Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264edd2e3a | ||
|
|
cd2cbe7be6 | ||
|
|
73182d0c0c | ||
|
|
cbe3a9a772 | ||
|
|
2e9b5c83c5 | ||
|
|
102397fe69 | ||
|
|
68a192e430 | ||
|
|
ccd5074c90 | ||
|
|
2b4cf20d75 | ||
|
|
cef2f77149 | ||
|
|
68b6417149 | ||
|
|
c5719ef362 | ||
|
|
5e5b61987a | ||
|
|
c564566432 | ||
|
|
9188626051 | ||
|
|
7ae9ecdc36 | ||
|
|
1980d79eee | ||
|
|
9423f11567 | ||
|
|
576013d627 | ||
|
|
7c08138fe4 | ||
|
|
a7829d591a | ||
|
|
1ed4470fc2 | ||
|
|
7a16b1b52f | ||
|
|
cf9d41b476 | ||
|
|
e820e7fecd | ||
|
|
4fdeb70a93 | ||
|
|
9a1f56c431 | ||
|
|
ade9dbb3ba | ||
|
|
d1a8596208 | ||
|
|
a3c09d9b67 | ||
|
|
a7f4c86464 | ||
|
|
0804566736 | ||
|
|
55363cbd18 | ||
|
|
868eb344f5 | ||
|
|
0b15497c09 | ||
|
|
d55f6cce23 | ||
|
|
3236efa846 | ||
|
|
d5a6db289d | ||
|
|
d8ccb5b733 | ||
|
|
0fa1a292c7 | ||
|
|
c3f044bd65 | ||
|
|
d2115364ae | ||
|
|
28cf0a342c | ||
|
|
d88e77f459 | ||
|
|
06eb433db5 | ||
|
|
1a6d349dad | ||
|
|
75d3534d82 | ||
|
|
ff041544f2 | ||
|
|
59265f78b6 | ||
|
|
6e0a278340 | ||
|
|
e651dd0d65 | ||
|
|
22c113fc29 | ||
|
|
e953ee7c5f | ||
|
|
413d86cc3c | ||
|
|
4f01fbdedb | ||
|
|
c3ba7aae0d | ||
|
|
54a30de0d8 | ||
|
|
c30ad1db07 | ||
|
|
7424cef7c6 | ||
|
|
01ccbb8e82 | ||
|
|
c339c2f1a1 | ||
|
|
e2ca3d79f9 | ||
|
|
c2364bf09e | ||
|
|
43e0621728 | ||
|
|
d1358cc00f | ||
|
|
865a69b54d | ||
|
|
9cc87c41bb | ||
|
|
8967966eef | ||
|
|
4a9a5c734b | ||
|
|
4e82e6c916 | ||
|
|
d4340a8fdd | ||
|
|
bb2f7a5841 | ||
|
|
1cb5314c53 | ||
|
|
4cea26b813 | ||
|
|
dbb74d9b60 | ||
|
|
9545aec7f4 | ||
|
|
3365ebcb3a | ||
|
|
6d79bd3331 | ||
|
|
24e114dee7 | ||
|
|
6c5db0c266 | ||
|
|
6f749ed21f | ||
|
|
d423826840 | ||
|
|
d3c63ccc6d | ||
|
|
b93eb3a691 | ||
|
|
6b871ce1e9 | ||
|
|
c981d89137 | ||
|
|
de5e790112 | ||
|
|
42138fc402 | ||
|
|
c5af476213 | ||
|
|
de853b90b3 | ||
|
|
759d9f5cea | ||
|
|
555d3f0a7d | ||
|
|
ecc847091c | ||
|
|
b187a478c9 | ||
|
|
c0547c45e5 | ||
|
|
4a3928df9f |
No files matched your search
+321
-5
@@ -6,6 +6,7 @@ Uses real data from UK Government Compare School Performance downloads.
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
@@ -15,7 +16,7 @@ import pandas as pd
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
@@ -33,8 +34,11 @@ from .data_loader import (
|
||||
get_supplementary_data,
|
||||
get_supplementary_data_batch,
|
||||
search_schools_typesense,
|
||||
suggest_schools_typesense,
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from . import flags
|
||||
from .places import build_place_registry
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
@@ -58,6 +62,12 @@ MAX_SLUG_LENGTH = 60
|
||||
# regenerate endpoint after a pipeline run.
|
||||
_sitemaps: dict[str, str] | None = None
|
||||
|
||||
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
||||
# never describe different corpora. Reset by the same admin endpoint.
|
||||
_place_registry: dict | None = None
|
||||
|
||||
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
text = text.lower()
|
||||
@@ -170,6 +180,14 @@ SITEMAP_CHUNK_SIZE = 10_000
|
||||
SITEMAP_CHILD_PREFIX = "/sitemaps"
|
||||
|
||||
|
||||
def get_place_registry() -> dict:
|
||||
"""The place registry, built once and cached for the process."""
|
||||
global _place_registry
|
||||
if _place_registry is None:
|
||||
_place_registry = build_place_registry(load_school_data())
|
||||
return _place_registry
|
||||
|
||||
|
||||
def _urlset(rows: list[str]) -> str:
|
||||
return "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -179,6 +197,44 @@ def _urlset(rows: list[str]) -> str:
|
||||
])
|
||||
|
||||
|
||||
def _place_url(place) -> str:
|
||||
"""The canonical path for a place. Two namespaces, per the spec.
|
||||
|
||||
Towns and localities share /schools/[place]; authorities take their own
|
||||
prefix because 67 town names collide with an authority name and neither
|
||||
set contains the other.
|
||||
"""
|
||||
if place.kind == "authority":
|
||||
return f"/schools/authority/{place.slug}"
|
||||
if place.kind == "outcode":
|
||||
return f"/schools/near/{place.slug}"
|
||||
return f"/schools/{place.slug}"
|
||||
|
||||
|
||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||
threshold on its own.
|
||||
|
||||
Phase is part of the query — "primary schools in beccles" — so each
|
||||
variant is its own indexable page. Submitting only the bare place URL left
|
||||
~950 of them reachable by nothing: absent from every sitemap, and not
|
||||
linked from the place page either.
|
||||
"""
|
||||
rows: list[str] = []
|
||||
for p in sorted(get_place_registry().values(), key=lambda p: (p.kind, p.slug)):
|
||||
if p.kind not in kinds:
|
||||
continue
|
||||
rows.append(_url_element(BASE_URL + _place_url(p)))
|
||||
# Which phases a place publishes is the registry's decision alone —
|
||||
# outcodes report none, because the spec gives them no phase route.
|
||||
# Repeating that rule here was how the page and the sitemap came to
|
||||
# disagree about which URLs exist.
|
||||
for phase in ("primary", "secondary"):
|
||||
if p.publishes_phase(phase):
|
||||
rows.append(_url_element(f"{BASE_URL}{_place_url(p)}/{phase}"))
|
||||
return rows
|
||||
|
||||
|
||||
def build_sitemaps() -> dict[str, str]:
|
||||
"""Build the sitemap index and every child, keyed by name."""
|
||||
df = load_school_data()
|
||||
@@ -196,6 +252,17 @@ def build_sitemaps() -> dict[str, str]:
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"schools-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# Separate children per family: Search Console reports coverage per
|
||||
# submitted sitemap, which is how the location layer's indexation is
|
||||
# measured apart from the school pages'.
|
||||
for label, kinds in (("places", ("town", "locality", "authority")),
|
||||
("outcodes", ("outcode",))):
|
||||
rows = _place_sitemap_rows(kinds)
|
||||
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"{label}-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# On a sitemap index, lastmod means "when this sitemap file last changed",
|
||||
# so generation time is the correct value here — unlike on a <url>, where
|
||||
# it would be a claim about content we cannot support.
|
||||
@@ -231,8 +298,101 @@ def clean_filter_values(series: pd.Series) -> list[str]:
|
||||
# SECURITY MIDDLEWARE & HELPERS
|
||||
# =============================================================================
|
||||
|
||||
# Rate limiter
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
def client_key(request: Request) -> str:
|
||||
"""The rate-limit bucket: the real caller, not the proxy in front of them.
|
||||
|
||||
`get_remote_address` reads request.client.host. In staging and production
|
||||
the backend has no published ports and sits on the internal network, so its
|
||||
only caller is the Next proxy — meaning every browser user on the site
|
||||
shared one bucket. Measured before this fix: 70 concurrent requests to
|
||||
/api/schools returned 60 OK and 10 refused.
|
||||
|
||||
CF-Connecting-IP first, because Cloudflare (in front of both environments)
|
||||
sets it on every origin request and *overwrites* any client-supplied value,
|
||||
which a parsed X-Forwarded-For chain does not guarantee. The XFF fallback is
|
||||
forgeable, but only by a caller already inside the Docker network, which is
|
||||
the one place nothing untrusted can reach.
|
||||
"""
|
||||
cf = request.headers.get("cf-connecting-ip")
|
||||
if cf:
|
||||
return cf.strip()
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
# Per-client limiter. Paired with the global ceiling below — the two do
|
||||
# different jobs and neither substitutes for the other.
|
||||
limiter = Limiter(key_func=client_key)
|
||||
|
||||
|
||||
# --- The ceiling no header can raise ----------------------------------------
|
||||
#
|
||||
# client_key trusts CF-Connecting-IP, and nothing in this process can tell an
|
||||
# edge-set header from an attacker-set one. That distinction can only be made
|
||||
# at Cloudflare, with Authenticated Origin Pulls or an origin firewall. A
|
||||
# caller reaching the origin directly could otherwise mint a fresh rate-limit
|
||||
# bucket per request and evade per-client limits entirely — which would make
|
||||
# correct keying a net regression against abuse, since the single shared bucket
|
||||
# it replaced at least capped everyone at 60/minute together.
|
||||
#
|
||||
# So per-client limits give fairness, and this gives the origin a hard total.
|
||||
# It does not make the header trustworthy; it bounds what trusting it can cost.
|
||||
# The header problem itself is closed at Cloudflare, not here.
|
||||
#
|
||||
# [window_start_monotonic, count], or None before the first request. A fixed
|
||||
# window is crude, which is right for a backstop: it has to be obviously
|
||||
# correct rather than fair.
|
||||
_global_window: Optional[list] = None
|
||||
|
||||
# The container healthcheck runs `curl http://localhost:80/api/data-info` from
|
||||
# inside the container. Starving it would fail the check, restart the
|
||||
# container, and turn a load spike into an outage loop — the ceiling exists to
|
||||
# protect the origin, not to kill it.
|
||||
_LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def exempt_from_ceiling(request: Request) -> bool:
|
||||
"""Whether the ceiling should ignore this request.
|
||||
|
||||
Its own function so the rule is testable without standing up a server —
|
||||
and so the healthcheck exemption is somewhere a reader can find it.
|
||||
"""
|
||||
if not request.url.path.startswith("/api/"):
|
||||
return True
|
||||
# The peer address, never the Host header: Host is set by the caller and
|
||||
# would hand every attacker an exemption.
|
||||
return (request.client.host if request.client else "") in _LOCAL_HOSTS
|
||||
|
||||
|
||||
class GlobalRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""A cap on total /api/ traffic, independent of any client identity."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
global _global_window
|
||||
|
||||
if exempt_from_ceiling(request):
|
||||
return await call_next(request)
|
||||
|
||||
now = time.monotonic()
|
||||
# One event loop, and no await between the read and the write, so this
|
||||
# sequence is atomic without a lock.
|
||||
if _global_window is None or now - _global_window[0] >= 60:
|
||||
_global_window = [now, 0]
|
||||
_global_window[1] += 1
|
||||
|
||||
if _global_window[1] > settings.global_rate_limit_per_minute:
|
||||
return JSONResponse(
|
||||
# Distinguishable from slowapi's per-client 429: an operator
|
||||
# reading logs has to be able to tell "one noisy client" from
|
||||
# "the origin is saturated".
|
||||
{"detail": "The service is at capacity. Please retry shortly."},
|
||||
status_code=429,
|
||||
headers={"Retry-After":
|
||||
str(max(1, int(60 - (now - _global_window[0]))))},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
@@ -290,6 +450,7 @@ CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
|
||||
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
|
||||
("/api/rankings", (60, 600, 3600)),
|
||||
("/api/compare", (60, 600, 3600)),
|
||||
("/api/suggest", (60, 3600, 86400)), # autosuggest
|
||||
("/api/schools", (30, 300, 1800)), # search list
|
||||
]
|
||||
|
||||
@@ -395,6 +556,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - startup and shutdown events."""
|
||||
global _sitemaps
|
||||
flags.init()
|
||||
print("Loading school data from marts...")
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
@@ -436,6 +598,10 @@ app.add_middleware(CacheAndETagMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(RequestSizeLimitMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512)
|
||||
# Added last, so it is outermost and refuses before anything downstream does
|
||||
# work. A ceiling that only applies after the expensive part has run is not a
|
||||
# ceiling.
|
||||
app.add_middleware(GlobalRateLimitMiddleware)
|
||||
|
||||
# CORS middleware - restricted for production
|
||||
app.add_middleware(
|
||||
@@ -742,11 +908,18 @@ async def get_school_details(request: Request, urn: int):
|
||||
"census": supplementary.get("census"),
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
"admission_distance": supplementary.get("admission_distance"),
|
||||
# Behind a flag, and withheld at the source rather than rendered-but-
|
||||
# hidden: this endpoint is public and unauthenticated, so a field left
|
||||
# in the payload is a published field. The key is absent, not null —
|
||||
# null would state that this school has no cut-off, which is a
|
||||
# different claim from "we are not publishing cut-offs".
|
||||
**({"admission_distance": supplementary.get("admission_distance")}
|
||||
if flags.is_enabled("admission_distance") else {}),
|
||||
"sen_detail": supplementary.get("sen_detail"),
|
||||
"phonics": supplementary.get("phonics"),
|
||||
"deprivation": supplementary.get("deprivation"),
|
||||
"finance": supplementary.get("finance"),
|
||||
"destinations": supplementary.get("destinations"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1117,6 +1290,145 @@ async def get_rankings(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/places")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def list_places(request: Request):
|
||||
"""Every published place. The sitemap and the link modules read this."""
|
||||
registry = get_place_registry()
|
||||
return {"places": [
|
||||
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
|
||||
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
|
||||
]}
|
||||
|
||||
|
||||
@app.get("/api/places/{kind}/{slug}")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_place(request: Request, kind: str, slug: str,
|
||||
phase: Optional[str] = None):
|
||||
"""One place: its schools ranked, and its local averages."""
|
||||
if kind not in VALID_PLACE_KINDS:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
registry = get_place_registry()
|
||||
place = registry.get(f"{kind}:{slug}")
|
||||
if place is None:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
df = load_latest_school_data()
|
||||
rows = df[df["urn"].isin(place.urns)]
|
||||
|
||||
if phase:
|
||||
wanted = PHASE_GROUPS.get(phase.lower())
|
||||
if wanted and "phase" in rows.columns:
|
||||
rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)]
|
||||
|
||||
# The metric the page shows, and averages.
|
||||
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
|
||||
|
||||
# Alphabetical, not by score. A place page is read by someone looking for
|
||||
# a school they can name, and scanning for it is what the order should
|
||||
# serve. /rankings is where the league-table ordering lives, and it keeps
|
||||
# sorting by metric.
|
||||
if "school_name" in rows.columns:
|
||||
rows = rows.sort_values("school_name", key=lambda c: c.str.lower())
|
||||
|
||||
averages = {
|
||||
m: (None if m not in rows.columns or rows[m].dropna().empty
|
||||
else float(rows[m].dropna().mean()))
|
||||
for m in ("rwm_expected_pct", "attainment_8_score")
|
||||
}
|
||||
|
||||
# dict.fromkeys, not a list: SCHOOL_COLUMNS already ends with latitude and
|
||||
# longitude, so concatenating them again selected each twice and pandas
|
||||
# dropped one of every duplicated pair with a "columns are not unique"
|
||||
# warning. Ordered de-duplication keeps the column order and the warning
|
||||
# cannot come back.
|
||||
#
|
||||
# nursery_provision and parliamentary_constituency are not in
|
||||
# SCHOOL_COLUMNS and the place table shows both. The `in rows.columns`
|
||||
# guard is what keeps a mart the pipeline has not rebuilt working: those
|
||||
# two are the optional GIAS columns data_loader degrades to NULL.
|
||||
cols = [c for c in dict.fromkeys(
|
||||
SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
||||
"nursery_provision",
|
||||
"parliamentary_constituency",
|
||||
"rwm_expected_pct", "attainment_8_score",
|
||||
"total_pupils"])
|
||||
if c in rows.columns]
|
||||
|
||||
return {
|
||||
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
|
||||
"count": len(place.urns),
|
||||
"parent_authority": place.parent_authority,
|
||||
# Every authority the place meaningfully sits in. SW19 is
|
||||
# mostly Merton but partly Wandsworth; naming one asserts
|
||||
# something false.
|
||||
#
|
||||
# The slug is null where that authority has no page of its
|
||||
# own: City of London and the Isles of Scilly hold fewer
|
||||
# schools than the threshold. Naming them is still right;
|
||||
# linking them would be a 404.
|
||||
"authorities": [
|
||||
{"name": name,
|
||||
"slug": (_slugify(name)
|
||||
if f"authority:{_slugify(name)}" in registry
|
||||
else None),
|
||||
"count": n}
|
||||
for name, n in place.authorities
|
||||
],
|
||||
# Only phases that clear the threshold, so the page links
|
||||
# variants that exist rather than 404s.
|
||||
"phases": [ph for ph in ("primary", "secondary")
|
||||
if place.publishes_phase(ph)]},
|
||||
"schools": clean_for_json(rows[cols]),
|
||||
"averages": averages,
|
||||
}
|
||||
|
||||
|
||||
# Two characters. One is not a query — it matches thousands of schools and the
|
||||
# response is useless, so it is not worth a round trip.
|
||||
SUGGEST_MIN_QUERY = 2
|
||||
|
||||
|
||||
@app.get("/api/suggest")
|
||||
@limiter.limit("120/minute")
|
||||
async def suggest_schools(
|
||||
request: Request,
|
||||
q: str = Query("", max_length=100),
|
||||
limit: int = Query(8, ge=1, le=20),
|
||||
):
|
||||
"""School name suggestions, from Typesense alone.
|
||||
|
||||
Deliberately not a mode of /api/schools: that path filters and sorts the
|
||||
full in-memory DataFrame, which is far too expensive to run per keystroke.
|
||||
|
||||
Nothing here returns an error for ordinary input. A short query, no
|
||||
matches, or Typesense being unreachable are all 200 with an empty list —
|
||||
a dropdown that quietly does not appear is the right failure for a
|
||||
keystroke path, and there is no DataFrame fallback because the 25,000-row
|
||||
substring scan is precisely what this endpoint exists to avoid.
|
||||
|
||||
120/minute rather than the default 60: a 200 ms debounce makes typing
|
||||
legitimately bursty.
|
||||
"""
|
||||
query = q.strip()
|
||||
if len(query) < SUGGEST_MIN_QUERY:
|
||||
return {"suggestions": []}
|
||||
return {"suggestions": suggest_schools_typesense(query, limit)}
|
||||
|
||||
|
||||
@app.get("/api/flags")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_feature_flags(request: Request):
|
||||
"""Every declared flag and its current value.
|
||||
|
||||
Internal only. The Next proxy denies this path, because the response names
|
||||
every unreleased feature the codebase knows about — which is exactly what
|
||||
shipping dark is meant to keep quiet.
|
||||
"""
|
||||
return flags.all_flags()
|
||||
|
||||
|
||||
@app.get("/api/data-info")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_data_info(request: Request):
|
||||
@@ -1228,7 +1540,11 @@ async def regenerate_sitemap(
|
||||
_: bool = Depends(verify_admin_api_key),
|
||||
):
|
||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||
global _sitemaps
|
||||
global _sitemaps, _place_registry
|
||||
# Places and sitemap are rebuilt together — they read the same marts, and
|
||||
# letting them drift apart would submit URLs for places that no longer
|
||||
# exist.
|
||||
_place_registry = None
|
||||
_sitemaps = build_sitemaps()
|
||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||
return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)}
|
||||
|
||||
@@ -35,6 +35,11 @@ class Settings(BaseSettings):
|
||||
# Security
|
||||
admin_api_key: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
rate_limit_per_minute: int = 60 # Requests per minute per IP
|
||||
# A ceiling on total /api/ traffic, independent of any client identity.
|
||||
# client_key trusts headers only Cloudflare can vouch for, so a caller
|
||||
# reaching the origin directly could otherwise mint a fresh bucket per
|
||||
# request. See GlobalRateLimitMiddleware in backend/app.py.
|
||||
global_rate_limit_per_minute: int = 3000
|
||||
rate_limit_burst: int = 10 # Allow burst of requests
|
||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||
|
||||
@@ -42,6 +47,16 @@ class Settings(BaseSettings):
|
||||
typesense_url: str = "http://localhost:8108"
|
||||
typesense_api_key: str = ""
|
||||
|
||||
# Feature flags (Unleash). An empty unleash_url disables flags entirely and
|
||||
# every flag evaluates False — the correct behaviour for local development
|
||||
# and CI, and the reason no test needs a running Unleash.
|
||||
unleash_url: str = ""
|
||||
unleash_api_token: str = ""
|
||||
unleash_app_name: str = "schoolcompare-backend"
|
||||
# On a named volume, so a restart during an Unleash outage keeps
|
||||
# last-known state instead of reverting a released feature to dark.
|
||||
unleash_cache_directory: str = "/app/.unleash"
|
||||
|
||||
# Analytics
|
||||
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
FactKs4Destinations, FactKs5Destinations,
|
||||
)
|
||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||
from .schemas import SCHOOL_TYPE_MAP
|
||||
@@ -100,6 +101,58 @@ def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
|
||||
return []
|
||||
|
||||
|
||||
# The most a public endpoint will return in one response.
|
||||
SUGGEST_MAX_LIMIT = 20
|
||||
|
||||
# Fields a suggestion row carries, and the default when the document omits an
|
||||
# optional one. phase and school_type are optional in the Typesense schema.
|
||||
_SUGGEST_FIELDS = ("school_name", "local_authority", "postcode",
|
||||
"phase", "school_type")
|
||||
|
||||
|
||||
def suggest_schools_typesense(query: str, limit: int = 8) -> List[dict]:
|
||||
"""Autosuggest rows straight from Typesense. Never raises.
|
||||
|
||||
Returns documents rather than URNs, unlike search_schools_typesense, so the
|
||||
caller needs no DataFrame. Every field below is already in the index — see
|
||||
pipeline/scripts/sync_typesense.py — which is what makes this cheap enough
|
||||
to run per keystroke.
|
||||
"""
|
||||
client = _get_typesense_client()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
result = client.collections["schools"].documents.search({
|
||||
"q": query,
|
||||
"query_by": "school_name,local_authority",
|
||||
"per_page": max(1, min(limit, SUGGEST_MAX_LIMIT)),
|
||||
"typo_tokens_threshold": 1,
|
||||
})
|
||||
except Exception:
|
||||
# A dropdown that quietly stops appearing is the right failure here.
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for hit in result.get("hits", []) or []:
|
||||
doc = (hit or {}).get("document") or {}
|
||||
try:
|
||||
urn = int(doc["urn"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# Skip the row, keep the rest. Typesense declares urn as int32 so
|
||||
# this should be unreachable, but the index is a separate system
|
||||
# that something other than this code can reindex — and "never
|
||||
# raises" is a promise the keystroke path actually depends on.
|
||||
# Dropping one malformed document is right; blanking the whole
|
||||
# dropdown, or serving a suggestion pointing at /school/0, is not.
|
||||
logging.getLogger(__name__).warning(
|
||||
"skipping malformed suggestion document: %r", doc)
|
||||
continue
|
||||
row = {"urn": urn}
|
||||
row.update({f: str(doc.get(f, "") or "") for f in _SUGGEST_FIELDS})
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
|
||||
"""Convert cryptic school type codes to user-friendly names."""
|
||||
if not school_type:
|
||||
@@ -764,6 +817,218 @@ def _finance_dict(f) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# Destination measures that are totals DfE published itself, rather than one of
|
||||
# the categories that partition the cohort.
|
||||
_AGGREGATE_MEASURES = {"agg_sustained_education", "agg_sustained_all"}
|
||||
|
||||
|
||||
def _format_cohort_year(year) -> str | None:
|
||||
"""202223 -> '2022/23'.
|
||||
|
||||
The section has to date its own cohort. Destination measures run about two
|
||||
GCSE years behind the results shown above them on the same page, so an
|
||||
undated figure reads as stale data rather than as a different question.
|
||||
"""
|
||||
if not year:
|
||||
return None
|
||||
text = str(year)
|
||||
if len(text) == 6:
|
||||
return f"{text[:4]}/{text[4:6]}"
|
||||
if len(text) == 8:
|
||||
return f"{text[:4]}/{text[6:8]}"
|
||||
return text
|
||||
|
||||
|
||||
_PUPIL_GROUPS = ("disadvantaged", "other", "all")
|
||||
|
||||
|
||||
def _lone_hidden_groups(groups: dict) -> list:
|
||||
"""Pupil groups hiding exactly one category — solvable by subtraction."""
|
||||
return [
|
||||
key for key, group in groups.items()
|
||||
if sum(1 for c in group["categories"] if c["status"] == "suppressed") == 1
|
||||
]
|
||||
|
||||
|
||||
def _lone_hidden_categories(groups: dict) -> list:
|
||||
"""Categories hidden in exactly one of several pupil groups."""
|
||||
lone = []
|
||||
categories = {c["category"] for g in groups.values() for c in g["categories"]}
|
||||
for category in categories:
|
||||
found = [
|
||||
c for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == category
|
||||
]
|
||||
hidden = [c for c in found if c["status"] == "suppressed"]
|
||||
if len(hidden) == 1 and len(found) > 1:
|
||||
lone.append(category)
|
||||
return lone
|
||||
|
||||
|
||||
def disclosure_invariant_holds(groups: dict) -> bool:
|
||||
"""Every row and every column hides none, or at least two.
|
||||
|
||||
Public so the tests can assert it directly rather than re-deriving it.
|
||||
"""
|
||||
return not _lone_hidden_groups(groups) and not _lone_hidden_categories(groups)
|
||||
|
||||
|
||||
def _mask_for_disclosure(groups: dict) -> None:
|
||||
"""Withhold further cells until nothing suppressed can be solved for.
|
||||
|
||||
Not rendering a figure is not the same as not publishing it. This endpoint
|
||||
is public and unauthenticated, so anything left in the payload is
|
||||
published, whatever the UI chooses to draw — the same reasoning the
|
||||
admission_distance field carries in app.py.
|
||||
|
||||
Two identities let a caller solve for a withheld cell:
|
||||
|
||||
* within a pupil group, the categories sum to the cohort, so a group with
|
||||
exactly ONE suppressed category gives it away as cohort - sum(rest);
|
||||
* across groups, disadvantaged + other = all for every category, so a
|
||||
category suppressed in exactly ONE of the three gives itself away.
|
||||
|
||||
DfE's own answer is secondary suppression: withhold a second cell so the
|
||||
residual spans two unknowns and identifies neither.
|
||||
|
||||
Where no companion can do that — a sparse cohort whose every other category
|
||||
is `not_applicable`, which is common in special schools and alternative
|
||||
provision — there is nothing left to withhold, so the pupil group is
|
||||
DROPPED entirely. An earlier version simply gave up here and returned with
|
||||
the violation intact and no signal, which is the one outcome this function
|
||||
must never produce: a disclosure-control pass that fails silently is worse
|
||||
than none, because everything downstream trusts it.
|
||||
|
||||
Mutates `groups` in place. Guaranteed to return with
|
||||
disclosure_invariant_holds(groups) true.
|
||||
"""
|
||||
|
||||
def suppress(cell):
|
||||
if cell["status"] == "published":
|
||||
cell["status"] = "suppressed"
|
||||
cell["pupils"] = None
|
||||
cell["percentage"] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_companion(candidates) -> bool:
|
||||
"""Withhold a second cell so the residual spans two unknowns.
|
||||
|
||||
The companion must carry pupils. Suppressing a zero looks like
|
||||
secondary suppression and protects nothing: the residual still equals
|
||||
the original withheld figure exactly. Returns False when no cell can
|
||||
do the job, which escalates to dropping the group.
|
||||
"""
|
||||
published = [c for c in candidates if c["status"] == "published"]
|
||||
useful = sorted(
|
||||
(c for c in published if (c["pupils"] or 0) > 0),
|
||||
key=lambda c: c["pupils"],
|
||||
)
|
||||
if useful:
|
||||
return suppress(useful[0])
|
||||
# Every remaining cell is zero or not applicable: withholding any of
|
||||
# them leaves the residual equal to the original figure.
|
||||
return False
|
||||
|
||||
# Fixpoint: each new suppression can break the other identity. Terminates
|
||||
# because every pass either adds a suppression, drops a group, or stops.
|
||||
while not disclosure_invariant_holds(groups):
|
||||
changed = False
|
||||
|
||||
for category in _lone_hidden_categories(groups):
|
||||
siblings = [
|
||||
c for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == category
|
||||
]
|
||||
if add_companion(siblings):
|
||||
changed = True
|
||||
|
||||
for key in _lone_hidden_groups(groups):
|
||||
if add_companion(groups[key]["categories"]):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
continue
|
||||
|
||||
# Nothing left to withhold. Drop the groups that are still solvable,
|
||||
# and any category still solvable across the groups that remain.
|
||||
for key in _lone_hidden_groups(groups):
|
||||
del groups[key]
|
||||
changed = True
|
||||
|
||||
for category in _lone_hidden_categories(groups):
|
||||
for group in groups.values():
|
||||
for cell in group["categories"]:
|
||||
if cell["category"] == category and suppress(cell):
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
# Unreachable given the two escalations above, but a masking pass
|
||||
# must never spin or exit unsafely. Withhold everything.
|
||||
groups.clear()
|
||||
return
|
||||
|
||||
|
||||
def _destinations_block(rows: list) -> dict | None:
|
||||
"""Shape destination rows for one phase into the API's block.
|
||||
|
||||
Applies secondary suppression before returning, so no caller of this public
|
||||
endpoint can solve for a figure DfE withheld. See _mask_for_disclosure.
|
||||
|
||||
Aggregate measures are dropped entirely. DfE publishes them, and they would
|
||||
be useful for a "what is published for this group" fallback, but nothing
|
||||
renders them today and an aggregate spanning exactly one suppressed
|
||||
component names that component. An unused field that leaks is not a
|
||||
trade-off worth carrying — re-add them with their own guard if the fallback
|
||||
is ever built.
|
||||
|
||||
Deliberately computes no residual, no "remaining pupils" figure, and no
|
||||
total that would close a gap left by a suppressed category.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
years = [r["year"] for r in rows if r.get("year") is not None]
|
||||
if not years:
|
||||
return None
|
||||
latest_year = max(years)
|
||||
rows = [r for r in rows if r.get("year") == latest_year]
|
||||
|
||||
groups: dict = {}
|
||||
for row in rows:
|
||||
group = groups.setdefault(
|
||||
row["pupil_group"],
|
||||
{"cohort": row.get("cohort_pupils"), "categories": []},
|
||||
)
|
||||
measure = row["destination_measure"]
|
||||
published = row.get("status") == "published"
|
||||
# Belt and braces: percentage is derived from the same source cell as
|
||||
# pupils, but publishing one without the other would hand back the
|
||||
# cohort (pupils / percentage) and with it the residual.
|
||||
cell = {
|
||||
"category": measure,
|
||||
"pupils": row.get("pupils") if published else None,
|
||||
"percentage": row.get("percentage") if published else None,
|
||||
"status": row.get("status"),
|
||||
}
|
||||
if measure in _AGGREGATE_MEASURES:
|
||||
continue
|
||||
group["categories"].append(cell)
|
||||
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
_mask_for_disclosure(groups)
|
||||
|
||||
# Masking can empty the block entirely — a sparse cohort where no group
|
||||
# could be made safe. Return None so the section is absent rather than
|
||||
# rendering an empty shell.
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
return {"cohort_year": _format_cohort_year(latest_year), "groups": groups}
|
||||
|
||||
|
||||
def _empty_supplementary() -> dict:
|
||||
return {
|
||||
"ofsted": None,
|
||||
@@ -775,6 +1040,7 @@ def _empty_supplementary() -> dict:
|
||||
"phonics": None,
|
||||
"deprivation": None,
|
||||
"finance": None,
|
||||
"destinations": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -902,6 +1168,38 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
||||
result[f.urn]["finance"] = _finance_dict(f)
|
||||
_safe(_finance)
|
||||
|
||||
# Destinations — KS4 and 16-18. Both marts are long-format, so every row
|
||||
# for a URN is collected and _destinations_block picks the latest year and
|
||||
# shapes the pupil groups. A phase with no rows serialises as null rather
|
||||
# than an empty shell, so the frontend renders nothing rather than an empty
|
||||
# section.
|
||||
def _destinations():
|
||||
from collections import defaultdict
|
||||
|
||||
def _collect(model):
|
||||
per_urn = defaultdict(list)
|
||||
for r in db.query(model).filter(model.urn.in_(urns)).all():
|
||||
per_urn[r.urn].append({
|
||||
"year": r.year,
|
||||
"pupil_group": r.pupil_group,
|
||||
"destination_measure": r.destination_measure,
|
||||
"cohort_pupils": r.cohort_pupils,
|
||||
"pupils": r.pupils,
|
||||
"percentage": r.percentage,
|
||||
"status": r.status,
|
||||
})
|
||||
return per_urn
|
||||
|
||||
ks4_rows = _collect(FactKs4Destinations)
|
||||
ks5_rows = _collect(FactKs5Destinations)
|
||||
for urn in urns:
|
||||
ks4 = _destinations_block(ks4_rows.get(urn, []))
|
||||
ks5 = _destinations_block(ks5_rows.get(urn, []))
|
||||
result[urn]["destinations"] = (
|
||||
{"ks4": ks4, "ks5": ks5} if (ks4 or ks5) else None
|
||||
)
|
||||
_safe(_destinations)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Feature flags: what can be switched, and what is switched right now.
|
||||
|
||||
Ship-dark, not a kill switch. Flags let work merge and deploy without becoming
|
||||
visible; they are expected to flip about monthly, by a person, deliberately.
|
||||
Nothing here does percentage rollouts or user targeting — the site has no user
|
||||
identity to target.
|
||||
|
||||
Unleash holds the state. It does not hold the list. REGISTRY below is that
|
||||
list, and it exists for three reasons: the SDK evaluates an unknown flag to
|
||||
False, so without a registry that is an *undeclared* False, indistinguishable
|
||||
from a typo; /api/flags needs a key set to return when Unleash is unreachable;
|
||||
and a flag in the UI but not in the registry is orphaned and should be visibly
|
||||
so rather than quietly authoritative.
|
||||
|
||||
Every flag defaults to False. There is no per-flag default, because a flag that
|
||||
defaults on is a kill switch, and this is not one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A flag is temporary scaffolding. See test_a_flag_older_than_the_limit.
|
||||
MAX_FLAG_AGE_DAYS = 90
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Flag:
|
||||
# One string: the registry key, the Unleash flag name, and the JSON key in
|
||||
# /api/flags. snake_case, matching the API's existing convention. No case
|
||||
# transformation anywhere, so there is no mapping layer to get wrong.
|
||||
name: str
|
||||
description: str # one line: what turning this on reveals
|
||||
added: date # for the staleness tripwire
|
||||
|
||||
|
||||
REGISTRY: dict[str, Flag] = {
|
||||
f.name: f for f in (
|
||||
Flag(
|
||||
name="admission_distance",
|
||||
description=(
|
||||
"The last-distance-offered figure on the Admissions tile and "
|
||||
"the 'How far away are you?' section on school pages."
|
||||
),
|
||||
added=date(2026, 8, 23),
|
||||
),
|
||||
Flag(
|
||||
name="school_autosuggest",
|
||||
description=(
|
||||
"School name suggestions as you type in the main search box."
|
||||
),
|
||||
added=date(2026, 8, 26),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def init() -> None:
|
||||
"""Start the Unleash client, or log why flags are all off.
|
||||
|
||||
Called once from the app lifespan. Never raises: a flag system that can
|
||||
stop the API from booting is worse than one that is switched off.
|
||||
"""
|
||||
global _client
|
||||
if not settings.unleash_url or not settings.unleash_api_token:
|
||||
logger.warning(
|
||||
"Unleash is not configured (UNLEASH_URL / UNLEASH_API_TOKEN); "
|
||||
"every feature flag evaluates to False.")
|
||||
return
|
||||
|
||||
try:
|
||||
from UnleashClient import UnleashClient
|
||||
|
||||
_client = UnleashClient(
|
||||
url=settings.unleash_url,
|
||||
app_name=settings.unleash_app_name,
|
||||
custom_headers={"Authorization": settings.unleash_api_token},
|
||||
cache_directory=settings.unleash_cache_directory,
|
||||
refresh_interval=15,
|
||||
)
|
||||
_client.initialize_client()
|
||||
logger.info("Unleash client initialised against %s", settings.unleash_url)
|
||||
except Exception:
|
||||
# Fail closed and keep serving. The SDK also evaluates everything False
|
||||
# until its first successful sync, so this is the same direction.
|
||||
_client = None
|
||||
logger.exception("Unleash client failed to start; flags are all False.")
|
||||
|
||||
|
||||
def is_enabled(name: str) -> bool:
|
||||
"""Whether `name` is on. False for anything unknown, unreachable or broken."""
|
||||
if name not in REGISTRY:
|
||||
logger.error(
|
||||
"undeclared feature flag %r was evaluated; returning False. "
|
||||
"Add it to backend/flags.py REGISTRY or fix the name.", name)
|
||||
return False
|
||||
if _client is None:
|
||||
return False
|
||||
try:
|
||||
return bool(_client.is_enabled(
|
||||
name, fallback_function=lambda feature_name, context: False))
|
||||
except Exception:
|
||||
logger.exception("flag %r failed to evaluate; returning False", name)
|
||||
return False
|
||||
|
||||
|
||||
def all_flags() -> dict[str, bool]:
|
||||
"""Every declared flag and its current value. Serves /api/flags."""
|
||||
return {name: is_enabled(name) for name in REGISTRY}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Curated London localities, defined by the postcode districts they cover.
|
||||
|
||||
The GIAS `town` field puts 1,819 London schools under the single value
|
||||
"London", so it cannot answer "schools in Battersea" — a query that appears in
|
||||
the Search Console baseline. No single field can: parliamentary constituency
|
||||
gives Battersea but not Canary Wharf; postcodes.io's admin_ward gives Canary
|
||||
Wharf but not Battersea; neither gives Clapham or Shoreditch, which are postal
|
||||
and colloquial rather than administrative.
|
||||
|
||||
So this is curated. Where a locality ends is a judgement, not a fact, and a
|
||||
reviewable file is the honest place for a judgement. No new ingestion is
|
||||
needed — the corpus already carries postcodes.
|
||||
|
||||
This is the canonical copy. `pipeline/transform/seeds/locality_outcodes.csv`
|
||||
mirrors it for anyone querying the warehouse directly; the backend image does
|
||||
not contain `pipeline/`, which is why the module rather than the seed is
|
||||
canonical. Same arrangement as `backend/gias_codes.py`.
|
||||
|
||||
A locality whose outcodes hold fewer than MIN_SCHOOLS schools is not
|
||||
published, so a typo produces no page rather than an empty one. Places that
|
||||
fail that check are logged at startup, because a locality you meant to publish
|
||||
quietly not appearing is the failure worth hearing about.
|
||||
|
||||
Two rules for anything added here.
|
||||
|
||||
**Sub-borough districts only.** A London borough is a local authority and
|
||||
already has a page at /schools/authority/[la] covering all of its schools; a
|
||||
locality defined by two or three outcodes would be a partial, near-duplicate
|
||||
subset of it. Hackney, Islington, Greenwich and Ealing were all in the first
|
||||
draft for that reason and have been removed.
|
||||
|
||||
**The slug must not match a GIAS town.** "Richmond" did — GIAS has a Richmond
|
||||
in North Yorkshire with 37 schools — so the London one could never publish.
|
||||
The registry skips any locality that collides and logs it.
|
||||
"""
|
||||
|
||||
# slug -> (display name, outcodes)
|
||||
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"battersea": ("Battersea", ("SW11",)),
|
||||
"canary-wharf": ("Canary Wharf", ("E14",)),
|
||||
"clapham": ("Clapham", ("SW4",)),
|
||||
"shoreditch": ("Shoreditch", ("EC2A", "E1")),
|
||||
"peckham": ("Peckham", ("SE15",)),
|
||||
"brixton": ("Brixton", ("SW2", "SW9")),
|
||||
"camden-town": ("Camden Town", ("NW1",)),
|
||||
"wimbledon": ("Wimbledon", ("SW19",)),
|
||||
"putney": ("Putney", ("SW15",)),
|
||||
"fulham": ("Fulham", ("SW6",)),
|
||||
"chiswick": ("Chiswick", ("W4",)),
|
||||
"stratford": ("Stratford", ("E15",)),
|
||||
"walthamstow": ("Walthamstow", ("E17",)),
|
||||
"tooting": ("Tooting", ("SW17",)),
|
||||
"dulwich": ("Dulwich", ("SE21", "SE22")),
|
||||
}
|
||||
@@ -321,3 +321,48 @@ class Ks2NationalAverage(Base):
|
||||
gps_high_pct = Column(Float)
|
||||
gps_avg_score = Column(Float)
|
||||
science_expected_pct = Column(Float)
|
||||
|
||||
|
||||
class FactKs4Destinations(Base):
|
||||
"""KS4 leavers destinations — one row per URN, year, pupil group, measure.
|
||||
|
||||
Long format rather than wide because pupil_group is a real third dimension.
|
||||
`status` is load-bearing: 'suppressed' means DfE withheld a figure it
|
||||
considered disclosive and the page must print "withheld"; 'not_applicable'
|
||||
means the measure does not apply and the page must print nothing. `pupils`
|
||||
is null for both, so collapsing status to a null check loses the
|
||||
difference — and the categories sum to the cohort, so a consumer that
|
||||
treats a withheld cell as zero republishes what DfE hid.
|
||||
"""
|
||||
__tablename__ = "fact_ks4_destinations"
|
||||
__table_args__ = (
|
||||
Index("ix_ks4_dest_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
pupil_group = Column(String(20), primary_key=True)
|
||||
destination_measure = Column(String(40), primary_key=True)
|
||||
cohort_pupils = Column(Integer)
|
||||
pupils = Column(Integer)
|
||||
percentage = Column(Float)
|
||||
status = Column(String(20))
|
||||
|
||||
|
||||
class FactKs5Destinations(Base):
|
||||
"""16-18 study leavers destinations — same grain as FactKs4Destinations."""
|
||||
__tablename__ = "fact_ks5_destinations"
|
||||
__table_args__ = (
|
||||
Index("ix_ks5_dest_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
pupil_group = Column(String(20), primary_key=True)
|
||||
destination_measure = Column(String(40), primary_key=True)
|
||||
cohort_pupils = Column(Integer)
|
||||
pupils = Column(Integer)
|
||||
percentage = Column(Float)
|
||||
status = Column(String(20))
|
||||
@@ -0,0 +1,314 @@
|
||||
"""The place registry: what places the site publishes, and what is in each.
|
||||
|
||||
One module owns this question. The pages, the sitemap and the internal-link
|
||||
modules all read from here, so the threshold and the collision rules exist in
|
||||
exactly one place and are testable without a browser or a database.
|
||||
|
||||
Two namespaces, never one. 67 viable town names collide with a local
|
||||
authority name, and the authority is the larger set in only 43 of them —
|
||||
postal towns cross authority boundaries, so neither can absorb the other.
|
||||
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Five schools with publishable data. Below this a place has nothing to say
|
||||
# that a list of schools does not, and publishing it is index bloat.
|
||||
MIN_SCHOOLS = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Place:
|
||||
kind: str # "town" | "locality" | "authority" | "outcode"
|
||||
slug: str
|
||||
name: str
|
||||
urns: tuple[int, ...]
|
||||
parent_authority: str | None # authority NAME, for the 301 target
|
||||
# Every authority the place meaningfully sits in, largest first. A quarter
|
||||
# of outcodes and a third of towns straddle a boundary — SW19 is mostly
|
||||
# Merton but partly Wandsworth — so naming only one asserts something
|
||||
# false. parent_authority stays single because a redirect needs one
|
||||
# target; this is what the page shows.
|
||||
authorities: tuple[tuple[str, int], ...] = ()
|
||||
# URNs per phase, so the per-phase threshold can be applied without
|
||||
# re-querying. A place with 30 primaries and 2 secondaries publishes a
|
||||
# primary variant and no secondary one.
|
||||
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
||||
|
||||
def publishes_phase(self, phase: str) -> bool:
|
||||
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.slug}"
|
||||
|
||||
|
||||
def _publishable_urns(df) -> set[int]:
|
||||
"""URNs with something a page could state, deduplicated across years."""
|
||||
from backend.app import _PUBLISHABLE_FIELDS
|
||||
|
||||
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||
if not cols:
|
||||
return set()
|
||||
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
||||
|
||||
|
||||
# The measure a phase page is built around. A page with no results in this
|
||||
# column has nothing a list of school names does not already give.
|
||||
_PHASE_METRIC = {
|
||||
"primary": "rwm_expected_pct",
|
||||
"secondary": "attainment_8_score",
|
||||
}
|
||||
|
||||
|
||||
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
|
||||
"""URNs per phase, counting only schools with a result for that phase.
|
||||
|
||||
Not merely "publishable". A school with an Ofsted grade and no results is
|
||||
worth a page of its own and belongs in the place list, but it cannot
|
||||
populate a phase page's results column — and the threshold is there to ask
|
||||
whether that column will have anything in it.
|
||||
|
||||
Counting publishable schools instead let /schools/kent/primary publish
|
||||
with none of its five rows carrying a result, and left 44 phase pages
|
||||
majority-blank. It is the same rule as "no page without a local average",
|
||||
which was never extended per phase.
|
||||
|
||||
All-through schools count toward both phases, matching the PHASE_GROUPS
|
||||
mapping the search filters already use.
|
||||
"""
|
||||
from backend.app import PHASE_GROUPS
|
||||
|
||||
if "phase" not in group.columns:
|
||||
return {}
|
||||
lowered = group["phase"].fillna("").str.lower()
|
||||
|
||||
out: dict[str, tuple[int, ...]] = {}
|
||||
for phase in ("primary", "secondary"):
|
||||
wanted = PHASE_GROUPS.get(phase, set())
|
||||
subset = group[lowered.isin(wanted)]
|
||||
|
||||
# The page lists every school of the phase; the threshold counts only
|
||||
# those carrying a result, so a mostly-empty table never publishes.
|
||||
metric = _PHASE_METRIC[phase]
|
||||
with_result = (
|
||||
{int(u) for u in subset.loc[subset[metric].notna(), "urn"]}
|
||||
if metric in subset.columns else set()
|
||||
)
|
||||
if len(with_result & publishable) < MIN_SCHOOLS:
|
||||
continue
|
||||
|
||||
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
|
||||
if urns:
|
||||
out[phase] = urns
|
||||
return out
|
||||
|
||||
|
||||
# A place is described by an authority when it holds at least a tenth of the
|
||||
# schools, and at least two. GIAS carries occasional postcode errors — EN6
|
||||
# lists two Shropshire schools among fourteen in Hertfordshire — and a bare
|
||||
# "any authority present" rule would print those as though they were real.
|
||||
# There is deliberately no cap on how many are named. An earlier cut stopped
|
||||
# at three, which silently dropped the fourth in exactly the case where the
|
||||
# information matters most — a genuinely fragmented place. The share rule is
|
||||
# the only limit, and it already bounds the list at ten.
|
||||
_AUTHORITY_MIN_SHARE = 0.10
|
||||
_AUTHORITY_MIN_SCHOOLS = 2
|
||||
|
||||
|
||||
def _authorities(group) -> tuple[tuple[str, int], ...]:
|
||||
"""Authorities this place meaningfully sits in, largest first."""
|
||||
from backend.app import EXCLUDED_FILTER_VALUES
|
||||
|
||||
if "local_authority" not in group.columns:
|
||||
return ()
|
||||
counts = group["local_authority"].dropna().value_counts()
|
||||
total = int(counts.sum())
|
||||
if not total:
|
||||
return ()
|
||||
|
||||
kept = [
|
||||
(str(name), int(n)) for name, n in counts.items()
|
||||
if str(name) not in EXCLUDED_FILTER_VALUES
|
||||
and n >= _AUTHORITY_MIN_SCHOOLS
|
||||
and n / total >= _AUTHORITY_MIN_SHARE
|
||||
]
|
||||
# A place too small or too fragmented for the share rule still names its
|
||||
# largest authority, or the page would say nothing about where it is.
|
||||
if not kept:
|
||||
for name, n in counts.items():
|
||||
if str(name) not in EXCLUDED_FILTER_VALUES:
|
||||
return ((str(name), int(n)),)
|
||||
return ()
|
||||
return tuple(kept)
|
||||
|
||||
|
||||
def _parent_authority(authorities: tuple[tuple[str, int], ...]) -> str | None:
|
||||
"""The 301 target: the largest authority a place sits in.
|
||||
|
||||
Derived from `authorities` rather than computed separately. The first cut
|
||||
used `mode()` here while `authorities` used `value_counts()`, and on an
|
||||
exact tie pandas does not guarantee the two pick the same name — so the
|
||||
redirect could have pointed somewhere other than the authority the page
|
||||
named first. One computation, one answer.
|
||||
|
||||
Deriving it also inherits the sentinel filter, so a place can no longer
|
||||
redirect to /schools/authority/does-not-apply.
|
||||
"""
|
||||
return authorities[0][0] if authorities else None
|
||||
|
||||
|
||||
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per distinct SLUG in `column` that clears the threshold.
|
||||
|
||||
Grouped by slug, not by raw value, because GIAS spells the same place
|
||||
several ways and they all resolve to one URL. Five town slugs come from
|
||||
more than one spelling: "London" (1,819 schools) and "LONDON" (12) both
|
||||
slugify to `london`; Weston-super-Mare is split 14/19 across two
|
||||
spellings; Newcastle-under-Lyme across three.
|
||||
|
||||
Grouping by raw value meant the later group simply overwrote the earlier
|
||||
one in this dict — so /schools/london could have shown twelve schools
|
||||
instead of 1,819, silently and depending on row order.
|
||||
|
||||
The display name is the most common spelling, which is the one a reader
|
||||
expects to see.
|
||||
"""
|
||||
from backend.app import _slugify
|
||||
|
||||
if column not in df.columns:
|
||||
return {}
|
||||
|
||||
working = df.assign(_slug=df[column].map(
|
||||
lambda v: _slugify(str(v).strip()) if isinstance(v, str) and v.strip() else None))
|
||||
working = working[working["_slug"].notna() & (working["_slug"] != "")]
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for slug, group in working.groupby("_slug"):
|
||||
slug = str(slug)
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
spellings = group[column].dropna().value_counts()
|
||||
if spellings.empty:
|
||||
continue
|
||||
name = str(spellings.index[0]).strip()
|
||||
authorities = () if kind == "authority" else _authorities(group)
|
||||
place = Place(
|
||||
kind=kind, slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(authorities),
|
||||
authorities=authorities,
|
||||
phase_urns=_phase_urns(group, publishable),
|
||||
)
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
||||
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
||||
|
||||
|
||||
def _outcode(postcode) -> str | None:
|
||||
if not isinstance(postcode, str):
|
||||
return None
|
||||
m = _OUTCODE_RE.match(postcode.upper().strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per postcode district clearing the threshold.
|
||||
|
||||
These carry no phase variants: nobody searches "primary schools in SW11",
|
||||
so the spec gives them no /primary or /secondary route. `phase_urns` is
|
||||
left empty rather than computed and then filtered downstream — the
|
||||
registry is the one place that decides which phases a place publishes,
|
||||
and the page links whatever it reports.
|
||||
|
||||
Computing them here put a link to a route that does not exist on every one
|
||||
of the 1,720 outcode pages.
|
||||
"""
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
working = working[working["_oc"].notna()]
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for oc, group in working.groupby("_oc"):
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
authorities = _authorities(group)
|
||||
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
||||
urns=urns, parent_authority=_parent_authority(authorities),
|
||||
authorities=authorities)
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def _locality_places(df, publishable: set[int],
|
||||
town_slugs: set[str]) -> dict[str, Place]:
|
||||
"""One Place per curated locality clearing the threshold."""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
if slug in town_slugs:
|
||||
# Skip, do not raise. The guard exists so a locality never
|
||||
# silently shadows a town — skipping achieves that, and the error
|
||||
# log makes it loud.
|
||||
#
|
||||
# Raising here took down sitemap generation for all 25,000 school
|
||||
# pages when "richmond" met the GIAS town Richmond in North
|
||||
# Yorkshire. Worse, GIAS town names change without any code change,
|
||||
# so a raise means curated data can break the site spontaneously.
|
||||
# A curation mistake must cost one page, not the sitemap.
|
||||
logger.error(
|
||||
"locality %r collides with the published town of the same "
|
||||
"slug and has been skipped; rename it or remove it", slug)
|
||||
continue
|
||||
group = working[working["_oc"].isin(outcodes)]
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
# Not an error — a locality can legitimately be too small. Logged
|
||||
# because one you meant to publish quietly vanishing is the
|
||||
# failure worth hearing about.
|
||||
logger.warning(
|
||||
"locality %s (%s) has %d publishable schools, below the "
|
||||
"threshold of %d - not published",
|
||||
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
||||
continue
|
||||
authorities = _authorities(group)
|
||||
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(authorities),
|
||||
authorities=authorities,
|
||||
phase_urns=_phase_urns(group, publishable))
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def build_place_registry(df) -> dict[str, Place]:
|
||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||
if df.empty or "urn" not in df.columns:
|
||||
return {}
|
||||
|
||||
publishable = _publishable_urns(df)
|
||||
registry: dict[str, Place] = {}
|
||||
registry.update(_group(df, "local_authority", "authority", publishable))
|
||||
|
||||
towns = _group(df, "town", "town", publishable)
|
||||
registry.update(towns)
|
||||
|
||||
town_slugs = {p.slug for p in towns.values()}
|
||||
registry.update(_locality_places(df, publishable, town_slugs))
|
||||
registry.update(_outcode_places(df, publishable))
|
||||
return registry
|
||||
@@ -0,0 +1,269 @@
|
||||
"""The destinations serialiser's contract.
|
||||
|
||||
Not rendering a figure is not the same as not publishing it. This endpoint is
|
||||
public and unauthenticated, so whatever the payload carries is published,
|
||||
whatever the UI draws. The categories sum to the cohort and the pupil groups
|
||||
sum to each other, so a lone suppressed cell is solvable by subtraction — the
|
||||
serialiser adds secondary suppression to prevent it.
|
||||
|
||||
See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
"""
|
||||
|
||||
from backend.data_loader import (
|
||||
_destinations_block, _format_cohort_year, disclosure_invariant_holds,
|
||||
)
|
||||
|
||||
|
||||
def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
|
||||
return {
|
||||
"pupil_group": group,
|
||||
"destination_measure": measure,
|
||||
"pupils": pupils,
|
||||
"percentage": percentage,
|
||||
"status": status,
|
||||
"cohort_pupils": cohort,
|
||||
"year": year,
|
||||
}
|
||||
|
||||
|
||||
def test_suppressed_category_serialises_as_suppressed_with_null_pupils():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "sixth_form_college", None, "suppressed"),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
cats = {c["category"]: c for c in block["groups"]["all"]["categories"]}
|
||||
assert cats["sixth_form_college"]["status"] == "suppressed"
|
||||
assert cats["sixth_form_college"]["pupils"] is None
|
||||
assert cats["sixth_form_college"]["percentage"] is None
|
||||
|
||||
|
||||
def test_published_category_keeps_its_figures():
|
||||
block = _destinations_block([
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
])
|
||||
cat = block["groups"]["all"]["categories"][0]
|
||||
assert cat["pupils"] == 75
|
||||
assert cat["percentage"] == 41.7
|
||||
assert cat["status"] == "published"
|
||||
|
||||
|
||||
def test_only_the_latest_year_is_served():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 60, "published", year=202122),
|
||||
_row("all", "school_sixth_form", 75, "published", year=202223),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert block["cohort_year"] == "2022/23"
|
||||
assert len(block["groups"]["all"]["categories"]) == 1
|
||||
assert block["groups"]["all"]["categories"][0]["pupils"] == 75
|
||||
|
||||
|
||||
def test_all_three_pupil_groups_are_carried():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published"),
|
||||
_row("disadvantaged", "school_sixth_form", 17, "published", cohort=62),
|
||||
_row("other", "school_sixth_form", 58, "published", cohort=118),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert set(block["groups"]) == {"all", "disadvantaged", "other"}
|
||||
assert block["groups"]["disadvantaged"]["cohort"] == 62
|
||||
|
||||
|
||||
def test_cohort_year_is_reported_so_the_page_can_date_itself():
|
||||
block = _destinations_block([_row("all", "school_sixth_form", 75, "published")])
|
||||
assert block["cohort_year"] == "2022/23"
|
||||
|
||||
|
||||
def test_format_cohort_year_handles_the_six_digit_form():
|
||||
assert _format_cohort_year(202223) == "2022/23"
|
||||
assert _format_cohort_year(None) is None
|
||||
|
||||
|
||||
def test_empty_rows_yield_none_not_an_empty_shell():
|
||||
assert _destinations_block([]) is None
|
||||
|
||||
|
||||
# ── Disclosure control ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The rendering guards in lib/destinations.ts stop a withheld figure being
|
||||
# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
|
||||
# unauthenticated, so whatever the payload carries is published. These tests
|
||||
# are the ones that matter.
|
||||
|
||||
def _solve_residual(group):
|
||||
"""What any caller can work out: cohort minus everything published."""
|
||||
published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
return group["cohort"] - sum(published), len(hidden)
|
||||
|
||||
|
||||
def test_a_lone_suppressed_category_cannot_be_solved_for():
|
||||
"""Whitley Bay High School's real 2022/23 disadvantaged group: further
|
||||
education withheld, everything else published, cohort 41. Before secondary
|
||||
suppression the payload gave the answer away as 41 - 23 = 18."""
|
||||
rows = [
|
||||
_row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
|
||||
_row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
|
||||
_row("disadvantaged", "further_education", None, "suppressed", cohort=41),
|
||||
_row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
|
||||
_row("disadvantaged", "employment", 2, "published", cohort=41),
|
||||
_row("disadvantaged", "not_sustained", 3, "published", cohort=41),
|
||||
_row("disadvantaged", "not_captured", 2, "published", cohort=41),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["disadvantaged"]
|
||||
residual, hidden = _solve_residual(group)
|
||||
assert hidden >= 2, "a lone suppressed cell must gain a companion"
|
||||
assert residual != 18, "the withheld figure is recoverable from the payload"
|
||||
|
||||
|
||||
def test_every_group_hides_none_or_at_least_two_categories():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published"),
|
||||
_row("all", "sixth_form_college", None, "suppressed"),
|
||||
_row("all", "further_education", 61, "published"),
|
||||
_row("all", "apprenticeship", 8, "published"),
|
||||
_row("all", "employment", 6, "published"),
|
||||
_row("all", "not_sustained", 5, "published"),
|
||||
_row("all", "not_captured", 4, "published"),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
assert len(hidden) >= 2
|
||||
|
||||
|
||||
def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
|
||||
"""disadvantaged + other = all for every category, so a category withheld
|
||||
in exactly one of the three is recoverable from the other two."""
|
||||
rows = []
|
||||
for measure, a, d, o in [
|
||||
("school_sixth_form", 75, None, 58),
|
||||
("further_education", 61, 27, 34),
|
||||
("apprenticeship", 8, 4, 4),
|
||||
("employment", 6, 1, 5),
|
||||
("not_sustained", 5, 3, 2),
|
||||
("not_captured", 4, 2, 2),
|
||||
]:
|
||||
rows.append(_row("all", measure, a, "published", cohort=159))
|
||||
rows.append(_row("disadvantaged", measure, d,
|
||||
"published" if d is not None else "suppressed", cohort=37))
|
||||
rows.append(_row("other", measure, o, "published", cohort=122))
|
||||
|
||||
groups = _destinations_block(rows)["groups"]
|
||||
measures = {c["category"] for g in groups.values() for c in g["categories"]}
|
||||
assert len(measures) == 6, "the fixture's six measures must all be checked"
|
||||
|
||||
for measure in sorted(measures):
|
||||
hidden = sum(
|
||||
1 for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == measure and c["status"] == "suppressed"
|
||||
)
|
||||
# The invariant is "none, or at least two" — not "at least two".
|
||||
assert hidden != 1, f"{measure} is solvable across the pupil groups"
|
||||
|
||||
|
||||
def test_a_suppressed_cell_never_keeps_its_percentage():
|
||||
"""percentage / pupils would hand back the cohort, and with it the residual."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
|
||||
_row("all", "further_education", 61, "published", percentage=33.9),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
for cell in group["categories"]:
|
||||
if cell["status"] != "published":
|
||||
assert cell["pupils"] is None
|
||||
assert cell["percentage"] is None
|
||||
|
||||
|
||||
def test_aggregates_are_not_served():
|
||||
"""An aggregate spanning exactly one suppressed component names it, and
|
||||
nothing renders them today."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published"),
|
||||
_row("all", "agg_sustained_all", 171, "published"),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
||||
assert "aggregates" not in group
|
||||
|
||||
|
||||
def test_a_fully_published_group_is_left_alone():
|
||||
"""Secondary suppression must not cost anything where nothing is withheld —
|
||||
this is the all-pupils view on every mainstream secondary."""
|
||||
rows = [
|
||||
_row("all", m, p, "published")
|
||||
for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
|
||||
("further_education", 61), ("apprenticeship", 8),
|
||||
("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
assert all(c["status"] == "published" for c in group["categories"])
|
||||
assert len(group["categories"]) == 7
|
||||
|
||||
|
||||
def test_the_invariant_is_asserted_directly_not_re_derived():
|
||||
"""A group with one suppressed category and nothing else to withhold."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=9),
|
||||
_row("all", "sixth_form_college", None, "not_applicable", cohort=9),
|
||||
_row("all", "further_education", None, "not_applicable", cohort=9),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert block is None or disclosure_invariant_holds(block["groups"])
|
||||
|
||||
|
||||
def test_a_sparse_cohort_with_no_companion_drops_the_group():
|
||||
"""Special schools and AP routinely have one suppressed category and every
|
||||
other one not applicable. There is nothing left to withhold, so the group
|
||||
goes — an earlier version returned here with the violation intact."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=9),
|
||||
_row("all", "sixth_form_college", None, "not_applicable", cohort=9),
|
||||
_row("all", "further_education", None, "not_applicable", cohort=9),
|
||||
_row("all", "apprenticeship", None, "not_applicable", cohort=9),
|
||||
_row("all", "employment", None, "not_applicable", cohort=9),
|
||||
_row("all", "not_sustained", None, "not_applicable", cohort=9),
|
||||
_row("all", "not_captured", None, "not_applicable", cohort=9),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert block is None or "all" not in block["groups"], (
|
||||
"a group that cannot be made safe must not be served"
|
||||
)
|
||||
|
||||
|
||||
def test_zeros_are_not_treated_as_a_usable_companion():
|
||||
"""Suppressing a zero protects nothing — the residual is unchanged. With
|
||||
only zeros available the group must be dropped, not falsely 'fixed'."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=5),
|
||||
_row("all", "sixth_form_college", 0, "published", cohort=5),
|
||||
_row("all", "further_education", 0, "published", cohort=5),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
if block and "all" in block["groups"]:
|
||||
group = block["groups"]["all"]
|
||||
published = sum(c["pupils"] for c in group["categories"]
|
||||
if c["pupils"] is not None)
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
assert len(hidden) != 1, "a zero companion leaves the figure solvable"
|
||||
assert group["cohort"] - published != 5
|
||||
|
||||
|
||||
def test_masking_always_terminates_in_a_safe_state():
|
||||
"""Exhaustive over every suppression pattern of a four-category group."""
|
||||
from itertools import product
|
||||
MEASURES = ["school_sixth_form", "sixth_form_college",
|
||||
"further_education", "apprenticeship"]
|
||||
for statuses in product(["published", "suppressed", "not_applicable"],
|
||||
repeat=len(MEASURES)):
|
||||
rows = [
|
||||
_row("all", m, 3 if st == "published" else None, st, cohort=12)
|
||||
for m, st in zip(MEASURES, statuses)
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
if block is None:
|
||||
continue
|
||||
assert disclosure_invariant_holds(block["groups"]), (
|
||||
f"invariant broken for {statuses}"
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for the feature flag layer (spec 2026-08-23).
|
||||
|
||||
None of these need a running Unleash. That is the point: an unset UNLEASH_URL
|
||||
means every flag is False, which is what local development and CI get.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from backend import flags
|
||||
|
||||
|
||||
def test_every_declared_flag_is_keyed_by_its_own_name():
|
||||
# One string is the registry key, the Unleash flag name and the JSON key.
|
||||
# A mismatch here would mean the UI toggles a flag the code never reads.
|
||||
for key, flag in flags.REGISTRY.items():
|
||||
assert key == flag.name
|
||||
|
||||
|
||||
def test_flag_names_are_snake_case():
|
||||
# Matches the API's existing convention (admission_distance,
|
||||
# rwm_expected_pct) so no case transformation exists to get wrong.
|
||||
for name in flags.REGISTRY:
|
||||
assert name == name.lower()
|
||||
assert "-" not in name and " " not in name
|
||||
|
||||
|
||||
def test_an_unconfigured_client_evaluates_every_flag_false(monkeypatch):
|
||||
monkeypatch.setattr(flags, "_client", None)
|
||||
for name in flags.REGISTRY:
|
||||
assert flags.is_enabled(name) is False
|
||||
|
||||
|
||||
def test_an_undeclared_flag_is_false_rather_than_an_error(monkeypatch):
|
||||
# A typo'd flag name must not raise in a request path. It is logged as an
|
||||
# error, because an undeclared flag is always a bug.
|
||||
monkeypatch.setattr(flags, "_client", None)
|
||||
assert flags.is_enabled("no_such_flag") is False
|
||||
|
||||
|
||||
def test_an_exploding_client_is_false_rather_than_a_500(monkeypatch):
|
||||
class Boom:
|
||||
def is_enabled(self, *a, **kw):
|
||||
raise RuntimeError("unleash is on fire")
|
||||
|
||||
monkeypatch.setattr(flags, "_client", Boom())
|
||||
name = next(iter(flags.REGISTRY))
|
||||
assert flags.is_enabled(name) is False
|
||||
|
||||
|
||||
def test_all_flags_reports_every_declared_flag(monkeypatch):
|
||||
monkeypatch.setattr(flags, "_client", None)
|
||||
assert set(flags.all_flags()) == set(flags.REGISTRY)
|
||||
assert all(v is False for v in flags.all_flags().values())
|
||||
|
||||
|
||||
def test_a_flag_older_than_the_limit_fails_this_test():
|
||||
"""A tripwire, not an assertion about correctness.
|
||||
|
||||
Flags are temporary scaffolding and the failure mode of every flag system
|
||||
is accumulation. This fails on the day a flag turns 90, on whatever PR
|
||||
happens to be open — which is the point: someone has to decide.
|
||||
|
||||
To fix: delete the flag and the branches that read it, or, if it genuinely
|
||||
still needs to exist, move its `added` date and say why in the commit.
|
||||
"""
|
||||
stale = [
|
||||
f.name for f in flags.REGISTRY.values()
|
||||
if date.today() - f.added > timedelta(days=flags.MAX_FLAG_AGE_DAYS)
|
||||
]
|
||||
assert not stale, (
|
||||
f"Flags older than {flags.MAX_FLAG_AGE_DAYS} days: {stale}. "
|
||||
"Remove the flag and the code branches it guards, or move its `added` "
|
||||
"date deliberately."
|
||||
)
|
||||
|
||||
|
||||
def _client():
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_module
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_the_flags_endpoint_lists_every_declared_flag(monkeypatch):
|
||||
monkeypatch.setattr(flags, "_client", None)
|
||||
body = _client().get("/api/flags").json()
|
||||
assert set(body) == set(flags.REGISTRY)
|
||||
|
||||
|
||||
def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatch):
|
||||
# The endpoint must still answer. A frontend that cannot read flags renders
|
||||
# everything dark, which is right; one that gets a 500 renders nothing.
|
||||
monkeypatch.setattr(flags, "_client", None)
|
||||
res = _client().get("/api/flags")
|
||||
assert res.status_code == 200
|
||||
assert all(v is False for v in res.json().values())
|
||||
|
||||
|
||||
def _school_payload(monkeypatch, *, flag_on: bool):
|
||||
"""Fetch one school's payload with the distance flag forced on or off.
|
||||
|
||||
The DataFrame shape is copied from test_school_details.py rather than
|
||||
minimised: the endpoint reads a wide set of GIAS columns, and a trimmed
|
||||
frame fails for reasons that have nothing to do with flags.
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_module
|
||||
|
||||
df = pd.DataFrame([{
|
||||
"urn": 150275,
|
||||
"school_name": "West London Performing Arts Academy",
|
||||
"phase": "Secondary",
|
||||
"school_type": "Special post 16 institution",
|
||||
"trust_name": None,
|
||||
"religious_denomination": "Does not apply",
|
||||
"gender": None,
|
||||
"age_range": "16-25",
|
||||
"admissions_policy": None,
|
||||
"capacity": np.nan,
|
||||
"gias_total_pupils": np.nan,
|
||||
"headteacher_name": None,
|
||||
"website": None,
|
||||
"ofsted_grade": np.nan,
|
||||
"local_authority": "Ealing",
|
||||
"address": "268 Northfield Avenue, London, W5 4UB",
|
||||
"postcode": "W5 4UB",
|
||||
"latitude": 51.4986,
|
||||
"longitude": -0.3148,
|
||||
"year": np.nan,
|
||||
"total_pupils": np.nan,
|
||||
"eligible_pupils": np.nan,
|
||||
"rwm_expected_pct": np.nan,
|
||||
}])
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
# Two arguments: get_supplementary_data(db, urn). See backend/app.py.
|
||||
monkeypatch.setattr(
|
||||
app_module, "get_supplementary_data",
|
||||
lambda db, urn: {"admission_distance": {"distance_m": 772.49,
|
||||
"year": 2024}})
|
||||
monkeypatch.setattr(flags, "is_enabled", lambda name: flag_on)
|
||||
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
res = client.get("/api/schools/150275")
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def test_the_distance_field_is_absent_when_the_flag_is_off(monkeypatch):
|
||||
"""Absent, not null, and withheld at the source.
|
||||
|
||||
/api/schools/ is public and unauthenticated. Leaving a withheld field in
|
||||
the payload while declining to render it hands the record to anyone who
|
||||
opens the network tab — the reasoning already recorded in c9a1892.
|
||||
"""
|
||||
body = _school_payload(monkeypatch, flag_on=False)
|
||||
assert "admission_distance" not in body
|
||||
|
||||
|
||||
def test_the_distance_field_is_present_when_the_flag_is_on(monkeypatch):
|
||||
body = _school_payload(monkeypatch, flag_on=True)
|
||||
assert body["admission_distance"]["distance_m"] == 772.49
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Tests for the place registry (spec 2026-08-21).
|
||||
|
||||
The registry is built from the in-memory school DataFrame, so these build a
|
||||
small frame directly rather than touching a database.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
base = {
|
||||
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": np.nan,
|
||||
"phase": "Primary", "postcode": "AA1 1AA",
|
||||
}
|
||||
return pd.DataFrame([{**base, **r} for r in rows])
|
||||
|
||||
|
||||
def _town(n: int, town: str, la: str, start: int = 100000, **kw) -> list[dict]:
|
||||
"""`start` offsets the URNs so two calls can describe different schools —
|
||||
the Bedford case needs two authorities' worth of distinct URNs in one
|
||||
town."""
|
||||
return [
|
||||
{"urn": start + i, "school_name": f"{town} School {i}",
|
||||
"town": town, "local_authority": la, **kw}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_town_clearing_the_threshold_is_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
assert "town:brentwood" in reg
|
||||
assert reg["town:brentwood"].name == "Brentwood"
|
||||
assert len(reg["town:brentwood"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_town_below_the_threshold_is_not_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS - 1, "Crosby", "Sefton")))
|
||||
assert "town:crosby" not in reg
|
||||
|
||||
|
||||
def test_a_town_below_threshold_still_names_its_authority():
|
||||
# The route layer needs somewhere to 301 to.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS - 1, "Crosby", "Sefton") + _town(MIN_SCHOOLS, "Bootle", "Sefton")))
|
||||
assert "authority:sefton" in reg
|
||||
|
||||
|
||||
def test_town_and_authority_of_the_same_name_are_separate_places():
|
||||
# 67 real collisions. Neither set contains the other: Bedford the town has
|
||||
# 104 schools, Bedford the authority 86, because postal towns cross
|
||||
# authority boundaries.
|
||||
rows = (_town(MIN_SCHOOLS, "Bedford", "Bedford")
|
||||
+ _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire", start=200000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
town, authority = reg["town:bedford"], reg["authority:bedford"]
|
||||
assert set(town.urns) != set(authority.urns)
|
||||
assert len(town.urns) == MIN_SCHOOLS * 2 # both authorities' schools
|
||||
assert len(authority.urns) == MIN_SCHOOLS # only this authority's
|
||||
|
||||
|
||||
def test_schools_without_publishable_data_do_not_count_toward_the_threshold():
|
||||
rows = _town(MIN_SCHOOLS, "Ghosttown", "Nowhere")
|
||||
for r in rows:
|
||||
r["rwm_expected_pct"] = np.nan
|
||||
r["ofsted_grade"] = np.nan
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "town:ghosttown" not in reg
|
||||
|
||||
|
||||
def test_blank_town_is_ignored():
|
||||
rows = _town(MIN_SCHOOLS, "", "Essex")
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("town:") for k in reg)
|
||||
|
||||
|
||||
def test_a_school_is_counted_once_even_with_several_years_of_rows():
|
||||
rows = []
|
||||
for year in (202324, 202425):
|
||||
rows += [{**r, "year": year} for r in _town(MIN_SCHOOLS, "Beccles", "Suffolk")]
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert len(reg["town:beccles"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_groups_schools_by_outcode(monkeypatch):
|
||||
# The GIAS town field collapses 1,819 London schools into "London", so a
|
||||
# locality is defined by its postcode districts instead.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"battersea": ("Battersea", ("SW11",))})
|
||||
rows = _town(MIN_SCHOOLS, "London", "Wandsworth")
|
||||
for r in rows:
|
||||
r["postcode"] = "SW11 2AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["locality:battersea"].name == "Battersea"
|
||||
assert len(reg["locality:battersea"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_below_the_threshold_is_not_published(monkeypatch):
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"nowhere": ("Nowhere", ("ZZ99",))})
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "London", "Wandsworth")))
|
||||
assert "locality:nowhere" not in reg
|
||||
|
||||
|
||||
def test_a_locality_may_not_shadow_a_viable_town(monkeypatch, caplog):
|
||||
"""A colliding locality is skipped loudly, and the town survives.
|
||||
|
||||
This used to raise, which took down sitemap generation for all 25,000
|
||||
school pages the first time a curated slug met a real GIAS town. Curated
|
||||
data must not be able to break the site — and GIAS town names change with
|
||||
no code change at all, so the raise could fire spontaneously.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
assert "locality:brentwood" not in reg # skipped
|
||||
assert "town:brentwood" in reg # the town is untouched
|
||||
assert "brentwood" in caplog.text # and it was loud about it
|
||||
|
||||
|
||||
def test_a_locality_collision_does_not_break_the_rest_of_the_registry(monkeypatch):
|
||||
# The whole point of skipping rather than raising.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "authority:essex" in reg
|
||||
assert "outcode:cm13" in reg
|
||||
|
||||
|
||||
def test_outcode_places_are_built_from_postcodes():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["outcode:cm13"].name == "CM13"
|
||||
assert len(reg["outcode:cm13"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_malformed_postcodes_do_not_create_places():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "not a postcode"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("outcode:") for k in reg)
|
||||
|
||||
|
||||
def test_every_curated_locality_is_structurally_valid():
|
||||
# Guards the hand-maintained file: real slug, real name, real outcodes.
|
||||
import re
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
assert LOCALITY_OUTCODES, "the curated locality list must not be empty"
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
assert re.fullmatch(r"[a-z0-9-]+", slug), slug
|
||||
assert name.strip() == name and name, slug
|
||||
assert outcodes, f"{slug} has no outcodes"
|
||||
for oc in outcodes:
|
||||
assert re.fullmatch(r"[A-Z]{1,2}\d{1,2}[A-Z]?", oc), (slug, oc)
|
||||
|
||||
|
||||
def test_the_pipeline_seed_mirrors_the_canonical_module():
|
||||
"""Two copies with no drift guard is worse than one copy.
|
||||
|
||||
backend/localities.py is canonical because the backend image does not
|
||||
contain pipeline/. The seed exists so the warehouse can join on the same
|
||||
definitions, and this is what stops the two diverging — the same
|
||||
arrangement assert_gias_code_names_match_seed.sql gives gias_codes.
|
||||
"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
seed_path = (Path(__file__).resolve().parents[2]
|
||||
/ "pipeline/transform/seeds/locality_outcodes.csv")
|
||||
assert seed_path.exists(), f"missing seed mirror at {seed_path}"
|
||||
|
||||
seed = {
|
||||
row["locality_slug"]: (row["locality_name"],
|
||||
tuple(row["outcodes"].split("|")))
|
||||
for row in csv.DictReader(seed_path.open())
|
||||
}
|
||||
assert seed == LOCALITY_OUTCODES
|
||||
|
||||
|
||||
def test_no_curated_locality_names_a_london_borough():
|
||||
"""Boroughs are authorities and already have a page.
|
||||
|
||||
A locality defined by two or three outcodes inside a borough would be a
|
||||
partial, near-duplicate subset of that authority page — the exact
|
||||
thin-content failure the two-namespace design exists to avoid. Hackney,
|
||||
Islington, Greenwich and Ealing were all in the first draft.
|
||||
|
||||
Hardcoded rather than read from the corpus because this must fail in CI,
|
||||
where there is no database.
|
||||
"""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
boroughs = {
|
||||
"barking-and-dagenham", "barnet", "bexley", "brent", "bromley",
|
||||
"camden", "croydon", "ealing", "enfield", "greenwich", "hackney",
|
||||
"hammersmith-and-fulham", "haringey", "harrow", "havering",
|
||||
"hillingdon", "hounslow", "islington", "kensington-and-chelsea",
|
||||
"kingston-upon-thames", "lambeth", "lewisham", "merton", "newham",
|
||||
"redbridge", "richmond-upon-thames", "southwark", "sutton",
|
||||
"tower-hamlets", "waltham-forest", "wandsworth", "westminster",
|
||||
}
|
||||
named = boroughs & set(LOCALITY_OUTCODES)
|
||||
assert not named, (
|
||||
f"these are boroughs, not districts: {sorted(named)} - they already "
|
||||
"have an authority page covering every school"
|
||||
)
|
||||
|
||||
|
||||
def test_a_place_names_every_authority_it_straddles():
|
||||
"""SW19 is mostly Merton but partly Wandsworth.
|
||||
|
||||
A quarter of viable outcodes and a third of viable towns cross an
|
||||
authority boundary, so naming only the largest asserts something false.
|
||||
"""
|
||||
rows = (_town(26, "London", "Merton", start=300000)
|
||||
+ _town(7, "London", "Wandsworth", start=400000))
|
||||
for r in rows:
|
||||
r["postcode"] = "SW19 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
names = [n for n, _ in reg["outcode:sw19"].authorities]
|
||||
assert names == ["Merton", "Wandsworth"] # largest first
|
||||
assert dict(reg["outcode:sw19"].authorities)["Wandsworth"] == 7
|
||||
|
||||
|
||||
def test_the_redirect_target_stays_a_single_authority():
|
||||
# parent_authority and authorities do different jobs: a 301 needs one
|
||||
# target, the page needs the truth.
|
||||
rows = (_town(26, "London", "Merton", start=300000)
|
||||
+ _town(7, "London", "Wandsworth", start=400000))
|
||||
for r in rows:
|
||||
r["postcode"] = "SW19 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["outcode:sw19"].parent_authority == "Merton"
|
||||
|
||||
|
||||
def test_a_stray_authority_below_the_share_threshold_is_not_named():
|
||||
# GIAS carries postcode errors — EN6 lists two Shropshire schools among
|
||||
# fourteen in Hertfordshire. Printing those as though real would be worse
|
||||
# than omitting them.
|
||||
rows = (_town(30, "Barnet", "Hertfordshire", start=300000)
|
||||
+ _town(1, "Barnet", "Shropshire", start=400000))
|
||||
for r in rows:
|
||||
r["postcode"] = "EN6 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert [n for n, _ in reg["outcode:en6"].authorities] == ["Hertfordshire"]
|
||||
|
||||
|
||||
def test_a_sentinel_authority_is_never_named():
|
||||
rows = (_town(20, "London", "Merton", start=300000)
|
||||
+ _town(6, "London", "Does not apply", start=400000))
|
||||
for r in rows:
|
||||
r["postcode"] = "SW19 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert [n for n, _ in reg["outcode:sw19"].authorities] == ["Merton"]
|
||||
|
||||
|
||||
def test_a_place_always_names_at_least_one_authority():
|
||||
# Even when every authority is below the share threshold, the page has to
|
||||
# say where the place is.
|
||||
rows = []
|
||||
for i, la in enumerate(["A", "B", "C", "D", "E", "F", "G"]):
|
||||
rows += _town(1, "Fragmented", la, start=300000 + i * 100)
|
||||
reg = build_place_registry(_df(rows))
|
||||
place = reg.get("town:fragmented")
|
||||
assert place is not None
|
||||
assert len(place.authorities) == 1
|
||||
|
||||
|
||||
def test_every_qualifying_authority_is_named_with_no_cap():
|
||||
"""An earlier cut stopped at three, dropping the fourth silently.
|
||||
|
||||
That truncation bit exactly where the information matters most — a
|
||||
genuinely fragmented place — and nothing recorded it.
|
||||
"""
|
||||
rows = []
|
||||
for i, la in enumerate(["Hackney", "Lambeth", "Westminster", "Lewisham"]):
|
||||
rows += _town(3, "Fourway", la, start=300000 + i * 100)
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert len(reg["town:fourway"].authorities) == 4
|
||||
|
||||
|
||||
def test_the_redirect_target_is_the_authority_named_first():
|
||||
"""They were computed separately — mode() against value_counts() — and on
|
||||
an exact tie pandas does not guarantee the two agree."""
|
||||
rows = (_town(26, "London", "Merton", start=300000)
|
||||
+ _town(7, "London", "Wandsworth", start=400000))
|
||||
for r in rows:
|
||||
r["postcode"] = "SW19 1AA"
|
||||
place = build_place_registry(_df(rows))["outcode:sw19"]
|
||||
assert place.parent_authority == place.authorities[0][0]
|
||||
|
||||
|
||||
def test_a_place_never_redirects_to_a_sentinel_authority():
|
||||
# Deriving the parent from `authorities` inherits its sentinel filter.
|
||||
rows = (_town(6, "Someplace", "Does not apply", start=300000)
|
||||
+ _town(5, "Someplace", "Essex", start=400000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["town:someplace"].parent_authority == "Essex"
|
||||
|
||||
|
||||
def test_spellings_of_one_place_are_merged_not_overwritten():
|
||||
"""GIAS spells the same place several ways, and they share a URL.
|
||||
|
||||
"London" (1,819 schools) and "LONDON" (12) both slugify to `london`.
|
||||
Grouping by raw value let the later group overwrite the earlier one, so
|
||||
the page could have shown twelve schools instead of 1,819 — silently, and
|
||||
depending on row order.
|
||||
"""
|
||||
rows = (_town(6, "Weston-super-Mare", "North Somerset", start=300000)
|
||||
+ _town(5, "Weston-Super-Mare", "North Somerset", start=400000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert len(reg["town:weston-super-mare"].urns) == 11
|
||||
|
||||
|
||||
def test_the_merged_place_takes_its_most_common_spelling():
|
||||
rows = (_town(9, "Newcastle-under-Lyme", "Staffordshire", start=300000)
|
||||
+ _town(5, "NEWCASTLE-UNDER-LYME", "Staffordshire", start=400000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["town:newcastle-under-lyme"].name == "Newcastle-under-Lyme"
|
||||
|
||||
|
||||
def test_a_phase_page_needs_results_not_merely_publishable_schools():
|
||||
"""/schools/kent/primary published with none of its five rows scored.
|
||||
|
||||
The threshold counted schools that were publishable — a result OR an
|
||||
Ofsted grade — while the page exists for its results column. Forty-four
|
||||
phase pages were majority-blank; one had no results at all.
|
||||
"""
|
||||
rows = _town(MIN_SCHOOLS, "Kent", "Kent")
|
||||
for r in rows:
|
||||
r["rwm_expected_pct"] = np.nan # Ofsted only, no results
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
assert "town:kent" in reg # the place still publishes
|
||||
assert not reg["town:kent"].publishes_phase("primary")
|
||||
|
||||
|
||||
def test_a_phase_page_publishes_once_enough_schools_carry_a_result():
|
||||
rows = _town(MIN_SCHOOLS, "Beccles", "Suffolk")
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["town:beccles"].publishes_phase("primary")
|
||||
|
||||
|
||||
def test_a_publishing_phase_page_still_lists_its_unscored_schools():
|
||||
"""The threshold gates whether the page exists; it does not filter rows.
|
||||
|
||||
A parent looking up a school by name has to find it whether or not it
|
||||
published results.
|
||||
"""
|
||||
scored = _town(MIN_SCHOOLS, "Beccles", "Suffolk", start=300000)
|
||||
unscored = _town(2, "Beccles", "Suffolk", start=400000)
|
||||
for r in unscored:
|
||||
r["rwm_expected_pct"] = np.nan
|
||||
reg = build_place_registry(_df(scored + unscored))
|
||||
|
||||
place = reg["town:beccles"]
|
||||
assert place.publishes_phase("primary")
|
||||
assert len(place.phase_urns["primary"]) == MIN_SCHOOLS + 2
|
||||
|
||||
|
||||
def test_the_secondary_threshold_counts_its_own_metric():
|
||||
# A town full of scored primaries must not thereby publish a secondary page.
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not reg["town:brentwood"].publishes_phase("secondary")
|
||||
|
||||
|
||||
def test_an_outcode_publishes_no_phase_variants():
|
||||
"""There is no /schools/near/[outcode]/[phase] route, by design.
|
||||
|
||||
Nobody searches "primary schools in SW11", so the spec gives outcodes no
|
||||
phase variants. The registry computed them anyway, and the place page —
|
||||
which links whatever phases the registry reports — put two 404s on every
|
||||
outcode page in the site.
|
||||
|
||||
This is the single rule now: a kind with no phase route reports no phases,
|
||||
so neither the page nor the sitemap can offer one.
|
||||
"""
|
||||
rows = [{"urn": 500000 + i, "school_name": f"SW11 School {i}",
|
||||
"town": "London", "local_authority": "Wandsworth",
|
||||
"postcode": "SW11 1AA"} for i in range(MIN_SCHOOLS + 3)]
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
place = reg["outcode:sw11"]
|
||||
assert place.phase_urns == {}
|
||||
assert not place.publishes_phase("primary")
|
||||
assert not place.publishes_phase("secondary")
|
||||
|
||||
|
||||
def test_an_authority_still_publishes_phase_variants():
|
||||
"""Authorities keep theirs — "primary schools in Kent" is a real query,
|
||||
and /schools/authority/[la]/[phase] is the route that serves it."""
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Maidstone", "Kent")))
|
||||
assert reg["authority:kent"].publishes_phase("primary")
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for the places API (spec 2026-08-21)."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
|
||||
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 50.0 + i}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_registry_lists_each_published_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
|
||||
assert ("town", "brentwood") in slugs
|
||||
assert ("authority", "essex") in slugs
|
||||
assert ("outcode", "cm13") in slugs
|
||||
|
||||
|
||||
def test_registry_carries_a_count_per_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
town = next(p for p in body["places"] if p["slug"] == "brentwood")
|
||||
assert town["count"] == 6
|
||||
|
||||
|
||||
def test_place_detail_returns_its_schools_alphabetically(client):
|
||||
"""A place page is read by someone looking for a school they can name.
|
||||
|
||||
Scanning for it is what the order should serve, so the list is A-Z.
|
||||
/api/rankings is where the league-table ordering lives.
|
||||
"""
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
assert body["place"]["name"] == "Brentwood"
|
||||
names = [s["school_name"] for s in body["schools"]]
|
||||
assert names == sorted(names, key=str.lower)
|
||||
|
||||
|
||||
def test_place_ordering_ignores_case(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
names = [s["school_name"] for s in body["schools"]]
|
||||
# A capitalised name must not sort ahead of every lowercase one.
|
||||
assert names == sorted(names, key=str.lower)
|
||||
|
||||
|
||||
def test_the_rankings_endpoint_still_ranks_by_metric(client):
|
||||
# Alphabetical is a place-page decision, not a site-wide one.
|
||||
body = client.get("/api/rankings?metric=rwm_expected_pct&phase=primary").json()
|
||||
scores = [r["rwm_expected_pct"] for r in body.get("rankings", [])
|
||||
if r.get("rwm_expected_pct") is not None]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
def test_place_detail_carries_the_local_average(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
# 50..55 inclusive
|
||||
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
|
||||
|
||||
|
||||
def test_phase_filter_narrows_the_school_list(client):
|
||||
body = client.get("/api/places/town/brentwood?phase=secondary").json()
|
||||
assert body["schools"] == []
|
||||
|
||||
|
||||
def test_unknown_place_404s(client):
|
||||
assert client.get("/api/places/town/atlantis").status_code == 404
|
||||
|
||||
|
||||
def test_unknown_kind_404s(client):
|
||||
assert client.get("/api/places/planet/mars").status_code == 404
|
||||
|
||||
|
||||
def _straddling_df() -> pd.DataFrame:
|
||||
"""Eight schools in CM13: six in Essex, which has a page, and two in an
|
||||
authority too small to have one.
|
||||
|
||||
Two, not one: the registry ignores an authority holding a single school in
|
||||
a place, because GIAS carries occasional postcode errors."""
|
||||
df = _schools_df()
|
||||
extra = df.iloc[:2].copy()
|
||||
extra["urn"] = [200000, 200001]
|
||||
extra["school_name"] = ["Scilly School 0", "Scilly School 1"]
|
||||
extra["local_authority"] = "Isles Of Scilly"
|
||||
return pd.concat([df, extra], ignore_index=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def straddling_client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _straddling_df)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _straddling_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_an_outcode_reports_no_phases_because_it_has_no_phase_route(client):
|
||||
body = client.get("/api/places/outcode/cm13").json()
|
||||
assert body["place"]["phases"] == []
|
||||
|
||||
|
||||
def test_an_authority_reports_the_phases_it_publishes(client):
|
||||
body = client.get("/api/places/authority/essex").json()
|
||||
assert body["place"]["phases"] == ["primary"]
|
||||
|
||||
|
||||
def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_client):
|
||||
"""Two English authorities — City of London and the Isles of Scilly — hold
|
||||
fewer than the five schools a page needs, so they have no page.
|
||||
|
||||
Naming them is still right: the page says where the place is. Linking them
|
||||
would not be. A null slug is what tells the page to print the name plainly
|
||||
rather than invent a URL that 404s.
|
||||
"""
|
||||
body = straddling_client.get("/api/places/outcode/cm13").json()
|
||||
by_name = {a["name"]: a for a in body["place"]["authorities"]}
|
||||
assert by_name["Essex"]["slug"] == "essex"
|
||||
assert by_name["Isles Of Scilly"]["slug"] is None
|
||||
|
||||
|
||||
def _attributed_df() -> pd.DataFrame:
|
||||
"""The same town, with the four attributes the place table now shows."""
|
||||
df = _schools_df()
|
||||
df["age_range"] = "4-11"
|
||||
df["religious_denomination"] = "Church of England"
|
||||
df["nursery_provision"] = True
|
||||
df["parliamentary_constituency"] = "Brentwood and Ongar"
|
||||
return df
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def attributed_client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _attributed_df)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _attributed_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_place_detail_carries_the_attributes_the_table_shows(attributed_client):
|
||||
"""age_range and religious_denomination ride in on SCHOOL_COLUMNS.
|
||||
|
||||
nursery_provision and parliamentary_constituency do not, and the place
|
||||
table needs all four — a column the response cannot fill is a column of
|
||||
dashes on ~3,900 pages.
|
||||
"""
|
||||
body = attributed_client.get("/api/places/town/brentwood").json()
|
||||
school = body["schools"][0]
|
||||
assert school["age_range"] == "4-11"
|
||||
assert school["religious_denomination"] == "Church of England"
|
||||
assert school["nursery_provision"] is True
|
||||
assert school["parliamentary_constituency"] == "Brentwood and Ongar"
|
||||
|
||||
|
||||
def test_place_detail_survives_a_mart_without_the_optional_columns(client):
|
||||
"""The base fixture has neither column, as an unrebuilt mart does not.
|
||||
|
||||
data_loader degrades those to NULL rather than failing the load, so the
|
||||
endpoint must not assume they are present.
|
||||
"""
|
||||
res = client.get("/api/places/town/brentwood")
|
||||
assert res.status_code == 200
|
||||
assert "nursery_provision" not in res.json()["schools"][0]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""The rate-limit bucket must be the caller, not the proxy in front of them.
|
||||
|
||||
`get_remote_address` reads request.client.host. In staging and production the
|
||||
backend has no published ports and its only caller is the Next proxy, so that
|
||||
host is the Next container — one bucket for every browser user on the site.
|
||||
Measured before this fix: 70 concurrent requests, 60 served and 10 refused.
|
||||
"""
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from backend.app import client_key
|
||||
|
||||
|
||||
class _Req:
|
||||
"""Enough of a Request for the key function: headers and a client host."""
|
||||
|
||||
def __init__(self, headers: dict, host: str = "10.0.0.9"):
|
||||
self.headers = Headers(headers)
|
||||
self.client = type("C", (), {"host": host})()
|
||||
self.scope = {"type": "http", "client": (host, 0),
|
||||
"headers": [(k.lower().encode(), v.encode())
|
||||
for k, v in headers.items()]}
|
||||
|
||||
|
||||
def test_cloudflare_header_wins():
|
||||
# Cloudflare sets CF-Connecting-IP and overwrites any client-supplied
|
||||
# value, so it is trustworthy in a way a parsed XFF chain is not.
|
||||
assert client_key(_Req({"cf-connecting-ip": "203.0.113.7"})) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_forwarded_for_is_the_fallback_and_takes_the_first_entry():
|
||||
# Left-most is the original client; everything after it is proxies.
|
||||
assert client_key(
|
||||
_Req({"x-forwarded-for": "203.0.113.7, 10.0.0.2"})) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_remote_address_is_the_last_resort():
|
||||
assert client_key(_Req({}, host="10.0.0.9")) == "10.0.0.9"
|
||||
|
||||
|
||||
def test_cloudflare_header_beats_forwarded_for():
|
||||
key = client_key(_Req({"cf-connecting-ip": "203.0.113.7",
|
||||
"x-forwarded-for": "198.51.100.1"}))
|
||||
assert key == "203.0.113.7"
|
||||
|
||||
|
||||
def test_two_callers_get_two_buckets():
|
||||
# The whole point: one user exhausting their limit must not refuse another.
|
||||
a = client_key(_Req({"cf-connecting-ip": "203.0.113.7"}))
|
||||
b = client_key(_Req({"cf-connecting-ip": "203.0.113.8"}))
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_whitespace_is_stripped():
|
||||
# "a, b" split on comma leaves a leading space on every entry but the
|
||||
# first; an unstripped key silently creates a second bucket per client.
|
||||
assert client_key(_Req({"x-forwarded-for": " 203.0.113.7 ,10.0.0.2"})) \
|
||||
== "203.0.113.7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The ceiling that header rotation cannot raise.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(monkeypatch):
|
||||
from backend import app as app_module
|
||||
from backend.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "global_rate_limit_per_minute", 5)
|
||||
monkeypatch.setattr(app_module, "_global_window", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
|
||||
def _ceiling_req(path: str, host: str):
|
||||
"""Enough of a Request for exempt_from_ceiling: a path and a peer host."""
|
||||
return type("R", (), {
|
||||
"url": type("U", (), {"path": path})(),
|
||||
"client": type("C", (), {"host": host})(),
|
||||
})()
|
||||
|
||||
|
||||
def _get(client, path="/api/flags", cf=None):
|
||||
headers = {"cf-connecting-ip": cf} if cf else {}
|
||||
return client.get(path, headers=headers)
|
||||
|
||||
|
||||
def test_rotating_the_cloudflare_header_cannot_buy_unlimited_requests(api):
|
||||
"""The attack the per-client keying opened up.
|
||||
|
||||
client_key trusts CF-Connecting-IP, and nothing in this process can tell an
|
||||
edge-set header from an attacker-set one — that distinction can only be
|
||||
made at Cloudflare, with Authenticated Origin Pulls or an origin firewall.
|
||||
A caller reaching the origin directly can therefore mint a fresh
|
||||
rate-limit bucket per request and evade per-client limits entirely.
|
||||
|
||||
Per-client fairness is still the right default; this is the backstop that
|
||||
bounds what evading it can achieve. Without it, correct keying would be a
|
||||
net regression against abuse compared with the shared bucket it replaced.
|
||||
"""
|
||||
codes = [_get(api, cf=f"203.0.113.{i}").status_code for i in range(8)]
|
||||
assert codes.count(200) == 5
|
||||
assert codes.count(429) == 3
|
||||
|
||||
|
||||
def test_the_ceiling_says_which_limit_was_hit(api):
|
||||
# Distinguishable from slowapi's per-client 429, or an operator reading
|
||||
# logs cannot tell "one noisy client" from "the origin is saturated".
|
||||
for i in range(5):
|
||||
_get(api, cf=f"203.0.113.{i}")
|
||||
refused = _get(api, cf="203.0.113.99")
|
||||
assert refused.status_code == 429
|
||||
assert "capacity" in refused.json()["detail"].lower()
|
||||
assert refused.headers.get("retry-after")
|
||||
|
||||
|
||||
def test_traffic_below_the_ceiling_is_untouched(api):
|
||||
codes = [_get(api, cf=f"203.0.113.{i}").status_code for i in range(5)]
|
||||
assert codes == [200] * 5
|
||||
|
||||
|
||||
def test_the_container_healthcheck_is_exempt(api):
|
||||
"""The healthcheck runs `curl http://localhost:80/api/data-info` inside the
|
||||
container. If the ceiling could starve it, saturation would fail the
|
||||
healthcheck, restart the container, and turn a load spike into an outage
|
||||
loop — the ceiling has to protect the origin, not kill it.
|
||||
"""
|
||||
from backend.app import exempt_from_ceiling
|
||||
|
||||
assert exempt_from_ceiling(_ceiling_req("/api/data-info", "127.0.0.1"))
|
||||
assert exempt_from_ceiling(_ceiling_req("/api/data-info", "::1"))
|
||||
# Everyone else is counted.
|
||||
assert not exempt_from_ceiling(_ceiling_req("/api/data-info", "10.0.0.9"))
|
||||
|
||||
|
||||
def test_the_ceiling_ignores_non_api_paths():
|
||||
# Sitemaps and robots.txt are served by this app too, and a crawler
|
||||
# fetching them must not be refused because the API is busy.
|
||||
from backend.app import exempt_from_ceiling
|
||||
|
||||
assert exempt_from_ceiling(_ceiling_req("/sitemap.xml", "10.0.0.9"))
|
||||
assert exempt_from_ceiling(_ceiling_req("/robots.txt", "10.0.0.9"))
|
||||
@@ -59,9 +59,14 @@ def static_child(monkeypatch) -> str:
|
||||
def test_every_loc_uses_the_www_host(sitemaps):
|
||||
# The apex 301s to www. A <loc> that redirects burns a crawl per URL.
|
||||
# Checked across every file, index included, not just one.
|
||||
#
|
||||
# A child can legitimately be empty — this fixture holds two schools and no
|
||||
# town clearing the threshold — so the presence check applies only to files
|
||||
# that carry URLs. The absence check applies to all of them.
|
||||
for name, xml in sitemaps.items():
|
||||
assert "https://www.schoolcompare.co.uk" in xml, name
|
||||
assert "https://schoolcompare.co.uk" not in xml, name
|
||||
if "<loc>" in xml:
|
||||
assert "https://www.schoolcompare.co.uk" in xml, name
|
||||
|
||||
|
||||
def test_school_with_results_is_listed(schools_child):
|
||||
@@ -217,3 +222,85 @@ def test_school_with_no_results_in_any_year_is_still_omitted(monkeypatch):
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
|
||||
assert "/school/100002" not in app_module.build_sitemaps()["schools-1.xml"]
|
||||
|
||||
|
||||
def _places_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA",
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 60.0}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def place_sitemaps(monkeypatch) -> dict:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _places_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return app_module.build_sitemaps()
|
||||
|
||||
|
||||
def test_place_children_are_listed_in_the_index(place_sitemaps):
|
||||
index = place_sitemaps["sitemap.xml"]
|
||||
assert "/sitemaps/places-1.xml" in index
|
||||
assert "/sitemaps/outcodes-1.xml" in index
|
||||
|
||||
|
||||
def test_town_and_authority_urls_use_their_own_namespaces(place_sitemaps):
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood</loc>" in xml
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/authority/essex</loc>" in xml
|
||||
|
||||
|
||||
def test_outcode_urls_live_in_their_own_child(place_sitemaps):
|
||||
assert "/schools/near/cm13" in place_sitemaps["outcodes-1.xml"]
|
||||
assert "/schools/near/cm13" not in place_sitemaps["places-1.xml"]
|
||||
|
||||
|
||||
def test_place_urls_carry_no_priority_or_changefreq(place_sitemaps):
|
||||
for name in ("places-1.xml", "outcodes-1.xml"):
|
||||
assert "<priority>" not in place_sitemaps[name]
|
||||
assert "<changefreq>" not in place_sitemaps[name]
|
||||
|
||||
|
||||
def test_phase_variants_are_submitted_where_the_phase_clears_the_threshold(place_sitemaps):
|
||||
# "primary schools in beccles" is the query shape the baseline showed, so
|
||||
# each variant is its own page and has to be submitted. Emitting only the
|
||||
# bare place URL left ~950 of them reachable by nothing.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood/primary</loc>" in xml
|
||||
|
||||
|
||||
def test_a_phase_below_its_own_threshold_is_not_submitted(place_sitemaps):
|
||||
# The fixture is six primaries and no secondaries.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "/schools/brentwood/secondary" not in xml
|
||||
|
||||
|
||||
def test_outcodes_get_no_phase_variants(place_sitemaps):
|
||||
# Nobody searches "primary schools in CM13"; the routes do not exist.
|
||||
xml = place_sitemaps["outcodes-1.xml"]
|
||||
assert "/primary" not in xml and "/secondary" not in xml
|
||||
|
||||
|
||||
def test_authority_phase_variants_are_submitted_in_their_own_namespace(place_sitemaps):
|
||||
"""302 of these were already in the sitemap, and every one 404'd.
|
||||
|
||||
The spec gives authorities a phase route; the plan built the bare
|
||||
authority route and dropped it. Nothing noticed because the sitemap was
|
||||
written from the registry, which was right, while the routes were written
|
||||
by hand. This test fails if the URL ever leaves the sitemap; the e2e
|
||||
journey fails if the route ever leaves the app.
|
||||
"""
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert ("<loc>https://www.schoolcompare.co.uk"
|
||||
"/schools/authority/essex/primary</loc>") in xml
|
||||
# And never in the town namespace, which is a different set of schools.
|
||||
assert "/schools/essex/primary" not in xml
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for school autosuggest (spec 2026-08-26)."""
|
||||
|
||||
from backend import data_loader
|
||||
|
||||
|
||||
class _FakeDocs:
|
||||
def __init__(self, hits, explode=False):
|
||||
self._hits = hits
|
||||
self._explode = explode
|
||||
self.last_params = None
|
||||
|
||||
def search(self, params):
|
||||
self.last_params = params
|
||||
if self._explode:
|
||||
raise RuntimeError("typesense is down")
|
||||
return {"hits": [{"document": d} for d in self._hits]}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, hits, explode=False):
|
||||
self.docs = _FakeDocs(hits, explode)
|
||||
self.collections = {"schools": type("C", (), {"documents": self.docs})()}
|
||||
|
||||
|
||||
_HIT = {
|
||||
"urn": 100010, "school_name": "Brecknock Primary School",
|
||||
"local_authority": "Camden", "postcode": "NW1 1AA",
|
||||
"phase": "Primary", "school_type": "Community school",
|
||||
}
|
||||
|
||||
|
||||
def _use(monkeypatch, client):
|
||||
monkeypatch.setattr(data_loader, "_get_typesense_client", lambda: client)
|
||||
|
||||
|
||||
def test_returns_the_fields_a_suggestion_needs(monkeypatch):
|
||||
# Local authority is not decoration: there are many schools called
|
||||
# "St Mary's", and a list without it cannot be chosen between.
|
||||
_use(monkeypatch, _FakeClient([_HIT]))
|
||||
out = data_loader.suggest_schools_typesense("breck")
|
||||
assert out == [{
|
||||
"urn": 100010, "school_name": "Brecknock Primary School",
|
||||
"local_authority": "Camden", "postcode": "NW1 1AA",
|
||||
"phase": "Primary", "school_type": "Community school",
|
||||
}]
|
||||
|
||||
|
||||
def test_a_missing_optional_field_becomes_an_empty_string(monkeypatch):
|
||||
# phase and school_type are optional in the Typesense schema. A missing
|
||||
# key must not KeyError in the keystroke path.
|
||||
_use(monkeypatch, _FakeClient([{"urn": 1, "school_name": "X",
|
||||
"local_authority": "Y", "postcode": "Z"}]))
|
||||
out = data_loader.suggest_schools_typesense("x")
|
||||
assert out[0]["phase"] == "" and out[0]["school_type"] == ""
|
||||
|
||||
|
||||
def test_typesense_unavailable_gives_no_suggestions_rather_than_raising(monkeypatch):
|
||||
_use(monkeypatch, None)
|
||||
assert data_loader.suggest_schools_typesense("anything") == []
|
||||
|
||||
|
||||
def test_a_typesense_error_gives_no_suggestions_rather_than_raising(monkeypatch):
|
||||
_use(monkeypatch, _FakeClient([], explode=True))
|
||||
assert data_loader.suggest_schools_typesense("anything") == []
|
||||
|
||||
|
||||
def test_the_limit_is_passed_through_and_clamped(monkeypatch):
|
||||
client = _FakeClient([])
|
||||
_use(monkeypatch, client)
|
||||
data_loader.suggest_schools_typesense("x", limit=500)
|
||||
assert client.docs.last_params["per_page"] == 20
|
||||
|
||||
|
||||
def _client(monkeypatch, rows, *, blow_up_dataframe=False):
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "suggest_schools_typesense",
|
||||
lambda q, limit=8: rows)
|
||||
if blow_up_dataframe:
|
||||
def _boom():
|
||||
raise AssertionError("the suggest path must not load the DataFrame")
|
||||
monkeypatch.setattr(app_module, "load_school_data", _boom)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _boom)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_the_endpoint_returns_suggestions(monkeypatch):
|
||||
body = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck").json()
|
||||
assert body["suggestions"][0]["school_name"] == "Brecknock Primary School"
|
||||
|
||||
|
||||
def test_the_endpoint_never_touches_the_dataframe(monkeypatch):
|
||||
"""The whole reason this is not a mode of /api/schools.
|
||||
|
||||
That endpoint filters and sorts 25,000 rows of pandas per query, holding
|
||||
the GIL. Per keystroke, that is the cost this endpoint exists to avoid.
|
||||
"""
|
||||
res = _client(monkeypatch, [_HIT], blow_up_dataframe=True).get("/api/suggest?q=breck")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["suggestions"]
|
||||
|
||||
|
||||
def test_a_one_character_query_returns_nothing_and_does_not_error(monkeypatch):
|
||||
# The keystroke path never errors on ordinary input.
|
||||
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=b")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"suggestions": []}
|
||||
|
||||
|
||||
def test_a_blank_query_returns_nothing_and_does_not_error(monkeypatch):
|
||||
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"suggestions": []}
|
||||
|
||||
|
||||
def test_typesense_down_is_an_empty_list_not_a_500(monkeypatch):
|
||||
res = _client(monkeypatch, []).get("/api/suggest?q=breck")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"suggestions": []}
|
||||
|
||||
|
||||
def test_the_response_is_cacheable(monkeypatch):
|
||||
# Prefix queries repeat enormously across users, and school names change
|
||||
# once a year. Without this the endpoint pays full price every keystroke.
|
||||
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck")
|
||||
assert "s-maxage" in res.headers.get("cache-control", "")
|
||||
assert res.headers.get("etag")
|
||||
|
||||
|
||||
def test_a_malformed_urn_does_not_raise(monkeypatch):
|
||||
"""The docstring promises "never raises"; the parsing loop sat outside the
|
||||
try, so int(None) or int("abc") would have turned a keystroke into a 500.
|
||||
|
||||
Typesense declares urn as int32, so this should be unreachable — but the
|
||||
contract is what the caller relies on, and a search index is a separate
|
||||
system that can be reindexed by something other than this code.
|
||||
"""
|
||||
_use(monkeypatch, _FakeClient([{"urn": None, "school_name": "X",
|
||||
"local_authority": "Y", "postcode": "Z"}]))
|
||||
assert data_loader.suggest_schools_typesense("x") == []
|
||||
|
||||
|
||||
def test_a_malformed_row_does_not_discard_the_good_ones(monkeypatch):
|
||||
# One bad document must not blank the whole dropdown.
|
||||
_use(monkeypatch, _FakeClient([
|
||||
{"urn": "not-a-number", "school_name": "Bad", "local_authority": "Y",
|
||||
"postcode": "Z"},
|
||||
_HIT,
|
||||
]))
|
||||
out = data_loader.suggest_schools_typesense("x")
|
||||
assert [r["urn"] for r in out] == [100010]
|
||||
|
||||
|
||||
def test_a_hit_with_no_document_does_not_raise(monkeypatch):
|
||||
_use(monkeypatch, _FakeClient([{}]))
|
||||
assert data_loader.suggest_schools_typesense("x") == []
|
||||
@@ -132,16 +132,23 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
"FactPupilCharacteristics": [],
|
||||
"FactDeprivation": [],
|
||||
"FactFinance": [],
|
||||
"FactKs4Destinations": [],
|
||||
"FactKs5Destinations": [],
|
||||
}
|
||||
session = _FakeSession(rows)
|
||||
out = get_supplementary_data_batch(session, [1, 2])
|
||||
|
||||
# Exactly one query per table — six total, regardless of two URNs.
|
||||
# Exactly one query per table — eight total, regardless of two URNs.
|
||||
assert sorted(session.queries) == [
|
||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||
"FactFinance", "FactKs4Destinations", "FactKs5Destinations",
|
||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||
]
|
||||
|
||||
# A school with no destination rows gets null, not an empty shell — the
|
||||
# frontend renders the section from the block's presence.
|
||||
assert out[1]["destinations"] is None
|
||||
|
||||
# Latest Ofsted kept per URN
|
||||
assert out[1]["ofsted"]["overall_effectiveness"] == 2
|
||||
assert out[2]["ofsted"]["overall_effectiveness"] == 1
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
# ADMIN_API_KEY — Backend admin API key
|
||||
# TYPESENSE_API_KEY — Typesense admin API key
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# UNLEASH_API_TOKEN — Unleash *client* token, environment: development
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
|
||||
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
# back to a generated one that changes on restart.
|
||||
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
|
||||
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
|
||||
|
||||
@@ -55,6 +60,12 @@ services:
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
# Unset means every feature flag is False — the correct dark state for an
|
||||
# environment with no Unleash, not a failure.
|
||||
UNLEASH_URL: ${UNLEASH_URL:-}
|
||||
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
|
||||
volumes:
|
||||
- unleash_cache:/app/.unleash
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
@@ -116,7 +127,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||
container_name: sc_staging_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
@@ -128,6 +155,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?set AIRFLOW_ADMIN_PASSWORD in the Portainer stack environment}
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
@@ -212,3 +241,4 @@ volumes:
|
||||
postgres_data:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
@@ -0,0 +1,73 @@
|
||||
# Portainer Stack Definition for School Compare — UNLEASH (feature flags)
|
||||
#
|
||||
# Deploy as a *separate* Portainer stack ("schoolcompare-unleash"), alongside
|
||||
# the production and staging stacks. It deliberately belongs to neither: a
|
||||
# staging redeploy must not be able to disturb production's flag state, and a
|
||||
# production redeploy must not disturb staging's.
|
||||
#
|
||||
# One instance serves both environments. Open-source Unleash ships with
|
||||
# `development` and `production` environments and environment-scoped client
|
||||
# tokens, so the same flag holds independent state in each — which is what
|
||||
# lets a feature be on in staging, where the E2E journeys exercise it, while
|
||||
# production stays dark.
|
||||
#
|
||||
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
|
||||
# UNLEASH_DB_PASSWORD — PostgreSQL password for the Unleash database
|
||||
# UNLEASH_ADMIN_PASSWORD — initial admin password for the Unleash UI
|
||||
# UNLEASH_IP — macvlan IP for the Unleash server (default 10.0.1.152)
|
||||
|
||||
services:
|
||||
|
||||
# ── PostgreSQL (Unleash's own; nothing else uses it) ──────────────────
|
||||
unleash_db:
|
||||
container_name: sc_unleash_postgres
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: unleash
|
||||
POSTGRES_PASSWORD: ${UNLEASH_DB_PASSWORD}
|
||||
POSTGRES_DB: unleash
|
||||
volumes:
|
||||
- unleash_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- unleash
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U unleash"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Unleash server (UI + client API on 4242) ──────────────────────────
|
||||
unleash:
|
||||
container_name: sc_unleash
|
||||
image: unleashorg/unleash-server:6
|
||||
environment:
|
||||
DATABASE_URL: postgres://unleash:${UNLEASH_DB_PASSWORD}@unleash_db:5432/unleash
|
||||
DATABASE_SSL: "false"
|
||||
INIT_ADMIN_API_TOKENS: ""
|
||||
UNLEASH_DEFAULT_ADMIN_PASSWORD: ${UNLEASH_ADMIN_PASSWORD}
|
||||
depends_on:
|
||||
unleash_db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
unleash: {}
|
||||
macvlan:
|
||||
ipv4_address: ${UNLEASH_IP:-10.0.1.152}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
unleash:
|
||||
driver: bridge
|
||||
macvlan:
|
||||
external:
|
||||
name: macvlan
|
||||
|
||||
volumes:
|
||||
unleash_postgres_data:
|
||||
@@ -7,7 +7,12 @@
|
||||
# ADMIN_API_KEY — Backend admin API key
|
||||
# TYPESENSE_API_KEY — Typesense admin API key
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# UNLEASH_API_TOKEN — Unleash *client* token, environment: production
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
|
||||
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
# back to a generated one that changes on restart.
|
||||
|
||||
services:
|
||||
|
||||
@@ -44,6 +49,12 @@ services:
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
# Unset means every feature flag is False — the correct dark state for an
|
||||
# environment with no Unleash, not a failure.
|
||||
UNLEASH_URL: ${UNLEASH_URL:-}
|
||||
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
|
||||
volumes:
|
||||
- unleash_cache:/app/.unleash
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
@@ -105,7 +116,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
|
||||
container_name: schoolcompare_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
@@ -117,6 +144,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?set AIRFLOW_ADMIN_PASSWORD in the Portainer stack environment}
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
@@ -201,3 +230,4 @@ volumes:
|
||||
postgres_data:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
+23
-1
@@ -36,6 +36,10 @@ services:
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
# Unset means every feature flag is False — the correct dark state for an
|
||||
# environment with no Unleash, not a failure.
|
||||
UNLEASH_URL: ${UNLEASH_URL:-}
|
||||
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
|
||||
volumes:
|
||||
- ./data:/app/data:ro
|
||||
depends_on:
|
||||
@@ -101,7 +105,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
container_name: schoolcompare_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment: &airflow-env
|
||||
@@ -113,6 +133,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "admin:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-admin}
|
||||
PG_HOST: db
|
||||
PG_PORT: "5432"
|
||||
PG_USER: schoolcompare
|
||||
|
||||
+106
@@ -98,6 +98,12 @@ fail the E2E gate. That's the point: staging absorbs the risk.
|
||||
pr-checks status checks (frontend, backend, builds, ai-review) to pass.
|
||||
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
||||
itself from source, exercising the pipeline image end-to-end):
|
||||
- Set `AIRFLOW_ADMIN_PASSWORD` in the stack environment first. The
|
||||
api-server refuses to start without it. Airflow's simple auth manager
|
||||
otherwise generates a password on first start and writes it to a file, so
|
||||
the login changes every time the container restarts; the stack writes that
|
||||
file itself from this variable instead. `AIRFLOW_ADMIN_USER` defaults to
|
||||
`admin`.
|
||||
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
|
||||
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
|
||||
`school_data_annual_ees` and `school_data_annual_idaci`.
|
||||
@@ -150,3 +156,103 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
|
||||
needed), and fails the check only when a finding is rated
|
||||
**severe** (would break prod, leak data, or corrupt data). Minor findings are
|
||||
informational and never block a merge.
|
||||
|
||||
## Rate limiting, and the Cloudflare gap
|
||||
|
||||
Two independent limits protect the API:
|
||||
|
||||
- **Per client**, via slowapi, keyed on `CF-Connecting-IP` (falling back to
|
||||
`X-Forwarded-For`, then the peer address). 60/minute by default;
|
||||
`/api/suggest` gets 120/minute because typing is bursty.
|
||||
- **Globally**, via `GlobalRateLimitMiddleware`: a fixed 60-second window over
|
||||
all `/api/` traffic, `GLOBAL_RATE_LIMIT_PER_MINUTE` (default 3000),
|
||||
independent of any client identity. Requests from `127.0.0.1` are exempt so
|
||||
the container healthcheck cannot be starved into a restart loop.
|
||||
|
||||
### Open: the origin must only accept Cloudflare
|
||||
|
||||
`CF-Connecting-IP` is only meaningful for requests that actually reached the
|
||||
origin through Cloudflare, and **the application cannot verify that they did**.
|
||||
Anything able to reach the origin directly can set that header freely and, by
|
||||
rotating it, mint a fresh rate-limit bucket per request — defeating per-client
|
||||
limits on every endpoint.
|
||||
|
||||
The global ceiling bounds the damage to total origin capacity. It does not fix
|
||||
the underlying gap, and nothing in the code can. Closing it needs one of:
|
||||
|
||||
- **Authenticated Origin Pulls** — Cloudflare presents a client certificate the
|
||||
origin requires, so non-Cloudflare traffic is refused at TLS.
|
||||
- **An origin firewall** restricted to Cloudflare's published IP ranges.
|
||||
|
||||
Until one is in place, treat per-client limits as protection against accidents
|
||||
and ordinary load, not against a determined caller.
|
||||
|
||||
## Feature flags (Unleash)
|
||||
|
||||
Flag state lives in a self-hosted Unleash instance, deployed as its own
|
||||
Portainer stack from `docker-compose.portainer.unleash.yml`. It is separate
|
||||
from the application stacks on purpose — redeploying staging must not be able
|
||||
to disturb production's flags.
|
||||
|
||||
The flags themselves are declared in `backend/flags.py`. Unleash holds the
|
||||
state; the registry holds the list. A flag in the UI that is not in the
|
||||
registry is orphaned and nothing reads it.
|
||||
|
||||
### First-time setup
|
||||
|
||||
1. Deploy the stack in Portainer. Set `UNLEASH_DB_PASSWORD`,
|
||||
`UNLEASH_ADMIN_PASSWORD` and (optionally) `UNLEASH_IP`.
|
||||
2. Log in to the UI at `http://<UNLEASH_IP>:4242` as `admin`.
|
||||
3. Create one **client** API token per environment:
|
||||
- `schoolcompare-staging`, environment **development**
|
||||
- `schoolcompare-prod`, environment **production**
|
||||
|
||||
Client tokens, not admin tokens — the backend only reads.
|
||||
4. Put each token in the matching Portainer stack's `UNLEASH_API_TOKEN`
|
||||
variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
|
||||
5. Redeploy the application stacks.
|
||||
|
||||
### Adding a flag to Unleash
|
||||
|
||||
**Unleash does not create flags by itself.** The SDK reads definitions from the
|
||||
server and never registers anything, and metrics for a flag the server has
|
||||
never heard of are discarded. So a flag declared in `backend/flags.py` will be
|
||||
evaluated on every request, stay `False` forever, and never appear in the UI
|
||||
until someone creates it there by hand.
|
||||
|
||||
For each flag in the registry, create one in Unleash with:
|
||||
|
||||
- **Name** — character for character what `backend/flags.py` declares.
|
||||
snake_case, no hyphens or spaces. A typo produces a flag that looks correct
|
||||
in the UI and is read by nothing.
|
||||
- **Type** — Release. No strategies, constraints or variants: these are plain
|
||||
on/off switches, by design.
|
||||
|
||||
### Turning a feature on
|
||||
|
||||
Toggle the flag in the environment matching the stack you mean: **development**
|
||||
for staging, **production** for prod. The token in each stack is scoped to one
|
||||
environment, so toggling the other one has no visible effect.
|
||||
|
||||
The SDK refreshes every 15 seconds, so the API reflects the change almost at
|
||||
once; the pages follow on their own schedule, below.
|
||||
|
||||
A flip reaches school pages within about five minutes and place pages within
|
||||
the hour. Next's ISR does the propagating — it revalidates a route at the
|
||||
*lowest* `revalidate` among that route's fetches, which is 300s for
|
||||
`/school/[slug]` and 3600s for the place pages. There is no webhook, and
|
||||
adding one would only be worth it if flips ever needed to be instant.
|
||||
|
||||
### When Unleash is unreachable
|
||||
|
||||
Every flag evaluates to `False` and the site serves as though nothing were
|
||||
switched on. That is deliberate — an unfinished feature staying hidden is the
|
||||
safe direction — but it means a *released* feature disappears if a backend
|
||||
container cold-starts with an empty cache while Unleash is down. The SDK's
|
||||
disk cache is on a named volume so restarts keep last-known state, and flags
|
||||
are removed from the code within 90 days (enforced by a test), which bounds
|
||||
how long any feature is exposed to this.
|
||||
|
||||
If `UNLEASH_URL` is unset, every flag is `False` and no connection is
|
||||
attempted. That is the correct behaviour for local development and CI, and it
|
||||
means the test suites need no flag server.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
||||
# W2: The Location Layer — Design
|
||||
|
||||
Date: 2026-08-21
|
||||
Status: awaiting review
|
||||
Supersedes: workstream W2 in `2026-08-20-seo-programme-design.md`
|
||||
|
||||
Scope note: this covers four page families in one spec. Splitting them — towns
|
||||
and authorities first, outcodes and localities after — was proposed and
|
||||
declined in favour of building the layer in one pass. The decomposition
|
||||
argument was that the curated locality seed needs human review and would hold
|
||||
up 783 pages of measured demand behind it; that risk is accepted here, and the
|
||||
implementation plan should sequence the seed early enough that review time
|
||||
does not become the critical path.
|
||||
|
||||
## Problem
|
||||
|
||||
Location intent is the largest unserved demand the site has. In the 16-month
|
||||
Search Console baseline it draws **874 impressions, one click, average
|
||||
position 49.5**. The site does not compete.
|
||||
|
||||
Unlike named-school queries — which the same baseline showed to be
|
||||
navigational and unwinnable, since a parent typing "audley junior school"
|
||||
wants that school's own website — location queries have no incumbent owner.
|
||||
Nobody owns "primary schools in Brentwood" the way a school owns its name.
|
||||
|
||||
The cause is structural: the site has no page about a place. Every competitor
|
||||
ranking above it does.
|
||||
|
||||
## What the demand actually looks like
|
||||
|
||||
Every location query in the baseline is **town or district level**. Not one is
|
||||
an administrative area:
|
||||
|
||||
| Query | Impressions | Position |
|
||||
|-------|-------------|----------|
|
||||
| colleges in solihull | 112 | 51.2 |
|
||||
| schools in ramsey | 64 | 42.5 |
|
||||
| schools in crosby | 57 | 47.7 |
|
||||
| primary schools in beccles | 44 | 40.9 |
|
||||
| private schools in battersea | 41 | 71.9 |
|
||||
| secondary schools in brentwood | 37 | 56.1 |
|
||||
| secondary schools in canary wharf | 30 | 35.9 |
|
||||
|
||||
Three patterns follow directly, and they drive the whole design.
|
||||
|
||||
**Towns, not authorities.** The superseded W2 put `/schools/[la]` first and
|
||||
towns second. The data inverts that. Brentwood appears four times in different
|
||||
phrasings; Beccles twice. Both are towns, not authorities.
|
||||
|
||||
**Phase is part of the query**, not a filter applied afterwards: "primary
|
||||
schools in beccles", "secondary schools in brentwood", "colleges in solihull".
|
||||
|
||||
**London is searched by district** — Battersea, Canary Wharf — and the GIAS
|
||||
`town` field cannot serve it at all.
|
||||
|
||||
## Measured sizing
|
||||
|
||||
Counted against the live corpus of 25,185 schools, not estimated.
|
||||
|
||||
| Family | Viable (≥5 schools) | Below threshold |
|
||||
|--------|--------------------|-----------------|
|
||||
| Towns | **783** | 907 → redirect to authority |
|
||||
| Outcodes | **1,760** | 305 |
|
||||
| Local authorities | 154 | — |
|
||||
| London localities | ~100–150 (curated) | — |
|
||||
|
||||
With phase variants — 783 town pages plus roughly 700 primary and 250
|
||||
secondary variants, 154 authorities across three variants, 1,760 outcodes and
|
||||
the curated localities — the total lands near **4,000 pages**. Phase variants
|
||||
need their own threshold: there are 17,426 primaries but only 4,456 secondaries nationally,
|
||||
so most towns will support a primary page and not a secondary one.
|
||||
|
||||
## Two design problems this spec exists to solve
|
||||
|
||||
### 1. Town and authority names collide, and neither contains the other
|
||||
|
||||
67 viable towns share a name with a local authority. The obvious fix — let the
|
||||
authority absorb the town, since it sounds like a superset — **does not work**:
|
||||
|
||||
| Place | Schools in the town | Schools in the authority |
|
||||
|-------|--------------------|-----------------------|
|
||||
| Bedford | 104 | 86 |
|
||||
| Birmingham | 520 | 518 |
|
||||
| Derby | 157 | 119 |
|
||||
| Doncaster | 152 | 145 |
|
||||
|
||||
The authority is the larger set in only 43 of the 67. Postal towns cross
|
||||
authority boundaries, so these are overlapping sets that happen to share a
|
||||
name. Publishing both into one namespace produces near-duplicate pages, which
|
||||
is the specific failure that sinks programmatic SEO.
|
||||
|
||||
**Resolution: two namespaces.**
|
||||
|
||||
```
|
||||
/schools/[place] towns and London localities
|
||||
/schools/[place]/primary
|
||||
/schools/[place]/secondary
|
||||
/schools/authority/[la] local authorities
|
||||
/schools/authority/[la]/primary
|
||||
/schools/authority/[la]/secondary
|
||||
/schools/near/[outcode]
|
||||
```
|
||||
|
||||
Outcodes carry no phase variants: nobody searches "primary schools in SW11",
|
||||
so the variants would be pages without demand.
|
||||
|
||||
Every collision disappears by construction. `/schools/[place]` keeps the clean
|
||||
URL for the pattern that carries the demand; authorities get a namespace whose
|
||||
purpose is genuinely different — admissions are authority-run, and the
|
||||
authority page is the one that can speak to catchment policy and LA averages.
|
||||
|
||||
A place page and an authority page of the same name must each say plainly
|
||||
which set of schools they cover, or they read as duplicates to a reader even
|
||||
when they differ in fact.
|
||||
|
||||
### 2. London has no locality field
|
||||
|
||||
`town` collapses **1,819 London schools into the single value "London"**. A
|
||||
page listing all of them is useless, and borough pages do not help because
|
||||
people search "Battersea", not "Wandsworth".
|
||||
|
||||
No single field solves it:
|
||||
|
||||
| Search term | `parliamentary_constituency` | postcodes.io `admin_ward` |
|
||||
|-------------|------------------------------|---------------------------|
|
||||
| Battersea | **Battersea** ✓ | Northcote / Wandsworth Town ✗ |
|
||||
| Canary Wharf | Poplar and Limehouse ✗ | **Canary Wharf** ✓ |
|
||||
| Vauxhall | Vauxhall and Camberwell Green ✗ | **Vauxhall** ✓ |
|
||||
|
||||
And neither covers Clapham, Shoreditch or Peckham, which are postal and
|
||||
colloquial rather than administrative.
|
||||
|
||||
**Resolution: a curated seed mapping locality to outcodes.**
|
||||
|
||||
```
|
||||
pipeline/transform/seeds/locality_outcodes.csv
|
||||
locality_slug,locality_name,outcodes,region
|
||||
battersea,Battersea,"SW11|SW8",London
|
||||
canary-wharf,Canary Wharf,"E14",London
|
||||
clapham,Clapham,"SW4|SW9",London
|
||||
```
|
||||
|
||||
This needs **no new ingestion** — the corpus already has postcodes. It puts
|
||||
the fuzzy, contested part of the problem in a reviewable file rather than in
|
||||
derived logic, which suits it: locality boundaries are a judgement, not a
|
||||
fact. The repo already uses dbt seeds for curated reference data
|
||||
(`la_code_names.csv`, `gias_code_names.csv`), so this follows an established
|
||||
pattern.
|
||||
|
||||
The seed generalises past London. Any colloquial place — Jesmond, Chorlton,
|
||||
Clifton — can be defined by its outcodes without a schema change.
|
||||
|
||||
**Constraint:** a locality slug may not collide with a viable town slug. The
|
||||
place registry enforces this and fails the build rather than silently
|
||||
shadowing a town.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The place registry
|
||||
|
||||
One module owns the question "what places do we publish, and what is in each".
|
||||
Everything else reads from it: the pages, the sitemap, the internal links.
|
||||
|
||||
```
|
||||
backend/places.py
|
||||
|
||||
Place = { kind: "town"|"locality"|"authority"|"outcode",
|
||||
slug, name, urn_list, parent_authority | None }
|
||||
|
||||
build_place_registry(df) -> dict[str, Place]
|
||||
place_schools(slug, phase=None) -> list[School]
|
||||
```
|
||||
|
||||
Built once at startup from the same DataFrame the sitemap uses, and rebuilt by
|
||||
the existing `/api/admin/regenerate-sitemap` path after a pipeline run.
|
||||
Registry construction is where the threshold, the collision rules and the
|
||||
seed's uniqueness constraint are enforced — in one place, testable without a
|
||||
browser or a database.
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
GET /api/places the registry: slug, kind, name, count
|
||||
GET /api/places/{slug}?phase= aggregate + ranked schools for one place
|
||||
```
|
||||
|
||||
`/api/places` is what the sitemap and the internal-link modules enumerate.
|
||||
|
||||
### Routes
|
||||
|
||||
Next App Router, ISR with the same 7-day revalidate the school pages use.
|
||||
`generateStaticParams` gated behind an env flag, matching
|
||||
`PRERENDER_SCHOOLS`, because 3,900 more routes cannot be statically built in
|
||||
CI on every deploy.
|
||||
|
||||
## What each page must contain
|
||||
|
||||
A place page that is a name substituted into a template is the thing Google's
|
||||
helpful-content stance exists to demote. Each page carries computed local
|
||||
facts that exist nowhere else on the site:
|
||||
|
||||
- **H1** matching the query: "Primary schools in Brentwood"
|
||||
- **Counts framed usefully**: "29 schools, 4 rated Outstanding"
|
||||
- **A ranked table** of the top 20 on the phase's headline metric —
|
||||
`rwm_expected_pct` for primary, `attainment_8_score` for secondary, and for
|
||||
an unphased place page the metric matching whichever phase it holds more of
|
||||
- **The local average against the England average** — the one number a parent
|
||||
cannot get from a list
|
||||
- **Ofsted grade distribution** for the place
|
||||
- **A map**
|
||||
- **Links to neighbouring places** and to the parent authority
|
||||
- **An FAQ block**, feeding `FAQPage` structured data
|
||||
- **A link to every school page in scope** — this is what finally de-orphans
|
||||
the 23,000 school pages the original spec identified as near-orphans
|
||||
|
||||
## Thin-page controls
|
||||
|
||||
Three, and they are the difference between a location layer and index bloat:
|
||||
|
||||
1. **Five schools with current data minimum.** Below it, 301 to the parent
|
||||
authority. This drops 907 towns and 305 outcodes.
|
||||
2. **Per-phase thresholds.** A town with 30 primaries and 2 secondaries
|
||||
publishes a primary page and no secondary page.
|
||||
3. **No page without a local average.** If a place has too few schools with
|
||||
results to compute one, it has nothing to say that a list does not, and it
|
||||
falls back to the authority.
|
||||
|
||||
## Sitemap
|
||||
|
||||
Two new children in the existing index: `/sitemaps/places-{n}.xml` and
|
||||
`/sitemaps/outcodes-{n}.xml`. Per-family children are why the index was built
|
||||
in W1 — Search Console reports coverage per submitted sitemap, so indexation
|
||||
of the location layer is measurable separately from the school pages.
|
||||
|
||||
## Testing
|
||||
|
||||
Per `CLAUDE.md`, user-facing behaviour extends `e2e/tests/journeys.spec.ts` in
|
||||
the same PR.
|
||||
|
||||
**Unit (registry, no DB):** threshold enforcement; a sub-threshold town
|
||||
resolves to its authority; a locality slug colliding with a town fails the
|
||||
build; Bedford's town and authority pages hold different URN sets; per-phase
|
||||
thresholds.
|
||||
|
||||
**Backend:** `/api/places` shape; `/api/places/{slug}` aggregate correctness
|
||||
against a fixture; unknown slug 404s.
|
||||
|
||||
**e2e:** a known town, authority, locality and outcode page each render with
|
||||
the expected count; a below-threshold town 301s; every place page declares a
|
||||
canonical and appears in the sitemap; `/schools/bedford` and
|
||||
`/schools/authority/bedford` both resolve and state which set they cover.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat** is the failure mode of every programmatic SEO programme. The
|
||||
three controls above are the answer, and the per-family sitemap is how we find
|
||||
out early if they were not enough.
|
||||
|
||||
**Helpful-content exposure.** Templated location pages are exactly what
|
||||
Google's stance targets. The mitigation is that every page carries real
|
||||
computed local data — counts, distributions, local-versus-national comparison
|
||||
— rather than a name dropped into boilerplate. If indexation of the places
|
||||
sitemap stalls below roughly half, that is the signal to stop and rethink
|
||||
rather than to add more pages.
|
||||
|
||||
**Build cost.** ~4,000 additional ISR routes on top of 23,000 school pages.
|
||||
The env-flag gate on `generateStaticParams` keeps CI viable.
|
||||
|
||||
**Curation drift.** The locality seed is hand-maintained and will go stale as
|
||||
places change. It is small and reviewable, and a dbt test asserts every seed
|
||||
outcode matches at least one school so a typo fails the pipeline rather than
|
||||
publishing an empty page.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Catchment-area estimation. It is a strong driver for this cluster and
|
||||
`fact_admissions` carries the distances, but it is a modelling problem with
|
||||
real accuracy risk and deserves its own design.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Feature Flags — Design
|
||||
|
||||
**Date:** 2026-08-23
|
||||
**Status:** approved for planning
|
||||
**First consumer:** the last-distance-offered feature (`admission_distance`)
|
||||
|
||||
## Goal
|
||||
|
||||
Let work merge to `main` and deploy to production without becoming visible,
|
||||
so that releasing a feature stops being the same event as deploying it.
|
||||
|
||||
The site has no way to do this today. A feature is either on `main` and live,
|
||||
or it is on a branch. That forces long-lived branches for anything not ready,
|
||||
and it makes every promotion to production an all-or-nothing decision about
|
||||
everything queued behind it.
|
||||
|
||||
This is a **ship-dark** capability, not a kill switch. Flags are expected to
|
||||
flip on the order of once a month, by a person, deliberately. Nothing here is
|
||||
designed for flipping something off in seconds under pressure, and nothing
|
||||
here does percentage rollouts, user targeting or A/B tests — the site has no
|
||||
user identity to target.
|
||||
|
||||
## Decision: Unleash
|
||||
|
||||
Flag state is held in a self-hosted [Unleash](https://www.getunleash.io/)
|
||||
instance (Apache-2.0), not in the repository.
|
||||
|
||||
A lighter option was considered and rejected by the project owner: a typed
|
||||
registry in each runtime with environment-variable overrides set in the
|
||||
Portainer stack files, which would have needed no new container and kept flag
|
||||
state in git. The argument for Unleash is that it provides a UI and an audit
|
||||
log without a deploy, and that flags are expected to become an ongoing
|
||||
operational tool rather than an occasional one.
|
||||
|
||||
Two consequences follow from choosing a service, and this design exists mostly
|
||||
to handle them:
|
||||
|
||||
1. **Flag state lives outside the repository.** `main` is no longer the whole
|
||||
truth about what is switched on. The registry in §2 exists to bound that.
|
||||
2. **A flag can change without a deploy**, so nothing else clears the caches
|
||||
that a deploy would have cleared. §4 establishes how long a flip takes to
|
||||
become visible, and why that is short enough to need no extra mechanism.
|
||||
|
||||
Also considered: Flagsmith (heavier — Django, Postgres and Redis), GrowthBook
|
||||
(requires MongoDB), and Flipt v2 (the closest conceptual fit, git-native, but
|
||||
now under the Fair Core Licence — source-available, not OSI open source).
|
||||
|
||||
## 1. Topology
|
||||
|
||||
A third Portainer stack, `docker-compose.portainer.unleash.yml`, holding
|
||||
`unleashorg/unleash-server` and its own PostgreSQL 16. It is on the macvlan so
|
||||
both application stacks can reach it, and it belongs to neither of them — a
|
||||
staging redeploy must not be able to disturb production's flag state, and vice
|
||||
versa.
|
||||
|
||||
One instance serves both environments. Open-source Unleash ships with
|
||||
`development` and `production` environments and environment-scoped client
|
||||
tokens, so the same flag holds independent state in each: staging's FastAPI
|
||||
carries a `development` token, production's carries a `production` one.
|
||||
|
||||
That property is what makes ship-dark testable. A feature can be **on in
|
||||
staging and off in production** for as long as it takes, which means the `e2e/`
|
||||
journeys exercise it against staging while production stays unchanged.
|
||||
|
||||
## 2. The registry
|
||||
|
||||
Unleash supplies flag *state* and the toggle UI. It does not supply the list of
|
||||
flags. `backend/flags.py` declares every flag the code knows about:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Flag:
|
||||
name: str # identical in the registry, in Unleash, and in JSON
|
||||
description: str # one line: what turning this on reveals
|
||||
added: date # for the staleness test in §8
|
||||
```
|
||||
|
||||
**Every flag defaults to `False`.** There is no per-flag default field, because
|
||||
a flag that defaults on is not a ship-dark flag — it is a kill switch, and this
|
||||
design does not offer one. A single unconditional default also means the
|
||||
fallback path has no branching to get wrong.
|
||||
|
||||
Three reasons the registry is not optional:
|
||||
|
||||
- The Unleash SDK evaluates an unknown flag to `False`. Without a registry that
|
||||
is an *undeclared* false — indistinguishable from a typo in a flag name.
|
||||
- `/api/flags` needs a key set to return when Unleash is unreachable. It cannot
|
||||
enumerate flags it has never heard of.
|
||||
- A flag present in the Unleash UI but absent from the registry is orphaned,
|
||||
and should be visibly so rather than quietly authoritative.
|
||||
|
||||
**Naming.** One string, used unchanged as the registry key, the Unleash flag
|
||||
name, and the JSON key in `/api/flags`. It is snake_case, matching the API's
|
||||
existing convention (`admission_distance`, `rwm_expected_pct`) and the mirrored
|
||||
types in `nextjs-app/lib/types.ts`. No case transformation anywhere, so there
|
||||
is no mapping layer to get wrong.
|
||||
|
||||
## 3. Read paths
|
||||
|
||||
### Backend
|
||||
|
||||
`backend/flags.py` wraps `UnleashClient` behind `is_enabled(name: str) -> bool`.
|
||||
|
||||
Fail-closed is the default rather than something added: the Python SDK
|
||||
evaluates every flag to `False` until it has synchronised with the server. An
|
||||
unfinished feature therefore stays hidden when Unleash is unreachable, which is
|
||||
the correct direction for ship-dark.
|
||||
|
||||
The SDK's fcache directory is mounted on a named volume so a container restart
|
||||
during an Unleash outage keeps last-known state rather than reverting a
|
||||
released feature to dark. The registry default remains `False`, so the worst
|
||||
case is a feature disappearing, never one appearing.
|
||||
|
||||
### Frontend
|
||||
|
||||
`nextjs-app/lib/flags.ts` exposes `getFlags(): Promise<Flags>`, a single
|
||||
server-side fetch of `/api/flags` returning a typed record. Server components
|
||||
only — no flag value reaches the browser bundle, and `package.json` gains no
|
||||
Unleash dependency. The Unleash client library stays entirely inside the
|
||||
service that already owns every other piece of data the frontend renders.
|
||||
|
||||
The cost, named plainly: a purely front-end flag must still be declared in a
|
||||
Python file. It is a flat data edit rather than programming, and the return is
|
||||
one list, so nobody has to ask which service knows about a given flag.
|
||||
|
||||
### `/api/flags` must not be publicly reachable
|
||||
|
||||
`nextjs-app/app/api/[...path]/route.ts` proxies **everything** under `/api/` to
|
||||
FastAPI. Left alone, `https://www.schoolcompare.co.uk/api/flags` would return
|
||||
`{"admission_distance": false, ...}` — publishing the name and state of every
|
||||
unreleased feature, which defeats the purpose of shipping dark.
|
||||
|
||||
The proxy therefore gains a denylist, and `flags` is on it: a request for a
|
||||
denied path returns 404 rather than being forwarded. Next's own `getFlags()` is
|
||||
unaffected because it calls `FASTAPI_URL` directly across the Docker network
|
||||
and never transits the public proxy.
|
||||
|
||||
This is a general hole rather than a flags-specific one — the proxy will
|
||||
forward any future internal endpoint too — so the denylist is written as a
|
||||
named constant with a comment saying what belongs on it.
|
||||
|
||||
## 4. Propagation
|
||||
|
||||
**Time-based revalidation is sufficient. There is no webhook.**
|
||||
|
||||
An earlier draft of this section specified two Unleash webhooks and a
|
||||
`revalidateTag('flags')` purge, on the premise that pages cache for seven days.
|
||||
That premise was wrong, and checking it removed the most complex part of the
|
||||
design.
|
||||
|
||||
Next uses the **lowest** `revalidate` among a route's fetches to set the
|
||||
revalidation frequency of the whole route — the segment-level
|
||||
`export const revalidate` does not override a lower value inside it. Measured
|
||||
against this codebase:
|
||||
|
||||
| Page family | Segment | Lowest fetch | Effective |
|
||||
|---|---|---|---|
|
||||
| `/school/[slug]` | 604800 | `fetchSchoolDetails` at 300 | **5 minutes** |
|
||||
| `/schools/*` | 604800 | `fetchNationalAverages` at 3600 | **1 hour** |
|
||||
|
||||
The Unleash SDK polls every 15 seconds, so a flip reaches school pages within
|
||||
about five minutes and place pages within the hour, unaided. Flags flip
|
||||
monthly, by hand, deliberately. That is fast enough.
|
||||
|
||||
What this removes: two webhook integrations, a `/api/revalidate-flags` route, a
|
||||
shared-secret-in-a-query-string scheme, an idempotency requirement against
|
||||
duplicate and out-of-order delivery, and a rule that every fetch in
|
||||
`nextjs-app/lib/` carry a cache tag. None of it has to be built, maintained, or
|
||||
kept correct as new fetches are added.
|
||||
|
||||
**If instant flips are ever wanted**, the webhook is the way to add them, and it
|
||||
is purely additive — nothing in this design has to change first.
|
||||
|
||||
### Two constraints this leaves behind
|
||||
|
||||
**Never flag content on a `force-static` page.** `app/admissions/page.tsx`
|
||||
declares `export const dynamic = 'force-static'`, so it is baked at build time
|
||||
and never revalidates. A flag gating anything on such a page would not take
|
||||
effect until the next deploy, silently. If a flag ever needs to reach one, that
|
||||
page must first move to ISR.
|
||||
|
||||
**A route-family flag still needs the sitemap rebuilt.** The sitemap is held in
|
||||
memory and rebuilt only at startup or via `POST /api/admin/regenerate-sitemap`.
|
||||
No flag in scope touches the sitemap (§6), so this is deferred with the route
|
||||
case rather than solved now — but a route flag must not ship without it, or the
|
||||
sitemap will advertise URLs that `notFound()`.
|
||||
|
||||
## 5. What "off" means, per surface
|
||||
|
||||
| Surface | Off |
|
||||
|---|---|
|
||||
| Route | `notFound()`, **and** absent from the sitemap, **and** absent from nav |
|
||||
| UI element | Not rendered; surrounding page byte-identical to today |
|
||||
| API field | Key **absent**, not `null` |
|
||||
| API endpoint | 404, not 403 |
|
||||
|
||||
The three parts of the route rule move together or not at all. Submitting URLs
|
||||
to Google that return 404 is the bug fixed in PR #124, and a flag is a new way
|
||||
to reintroduce it.
|
||||
|
||||
An API field is withheld **at the source**, never rendered-but-hidden. The
|
||||
precedent is already set in this codebase by commit `c9a1892`: `/api/schools/`
|
||||
is public and unauthenticated, so leaving a withheld field in the payload hands
|
||||
the record to anyone who opens the network tab.
|
||||
|
||||
## 6. First consumer: `admission_distance`
|
||||
|
||||
The last-distance-offered feature is merged to `main` and live on staging.
|
||||
Production has never received it: `/api/schools/100010` on production carries
|
||||
no `admission_distance` key, and no Distance section renders.
|
||||
|
||||
It needs **exactly one gate** — `backend/app.py:809`, where the field is
|
||||
attached to the school payload:
|
||||
|
||||
```python
|
||||
"admission_distance": (
|
||||
supplementary.get("admission_distance")
|
||||
if flags.is_enabled("admission_distance") else None
|
||||
),
|
||||
```
|
||||
|
||||
The frontend follows with no change. `DistanceSection` already returns `null`
|
||||
when `admission_distance?.distance_m == null`, and `PrimarySchoolSections`
|
||||
already conditions the admissions block on `(admissions || admissionDistance)`.
|
||||
The off-state is the commonest state on the site — only 57 local authorities
|
||||
publish cut-off distances at all — so it is well covered by construction.
|
||||
|
||||
The flag does not touch the sitemap: school pages exist either way.
|
||||
|
||||
Intended lifecycle: default off, so production receives the code dark on the
|
||||
next promotion; on in the `development` environment so staging keeps testing
|
||||
it; flipped on in `production` when the owner chooses.
|
||||
|
||||
**This flag exercises two of the three surfaces** in §5 — API field and UI
|
||||
element. No route case ships with it. The route rule is specified but unproven
|
||||
until a route-shaped flag exists, and should be treated as such.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
**Backend unit.** The registry is well-formed; an unknown flag evaluates
|
||||
`False`; `/api/flags` returns every declared flag with its default when the
|
||||
SDK is unreachable; `admission_distance` is absent from the school payload when
|
||||
the flag is off and present when on.
|
||||
|
||||
**Frontend unit.** `getFlags()` returns declared defaults when `/api/flags`
|
||||
fails, rather than throwing and taking the page with it.
|
||||
|
||||
**E2E.** Journeys read `/api/flags` and gate flag-dependent assertions on it,
|
||||
matching the `test.skip` shape the suite already uses.
|
||||
|
||||
One trap to avoid, worth stating because the existing distance journeys walk
|
||||
straight into it: they already skip when no school has a published figure, so
|
||||
with the flag off they would skip silently and the suite would go green. The
|
||||
gate must be explicit — **if `/api/flags` reports `admission_distance` on, then
|
||||
a school with a cut-off must be found**, converting a silent skip into a real
|
||||
assertion.
|
||||
|
||||
## 8. Lifecycle
|
||||
|
||||
A flag is temporary scaffolding, and the failure mode of every flag system is
|
||||
accumulation.
|
||||
|
||||
The registry records the date each flag was added, and a backend test fails any
|
||||
flag older than **90 days**. Removing a flag means deleting the registry entry,
|
||||
the branches that read it, and the flag in the Unleash UI.
|
||||
|
||||
Unleash SDK usage metrics stay enabled, so the UI shows which flags are still
|
||||
being evaluated — the evidence needed to retire one safely.
|
||||
|
||||
## 9. Risks
|
||||
|
||||
**Production gains a homelab dependency.** If Unleash is unreachable when a
|
||||
production container cold-starts with an empty cache, every flag evaluates
|
||||
`False` and any feature currently switched on disappears. The fcache volume
|
||||
covers restarts; the 90-day lifecycle rule bounds how long any feature is
|
||||
exposed to this. It is a real regression risk and the reason flags must be
|
||||
retired rather than left on indefinitely.
|
||||
|
||||
**Flag state is not in git.** `main` no longer tells you what production is
|
||||
showing. The registry lists what *can* be flagged; only the Unleash UI says
|
||||
what *is*. This is inherent to the choice of a service.
|
||||
|
||||
**A large promotion backlog exists.** Production is running the
|
||||
pre-SEO-programme build — no place pages, and a sitemap still declaring the
|
||||
apex host. The first promotion after this work ships that entire backlog. The
|
||||
flag isolates the distance feature from it and nothing else.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Percentage rollouts, user targeting, A/B testing, and Unleash strategies
|
||||
beyond simple on/off. Flags are booleans.
|
||||
- Pipeline and dbt flags. Airflow and dbt are not flag consumers.
|
||||
- Client-side flag evaluation. Flags are server-side only.
|
||||
- Automatic flag removal. The staleness test reports; a person deletes.
|
||||
@@ -0,0 +1,294 @@
|
||||
# School Autosuggest — Design
|
||||
|
||||
**Date:** 2026-08-26
|
||||
**Status:** approved for planning
|
||||
**Depends on:** the feature-flag layer (PR #125, merged)
|
||||
|
||||
## Goal
|
||||
|
||||
Suggest schools by name as someone types in the site's main search box, so a
|
||||
parent who knows the school they want reaches it in one step instead of
|
||||
searching, scanning a result list, and clicking.
|
||||
|
||||
Scope is **schools only**. Places and postcodes were considered and excluded —
|
||||
see *Out of scope*.
|
||||
|
||||
## The finding that shapes everything
|
||||
|
||||
The site's rate limiter does not do what it looks like it does.
|
||||
|
||||
`limiter = Limiter(key_func=get_remote_address)` with `60/minute` reads
|
||||
`request.client.host`. In staging and production the backend has no published
|
||||
ports and sits on the internal `backend` network, so its only caller is the
|
||||
Next proxy — and `request.client.host` is therefore **the Next container**, for
|
||||
every browser user on the site.
|
||||
|
||||
Measured against staging: 70 concurrent requests to `/api/schools` returned
|
||||
**60 × 200 and 10 × 429**. One machine consumed the whole site's budget for
|
||||
that minute.
|
||||
|
||||
Autosuggest is the worst possible feature to build on that. One person typing
|
||||
"st marys primary" produces six to eight debounced requests; **eight concurrent
|
||||
searchers would 429 the site.** The compare modal's search-as-you-type already
|
||||
shares this bucket, so the exposure exists today — autosuggest makes it
|
||||
certain.
|
||||
|
||||
Fixing the keying is therefore part of this work, not a follow-up.
|
||||
|
||||
## 1. Rate-limit keying
|
||||
|
||||
Both environments sit behind Cloudflare (`server: cloudflare`, `cf-ray` present
|
||||
on staging and production). Cloudflare sets `CF-Connecting-IP` on every request
|
||||
to the origin and **overwrites any client-supplied value**, which makes it
|
||||
trustworthy in a way a parsed `X-Forwarded-For` chain is not.
|
||||
|
||||
```python
|
||||
def client_key(request: Request) -> str:
|
||||
"""Rate-limit bucket: the real caller, not the proxy in front of them."""
|
||||
cf = request.headers.get("cf-connecting-ip")
|
||||
if cf:
|
||||
return cf.strip()
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return get_remote_address(request)
|
||||
```
|
||||
|
||||
`nextjs-app/app/api/[...path]/route.ts` already forwards every inbound header
|
||||
except `host` and `connection`, so `CF-Connecting-IP` reaches the backend with
|
||||
no proxy change.
|
||||
|
||||
**This header is trustworthy only for traffic that actually passed through
|
||||
Cloudflare, and nothing in the application can verify that it did.** An earlier
|
||||
draft of this section claimed Cloudflare "replaces the header, so a browser
|
||||
cannot forge it", and that only the `X-Forwarded-For` fallback was forgeable.
|
||||
That was wrong. Cloudflare does overwrite the header *on requests it handles* —
|
||||
but a caller reaching the origin directly sets whatever it likes, and this
|
||||
process cannot distinguish an edge-set header from an attacker-set one. Both
|
||||
headers are equally forgeable in that scenario.
|
||||
|
||||
The consequence is sharper than a weakened defence. An attacker rotating
|
||||
`CF-Connecting-IP` per request mints a fresh rate-limit bucket every time and
|
||||
evades per-client limits entirely — including on the DataFrame-heavy
|
||||
`/api/schools`. Against abuse that is *worse* than the shared bucket it
|
||||
replaced, which at least capped everyone at 60/minute together.
|
||||
|
||||
Two mitigations, and they are not interchangeable:
|
||||
|
||||
1. **The real fix is at Cloudflare** — Authenticated Origin Pulls, or an origin
|
||||
firewall that refuses connections not from Cloudflare's ranges. Only the
|
||||
edge can vouch for its own header. This is infrastructure work and is not
|
||||
part of this change; it is the thing that makes the header mean anything.
|
||||
2. **The ceiling in §1.1 bounds what evading the keying can achieve** while
|
||||
that remains open. It does not make the header trustworthy — it makes
|
||||
trusting it survivable.
|
||||
|
||||
The backend being unreachable from outside the Docker network is a real second
|
||||
layer, but it depends on the ingress path in front of the frontend, which this
|
||||
design does not control and should not assume.
|
||||
|
||||
### 1.1 The ceiling, which is back
|
||||
|
||||
The shared bucket was acting as an accidental global throttle on a
|
||||
single-process uvicorn backend that filters a 25,000-row DataFrame in-process.
|
||||
Correct per-user keying removes it: the origin becomes reachable at 60/min *per
|
||||
user* rather than 60/min in total, and — per above — at an unbounded rate by
|
||||
anyone willing to rotate a header.
|
||||
|
||||
An earlier draft dropped the in-app ceiling, arguing it belonged at Cloudflare.
|
||||
That argument assumed the keying was sound. It is not, so the ceiling is
|
||||
load-bearing rather than redundant, and it ships here:
|
||||
|
||||
`GlobalRateLimitMiddleware` counts all `/api/` requests in a fixed 60-second
|
||||
window against `global_rate_limit_per_minute` (3000), independent of any client
|
||||
identity, and refuses with a 429 that names capacity rather than the client —
|
||||
an operator has to be able to tell "one noisy client" from "the origin is
|
||||
saturated". It is registered last so it is outermost: a ceiling that applies
|
||||
after the expensive work has run is not a ceiling.
|
||||
|
||||
slowapi cannot express this. `default_limits` and `application_limits` are both
|
||||
evaluated with the same `key_func`, making them per-client rather than global,
|
||||
and `application_limits` only apply with `SlowAPIMiddleware` installed, which
|
||||
this app does not use. Hence the explicit middleware — about thirty lines, and
|
||||
obviously correct, which is what a backstop needs to be.
|
||||
|
||||
Requests from `127.0.0.1` are exempt. The container healthcheck runs
|
||||
`curl http://localhost:80/api/data-info` from inside the container, and
|
||||
starving it would fail the check, restart the container, and turn a load spike
|
||||
into an outage loop. The exemption keys on the peer address, never the `Host`
|
||||
header, which the caller sets.
|
||||
|
||||
3000/minute is an estimate, not a measurement, and worth revisiting against
|
||||
real traffic.
|
||||
|
||||
### Per-user limits
|
||||
|
||||
Per-user fairness and origin protection are different jobs, and this design now
|
||||
does both separately: the ceiling above for the origin, and per-route limits
|
||||
for fairness. Conflating them is what produced the original behaviour, where
|
||||
one bucket served the whole internet.
|
||||
|
||||
The existing 60/minute default is unchanged, and `/api/suggest` gets
|
||||
120/minute. Both are estimates rather than measurements, and are a starting
|
||||
point to revisit once the keying is correct enough for real per-user traffic to
|
||||
be visible — which it was not before, because everyone shared one bucket.
|
||||
|
||||
## 2. `GET /api/suggest`
|
||||
|
||||
A dedicated endpoint, not a mode of `/api/schools`.
|
||||
|
||||
The existing search path calls Typesense for URNs and then filters, ranks and
|
||||
sorts the full in-memory DataFrame — a pandas pass per keystroke, holding the
|
||||
GIL and blocking other requests in the same worker. Suggestions need none of
|
||||
it: `urn`, `school_name`, `phase`, `school_type`, `local_authority`,
|
||||
`postcode` and `ofsted_rating` are all already in the Typesense document
|
||||
(`pipeline/scripts/sync_typesense.py`).
|
||||
|
||||
```
|
||||
GET /api/suggest?q=<query>&limit=8
|
||||
→ 200 {"suggestions": [
|
||||
{"urn": 100010, "school_name": "Brecknock Primary School",
|
||||
"local_authority": "Camden", "postcode": "NW1 1AA",
|
||||
"phase": "Primary", "school_type": "Community school"}
|
||||
]}
|
||||
```
|
||||
|
||||
- **Under two characters** returns `{"suggestions": []}` with 200. The
|
||||
keystroke path never returns an error for ordinary input.
|
||||
- **Typesense unavailable** returns `{"suggestions": []}` with 200. There is
|
||||
deliberately **no DataFrame fallback**: the substring scan `/api/schools`
|
||||
falls back to is precisely the cost this endpoint exists to avoid, and a
|
||||
silent 25,000-row scan per keystroke is worse than no suggestions.
|
||||
- **`limit` is clamped** to 20. It is a public endpoint.
|
||||
- **Rate limit `120/minute`** per client, not the default 60. A 200 ms
|
||||
debounce tops out near 5 requests/second while someone is actively typing,
|
||||
but averages far below that across a real search; 120 leaves headroom for
|
||||
bursts while still bounding one client.
|
||||
- **Local authority is part of the payload, not decoration.** There are many
|
||||
schools called "St Mary's"; a suggestion list without the authority is
|
||||
unusable for exactly the queries autosuggest is meant to serve.
|
||||
|
||||
### Caching
|
||||
|
||||
`CACHE_RULES` gains `("/api/suggest", (60, 3600, 86400))`. Prefix queries
|
||||
repeat enormously across users and school names change once a year.
|
||||
|
||||
The client fetch must **not** use `cache: "no-store"`. The compare modal does,
|
||||
and copying that pattern would throw away both the browser cache and the ETag
|
||||
304s the existing `CacheAndETagMiddleware` already provides.
|
||||
|
||||
Both environments currently report `cf-cache-status: DYNAMIC` — Cloudflare
|
||||
ignores the `Cache-Control` the API already sends, because it does not cache
|
||||
dynamic paths by default. **A Cloudflare Cache Rule for `/api/suggest*` would
|
||||
let the edge absorb most of this traffic and never reach the origin.** That is
|
||||
a dashboard change, it is optional, and nothing here depends on it.
|
||||
|
||||
## 3. The combobox
|
||||
|
||||
This is an ARIA combobox, not a text input with a list underneath.
|
||||
|
||||
**Files.** `FilterBar.tsx` is already long. The work splits three ways:
|
||||
`hooks/useSchoolSuggest.ts` owns fetching, debouncing and cancellation;
|
||||
`components/SuggestList.tsx` owns rendering and ARIA; `FilterBar.tsx` wires
|
||||
them to the existing input and form.
|
||||
|
||||
**Fetching.** 200 ms debounce; minimum two characters; an `AbortController`
|
||||
cancels the superseded request on every keystroke. Cancellation is not an
|
||||
optimisation — without it, a slow response for `"st"` can land after the fast
|
||||
one for `"st marys"` and replace a correct list with a stale one.
|
||||
|
||||
**Suppressed during postcode entry.** The box takes a school name *or* a
|
||||
postcode, and `isValidPostcode` already distinguishes them. Suggestions do not
|
||||
appear once the value parses as a postcode.
|
||||
|
||||
**Keyboard.** `ArrowDown`/`ArrowUp` move the active option, `Escape` closes and
|
||||
keeps the typed text, `Tab` closes. `Enter` **with an option active** navigates
|
||||
to that school's page. `Enter` **with none active** submits the free-text
|
||||
search exactly as it does today — the existing behaviour is preserved, not
|
||||
replaced.
|
||||
|
||||
**ARIA.** `role="combobox"` with `aria-expanded` and `aria-controls` on the
|
||||
input, `aria-activedescendant` pointing at the active option, `role="listbox"`
|
||||
on the list and `role="option"` on each row.
|
||||
|
||||
**Both instances get it.** `HomeView` renders `FilterBar` twice — hero and
|
||||
sticky — from one component, so there is one implementation.
|
||||
|
||||
## 4. Behind a flag
|
||||
|
||||
Flag `school_autosuggest`, declared in `backend/flags.py`, default off.
|
||||
|
||||
This is the most-used control on the site and the first change to it in a
|
||||
while. `app/page.tsx` is an async server component, so it reads the flag and
|
||||
threads it to `FilterBar` through `HomeView` — two prop hops, explicit, no
|
||||
client-side flag read.
|
||||
|
||||
Off means the input behaves exactly as it does today: no listener, no fetch, no
|
||||
markup. Not a rendered-then-hidden dropdown.
|
||||
|
||||
The rate-limit keying is **not** flagged. It is a correctness fix that should
|
||||
apply whether or not autosuggest is on, and flagging it would mean shipping a
|
||||
known-wrong limiter into production deliberately.
|
||||
|
||||
## 5. Analytics
|
||||
|
||||
`search_submitted` already carries `via: 'input'`. Selecting a suggestion fires
|
||||
it with `via: 'suggestion'` plus the chosen `urn`, so the obvious question —
|
||||
does this actually help, or do people ignore it — has an answer in the data
|
||||
rather than an opinion.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
**Backend.** `client_key` prefers `CF-Connecting-IP`, falls back through
|
||||
`X-Forwarded-For` to the remote address, and two different values get two
|
||||
different buckets. `/api/suggest` returns matches, returns empty below two
|
||||
characters, returns empty and 200 when Typesense is unavailable, and clamps
|
||||
`limit`. That it never touches the DataFrame is asserted by making
|
||||
`load_school_data` raise and requiring the endpoint to answer anyway.
|
||||
|
||||
**Frontend.** The hook debounces, aborts superseded requests, and drops a
|
||||
late-arriving response for a stale query. The list renders the ARIA
|
||||
attributes. Keyboard navigation moves the active option; `Enter` on an option
|
||||
navigates; `Enter` on none submits the search.
|
||||
|
||||
**E2E.** With the flag on, typing a known school name shows it and selecting it
|
||||
lands on that school's page. With the flag off, no combobox markup exists.
|
||||
Gated on the flag the same way the distance journeys are — read the observable
|
||||
effect, since `/api/flags` is denied to the public.
|
||||
|
||||
## 7. Risks
|
||||
|
||||
**Removing the accidental throttle.** Covered in §1. Correct per-user keying
|
||||
means the origin is reachable at 60/minute *per user* where it was 60/minute
|
||||
in total, and no in-app global cap replaces it — that job goes to Cloudflare,
|
||||
which is not done as part of this change. Until it is, a determined caller
|
||||
with many source addresses can put more load on a single-process origin than
|
||||
they can today. Against this site's traffic that is a theoretical risk rather
|
||||
than a live one, but it is a real one and it is the price of the fix.
|
||||
|
||||
**Cloudflare bypass — the open one.** If the origin is reachable without
|
||||
passing through Cloudflare, `CF-Connecting-IP` is attacker-controlled, and
|
||||
rotating it per request defeats per-client limits on every endpoint. The
|
||||
ceiling in §1.1 bounds the damage to the origin's total capacity; it does not
|
||||
restore per-client fairness under attack, and it cannot. Closing this properly
|
||||
means Authenticated Origin Pulls or an origin firewall restricted to
|
||||
Cloudflare's published ranges — infrastructure work, outside this change, and
|
||||
the single most valuable follow-up here.
|
||||
|
||||
**Typesense becomes user-visible.** Today a Typesense outage degrades search to
|
||||
a slow substring match. With autosuggest it also means the dropdown silently
|
||||
stops appearing. That is the correct failure — quiet, not broken — but it makes
|
||||
Typesense health worth monitoring in a way it was not before.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Place suggestions.** The 2,646 town, authority and outcode pages are a
|
||||
strong candidate and would route people onto the pages W2 built, but they
|
||||
live in the place registry rather than Typesense, so it is a second index and
|
||||
a ranking rule for comparing two kinds of result. Worth its own change.
|
||||
- **Postcode completion.** Would put postcodes.io in the keystroke path, with
|
||||
its own latency and rate limits.
|
||||
- **The compare modal.** It already has search-as-you-type. Converting it to
|
||||
this component is a reasonable follow-up, not part of this.
|
||||
- **Recent or popular searches.** No storage for either, and no evidence yet
|
||||
that they are wanted.
|
||||
@@ -0,0 +1,399 @@
|
||||
# Destination Measures — Design
|
||||
|
||||
**Date:** 2026-08-28
|
||||
**Status:** awaiting review
|
||||
**Scope:** secondary school detail pages only
|
||||
|
||||
## Goal
|
||||
|
||||
Say what happened to a school's leavers after they left. Two sections on the
|
||||
secondary template:
|
||||
|
||||
- **After Year 11** — every secondary, from the KS4 destination measures
|
||||
- **After the sixth form** — sixth-form schools only, from the 16-18 measures
|
||||
|
||||
This replaces the "Post-16 destination data coming soon" placeholder standing in
|
||||
`nextjs-app/components/school/SecondaryAdmissionsSection.tsx:117` since the exam
|
||||
phase taxonomy work, and fills the `ks5_destinations_pct` slot specified but
|
||||
never built in `2026-07-07-exam-phase-taxonomy-design.md:201`.
|
||||
|
||||
Mockup, with all three data states live:
|
||||
<https://claude.ai/code/artifact/5be149d6-252f-473c-9a4f-4c36b05161b0>
|
||||
|
||||
## The finding that shapes everything
|
||||
|
||||
**Suppression is per cell, and the cells sum to the cohort.**
|
||||
|
||||
DfE withholds a figure it considers disclosive by writing `c`. It does this at
|
||||
the level of an individual destination category, not the whole school, and it
|
||||
publishes the cohort total alongside. The categories form a clean partition. So
|
||||
where exactly one category is suppressed, subtracting the published ones from the
|
||||
cohort recovers it exactly.
|
||||
|
||||
Verified against three real schools in the 2022/23 file:
|
||||
|
||||
| School | URN | Withheld | Recovers to |
|
||||
|---|---|---|---|
|
||||
| North East Futures UTC | 145900 | School sixth form | **3 pupils** |
|
||||
| Whitley Bay High School | 108638 | Further education | **18 pupils** |
|
||||
| St Matthew's RC High School | 148389 | School sixth form | **4 pupils** |
|
||||
|
||||
Those are the precise numbers the `c` exists to hide, and in a random 400-school
|
||||
sample **22% of mainstream secondaries** have exactly one suppressed category in
|
||||
their disadvantaged group. This is the normal case, not an edge case.
|
||||
|
||||
Three rules follow, and everything else in this document is downstream of them.
|
||||
|
||||
**R1 — Never *publish* enough to derive a remainder.**
|
||||
|
||||
An earlier draft of this rule said "never *render* a derived remainder", and
|
||||
that was the defect code review caught in PR #137. Not drawing a number does
|
||||
nothing to stop it being computed: `GET /api/schools/{urn}` is public and
|
||||
unauthenticated, so anything in the payload is published whatever the UI
|
||||
chooses to draw. The rendering guards shipped; the payload still carried the
|
||||
cohort and every published category, and `cohort - sum(published)` returned
|
||||
Whitley Bay's withheld figure exactly.
|
||||
|
||||
The rule is therefore about the serialiser, and the UI guards are a second line
|
||||
of defence behind it. Two identities have to be closed:
|
||||
|
||||
- within a pupil group the categories sum to the cohort, so a group with
|
||||
exactly **one** suppressed category gives it away;
|
||||
- across groups, disadvantaged + other = all for every category, so a category
|
||||
suppressed in exactly **one** of the three gives itself away.
|
||||
|
||||
`_mask_for_disclosure` applies DfE's own answer — secondary suppression —
|
||||
withholding a companion cell until every row and every column hides either none
|
||||
or at least two. It iterates, because each new suppression can break the other
|
||||
identity, and terminates because cells are only ever added.
|
||||
|
||||
The companion must carry pupils. Suppressing a zero looks like secondary
|
||||
suppression and protects nothing: the residual still equals the original
|
||||
withheld figure.
|
||||
|
||||
Where no companion can do the job — a sparse cohort whose every other category
|
||||
is `not_applicable`, routine in special schools and alternative provision — the
|
||||
pupil group is **dropped from the payload entirely**. A first version simply
|
||||
returned at that point with the violation intact and no signal, which review
|
||||
caught: a disclosure-control pass that fails silently is worse than none,
|
||||
because everything downstream trusts it. The function now cannot terminate
|
||||
except in a state where `disclosure_invariant_holds()` is true, and an
|
||||
exhaustive test sweeps all 81 suppression patterns of a four-category group to
|
||||
prove it.
|
||||
|
||||
Measured cost on the 400-school sample: the all-pupils bar survives on **94%**
|
||||
of mainstream secondaries rather than 100%. That is the price of not
|
||||
republishing what DfE withheld.
|
||||
|
||||
**R2 — Never aggregate across a suppression boundary.** Summing published
|
||||
components to fill a gap is R1 with extra steps.
|
||||
|
||||
DfE's own aggregates (`Sustained education destination`, `Sustained education,
|
||||
employment & apprenticeships`) are ingested but **not served**. An aggregate
|
||||
spanning exactly one suppressed component names it, and nothing renders them
|
||||
today — an unused field that leaks is not a trade-off worth carrying. They can
|
||||
be re-added with their own guard if the fallback ladder is ever built.
|
||||
|
||||
**R3 — The three pupil groups are one disclosure surface, not three.**
|
||||
Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so
|
||||
rendering any *two* of them recovers the third. Where a category is suppressed in
|
||||
the disadvantaged group, it must therefore also be withheld from **all other
|
||||
pupils** — the all-pupils view is the primary one and keeps it.
|
||||
|
||||
This costs almost nothing, because DfE already applies the same masking: across
|
||||
the sample, 493 of 498 suppressed disadvantaged cells were suppressed in the
|
||||
other group too. The mart enforces the remaining 5, which fell on 2 schools of
|
||||
262. **The all-pupils bar is unaffected** — masking the whole page wherever the
|
||||
disadvantaged group is thin would remove the bar from 80% of schools, and is not
|
||||
what this rule says.
|
||||
|
||||
R1 and R2 both hold within a group and still leak across the switch, which is why
|
||||
R3 is stated separately.
|
||||
|
||||
### The convention that would break this quietly
|
||||
|
||||
`macros/safe_numeric.sql` coerces every EES sentinel — `z`, `c`, `x`, `q`, `u` —
|
||||
to `NULL`, deliberately and correctly for attainment, where "suppressed" and "no
|
||||
data" are equally unrenderable. Here they are not the same thing: one must print
|
||||
*withheld*, the other must print nothing at all, and the difference is what keeps
|
||||
R1 enforceable.
|
||||
|
||||
**`safe_numeric` must not be used on destination counts.** The staging model
|
||||
keeps the sentinel in a companion status column. This is the single most likely
|
||||
way for this feature to regress into a disclosure, so it gets its own dbt test.
|
||||
|
||||
## What is actually available
|
||||
|
||||
Measured against the EES public API (open, no key). Both datasets carry
|
||||
`geographicLevel: School` with `urn` on every location option, so the join to
|
||||
`dim_school` is direct.
|
||||
|
||||
| | KS4 | 16-18 |
|
||||
|---|---|---|
|
||||
| Dataset id | `019d4f41-22d1-71b2-a1a7-f3b91026815b` | `019d4e73-6440-7523-b60c-bfab1ad4a30d` |
|
||||
| Rows | 1,871,739 | 3,862,658 |
|
||||
| Institutions | 4,946 | 3,065 |
|
||||
| Time periods | 2009/10–2022/23 | 2016/17–2022/23 |
|
||||
|
||||
**Destination categories (KS4).** School sixth form · Sixth form college ·
|
||||
Further education · Other education destination · Sustained apprenticeships (with
|
||||
level breakdown) · Sustained employment destination · Not recorded as a sustained
|
||||
destination · Activity not captured. Plus the aggregates `Sustained education
|
||||
destination` and `Sustained education, employment & apprenticeships`.
|
||||
|
||||
**16-18 adds** UK higher education institution and FE split by level, which is
|
||||
what makes the post-16 section worth having.
|
||||
|
||||
**Breakdowns.** `Disadvantage Status` gives Disadvantaged / Not known to be
|
||||
disadvantaged / Total — exactly the three-way switch. Sex, ethnicity, FSM status,
|
||||
prior attainment and SEN provision also travel in the same table; we ingest none
|
||||
of them.
|
||||
|
||||
**Indicators.** Both counts and percentages, plus the cohort size. Bar widths use
|
||||
the counts — the published percentages do not sum to 100.
|
||||
|
||||
### Coverage, and what degrades
|
||||
|
||||
Random 400-school sample, 2022/23, mainstream secondaries (n=262):
|
||||
|
||||
| View | As published by DfE | After R1–R3 masking | Consequence |
|
||||
|---|---|---|---|
|
||||
| All pupils, all categories | 100% | **94%** | Bar works nearly everywhere |
|
||||
| Disadvantaged, headline rate | 95% | 95% | Gap panel works |
|
||||
| Disadvantaged, three grouped cards | 68% | 68% | Degrades card by card |
|
||||
| Disadvantaged, all six categories | 20% | **20%** | Bar unusable for this group |
|
||||
|
||||
The middle column is what the site actually serves. Masking costs the
|
||||
all-pupils bar on 6% of mainstream secondaries — those are schools where a
|
||||
category was suppressed in exactly one pupil group and no non-zero companion
|
||||
existed below the all-pupils row.
|
||||
|
||||
Special schools and alternative provision are far worse: 13% and 41% respectively
|
||||
have the whole cohort suppressed even for all pupils. The empty state is
|
||||
load-bearing, not defensive.
|
||||
|
||||
## The display
|
||||
|
||||
Question-led. Three cards over one bar, with the cards acting as a lens on the
|
||||
bar rather than a summary beside it — hovering a card dims the bar, table and
|
||||
England reference to the categories that card is built from. The full mockup is
|
||||
linked above; what matters for implementation:
|
||||
|
||||
**The headline is not the sustained rate.** That figure sits between 92% and 97%
|
||||
for nearly every school in England. The mix is what varies, so the mix leads.
|
||||
|
||||
**The grouping is ours, not DfE's.** "Academic route" = school sixth form +
|
||||
sixth-form college; "College" = FE and other colleges; "Work" = apprenticeship +
|
||||
employment. This is the most arguable thing on the page, so it lives in one place
|
||||
in `lib/destinations.ts`, is explained in a tooltip, and is reversible in one
|
||||
edit.
|
||||
|
||||
**The absence is hatched neutral, never a colour.** "Activity not captured" means
|
||||
no record in the sources DfE holds — it includes independent schools, moving
|
||||
abroad and private training. Colouring it as a bad outcome would be a factual
|
||||
error rendered in CSS. The hatch also fixes a real contrast problem: neutral
|
||||
against the employment blue failed CVD separation at ΔE 7.6, and texture is the
|
||||
secondary encoding that rescues it. Every other adjacent pair clears ΔE 10.9
|
||||
under protanopia.
|
||||
|
||||
**Colour tokens.** Education is one hue in three steps (school-like to
|
||||
college-like); apprenticeship and employment are separate hues. Six new tokens in
|
||||
`globals.css`, defined in both themes, per the existing token discipline.
|
||||
|
||||
**The disadvantage split rides the same control.** One visualisation serving
|
||||
three cohorts, with the England reference repointing to the matching national
|
||||
group. The gap statement stays visible below the bar whatever is selected,
|
||||
because a gap nobody clicks on is a gap nobody sees.
|
||||
|
||||
## Data model
|
||||
|
||||
### Extraction
|
||||
|
||||
A new `tap-uk-ees-destinations` extractor, separate from `tap-uk-ees`. The
|
||||
existing tap downloads a release ZIP and reads a CSV inside it; the destinations
|
||||
files are far larger than we need and the query API filters server-side, so this
|
||||
one POSTs to `/v1/data-sets/{id}/query` and pages through results.
|
||||
|
||||
With every dimension pinned — destination measures, disadvantage status, sex
|
||||
Total, characteristic topic Total — one year returns **252,610 rows** across all
|
||||
geographic levels. Three school-level years is comfortably tractable.
|
||||
|
||||
Pinning is mandatory, not an optimisation: leaving the characteristic dimensions
|
||||
unconstrained returned 45 rows where 9 were wanted, because every breakdown
|
||||
shares one table.
|
||||
|
||||
The tap emits the raw value as text. **It does not coerce `c`.**
|
||||
|
||||
### Staging
|
||||
|
||||
`stg_ees_ks4_destinations` / `stg_ees_ks5_destinations`. Each raw value becomes
|
||||
two columns:
|
||||
|
||||
```sql
|
||||
case when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then raw::numeric end as pupils,
|
||||
case
|
||||
when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then 'published'
|
||||
when lower(trim(raw)) = 'c' then 'suppressed'
|
||||
else 'not_applicable'
|
||||
end as status
|
||||
```
|
||||
|
||||
### Marts
|
||||
|
||||
`fact_ks4_destinations` and `fact_ks5_destinations`, **long format**:
|
||||
|
||||
```
|
||||
urn, year, pupil_group, destination_category, cohort_pupils, pupils, percentage, status
|
||||
```
|
||||
|
||||
This departs from the wide house pattern (`fact_ks4_performance` and friends) on
|
||||
purpose. `pupil_group` is a genuine third dimension; going wide would need three
|
||||
sets of every column, and R2 is far easier to test on rows than on columns.
|
||||
|
||||
Roughly 8 categories × 3 groups × 4,946 schools × 3 years ≈ 356k rows.
|
||||
|
||||
`fact_destination_national` carries the same grain for England, so the page's
|
||||
England reference repoints with the switch.
|
||||
|
||||
### dbt tests
|
||||
|
||||
- `assert_destinations_no_derived_remainder` — for every (urn, year,
|
||||
pupil_group) with exactly one suppressed category, assert no aggregate row
|
||||
exists that would let the residual be recovered. **This is the R1 guard.**
|
||||
- `assert_destinations_group_masking` — for every (urn, year, category), if the
|
||||
disadvantaged group carries `suppressed`, so does the other-pupils group.
|
||||
**This is the R3 guard**, applied in the mart so no consumer can reach an
|
||||
unmasked combination.
|
||||
- `assert_destination_status_null_agreement` — `pupils is null` wherever
|
||||
`status != 'published'`, and never null where it is.
|
||||
- `assert_destinations_join_dim_school` — no orphaned URNs, matching the
|
||||
existing `assert_no_orphaned_facts` pattern.
|
||||
|
||||
## API
|
||||
|
||||
`GET /api/schools/{urn}` gains a `destinations` block:
|
||||
|
||||
```json
|
||||
{
|
||||
"ks4": {
|
||||
"cohort_year": "2022/23",
|
||||
"published": "2026-04",
|
||||
"groups": {
|
||||
"all": { "cohort": 180, "categories": [ … ], "aggregates": { … } },
|
||||
"disadvantaged": { … },
|
||||
"other": { … }
|
||||
}
|
||||
},
|
||||
"ks5": { … }
|
||||
}
|
||||
```
|
||||
|
||||
Each category carries `pupils`, `percentage` and `status`. **The serialiser never
|
||||
emits a computed remainder**, and a backend test asserts that a group containing a
|
||||
suppressed category serialises no total that closes the gap.
|
||||
|
||||
`null` for the whole block where nothing is published — the frontend renders the
|
||||
empty state from its absence, not from a sentinel.
|
||||
|
||||
## Frontend
|
||||
|
||||
| File | Kind | Job |
|
||||
|---|---|---|
|
||||
| `lib/destinations.ts` | pure | Category list, the academic/college/work grouping, `canAggregate()` enforcing R2, percentage derivation from counts |
|
||||
| `components/school/DestinationsSection.tsx` | server | Section shell, renders **all pupils** into the HTML |
|
||||
| `components/school/DestinationsView.tsx` | client | Cohort switch, card↔bar linkage |
|
||||
| `components/school/Post16DestinationsSection.tsx` | server | Year 13 section, sixth-form schools only |
|
||||
| `app/globals.css` | tokens | Six destination colours, both themes |
|
||||
|
||||
Server-first matches the directory's existing discipline — every component in
|
||||
`components/school/` is a server component except `AdmissionsViewToggle`, which
|
||||
is the precedent this follows. All-pupils figures are in the HTML for crawlers
|
||||
and for no-JS; only the switch and the hover linkage need the client.
|
||||
|
||||
`lib/schoolSections.ts` gains `hasKs4Destinations` / `hasKs5Destinations` flags
|
||||
and the nav items, following the existing `computeSchoolFlags` pattern.
|
||||
|
||||
**Placement** on the secondary template: GCSE results → After Year 11 → After the
|
||||
sixth form → admissions. Destinations follow attainment because they answer "and
|
||||
then what happened".
|
||||
|
||||
**Dating.** The latest destination year is 2022/23, published April 2026, while
|
||||
the site's newest KS4 year is 2024/25. The section header states its own cohort
|
||||
year, or it reads as stale data next to the GCSE section above it.
|
||||
|
||||
## Edge states
|
||||
|
||||
| State | Frequency | Behaviour |
|
||||
|---|---|---|
|
||||
| Whole cohort suppressed | 13% of special, 41% of AP | Section renders the explanation, no chart |
|
||||
| Some categories withheld | 80% of disadvantaged views | Cards degrade individually; **no bar**; table marks withheld rows |
|
||||
| Disadvantaged group suppressed entirely | 5% | Switch drops to two options, gap panel not rendered |
|
||||
| No sixth form | — | Post-16 section not rendered at all — absence is correct, a "no data" placeholder would imply something is missing |
|
||||
| School too new | — | "First figures expected in 2026", not a bare no |
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, user-facing behaviour extends `e2e/` in the same PR.
|
||||
|
||||
**Unit** — `lib/destinations.ts` is where R1 and R2 live, so it carries the
|
||||
heaviest tests: `canAggregate()` refuses a group containing one suppressed cell,
|
||||
allows one spanning two, and the bar builder refuses to emit segments for any
|
||||
group with suppression. These are the tests that must fail loudly if someone
|
||||
later "fixes" a gap in the chart.
|
||||
|
||||
**dbt** — the three tests above.
|
||||
|
||||
**Backend** — the serialiser emits no closing total for a partially suppressed
|
||||
group.
|
||||
|
||||
**E2E** — a school with full data renders three cards and a bar; a school with a
|
||||
partially suppressed disadvantaged group renders the withheld state and **no bar
|
||||
element**; a suppressed school renders the explanation; a school with no sixth
|
||||
form renders no post-16 section.
|
||||
|
||||
Note the staging caveat: mart changes are inert until the Airflow pipeline runs,
|
||||
and the staging E2E gate runs post-merge.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Compare view and rankings.** The long mart shape supports both; neither is
|
||||
built here. Flagged because "% to a school sixth form" is a plausible rankings
|
||||
metric and the mart shape should not have to change to allow it.
|
||||
- **Ethnicity, sex, SEN and prior-attainment breakdowns.** Available in the same
|
||||
file, ingested deliberately not at all — each is a separate editorial decision
|
||||
about what a school page should assert.
|
||||
- **Longer term destinations** (3 and 5 years out) and **Progression to higher
|
||||
education** — separate publications, worth a later look for sixth forms.
|
||||
- **Primary schools.** No KS2 destination measures publication exists; DfE
|
||||
tracking starts at KS4. Naming the secondaries a primary's leavers go to needs
|
||||
the National Pupil Database, which is not publishable at that grain.
|
||||
|
||||
## Risks
|
||||
|
||||
**A later change reintroduces the disclosure.** The likeliest routes are
|
||||
applying `safe_numeric` to a destination column for consistency, adding a
|
||||
`coalesce` in a mart, or — as happened in review — enforcing a disclosure rule
|
||||
at the rendering layer instead of the publishing layer. Mitigation is the dbt
|
||||
tests plus `backend/tests/test_destinations_api.py`, which reconstructs the
|
||||
residual the way an attacker would and asserts it no longer resolves.
|
||||
|
||||
**The two-year lag reads as staleness.** Mitigated by dating the cohort in the
|
||||
section header rather than only in a tooltip.
|
||||
|
||||
**Sixth-form retention will be misread.** "41% went to a school sixth form" says
|
||||
nothing about *which* school. The published file reports destination type, never
|
||||
destination institution. Copy must never imply "stayed on here", and the tooltip
|
||||
should say so.
|
||||
|
||||
**Section length.** The secondary template is already long and this adds two
|
||||
sections. If it becomes a problem the post-16 section is the one to collapse
|
||||
behind a disclosure, not the Year 11 one.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Is the disadvantage split its own section or a sub-block inside the
|
||||
destinations section? Modelled as a sub-block; it is the most differentiating
|
||||
figure on the page and the most easily misread on a small cohort.
|
||||
2. Do we ingest the apprenticeship level breakdown (intermediate / advanced /
|
||||
higher) now, or collapse to one apprenticeship figure and revisit? Collapsed
|
||||
in this design.
|
||||
+831
-3
@@ -1226,6 +1226,27 @@ const CUTOFF_CANDIDATE_URNS = [
|
||||
101099, 100553, 102574, 100769, // mixed
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the last-distance-offered feature is switched on here.
|
||||
*
|
||||
* Read from the data rather than from /api/flags, which the public proxy
|
||||
* denies on purpose — the endpoint names unreleased features. The observable
|
||||
* effect is the field's presence: the flag is off iff no candidate school
|
||||
* carries an `admission_distance` key at all.
|
||||
*
|
||||
* The distinction that matters: `admission_distance: null` means this school
|
||||
* has no published cut-off, and the key being ABSENT means cut-offs are not
|
||||
* being published at all.
|
||||
*/
|
||||
async function distanceFeatureIsOn(page: Page): Promise<boolean> {
|
||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||
const res = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!res.ok()) continue;
|
||||
if ('admission_distance' in (await res.json())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function schoolWithCutoff(page: Page) {
|
||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||
const res = await page.request.get(`/api/schools/${urn}`);
|
||||
@@ -1237,6 +1258,105 @@ async function schoolWithCutoff(page: Page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
test('when the distance feature is on, a school with a cut-off is findable', async ({ page }) => {
|
||||
/*
|
||||
* The gate that stops the other distance journeys passing vacuously.
|
||||
*
|
||||
* They all skip when schoolWithCutoff() finds nothing, which is right when
|
||||
* the feature is off — but it means a feature that is *supposed* to be on
|
||||
* and is silently broken shows up as a green run full of skips. This test
|
||||
* fails in that case.
|
||||
*/
|
||||
test.skip(!(await distanceFeatureIsOn(page)),
|
||||
'the admission_distance flag is off in this environment');
|
||||
|
||||
expect(await schoolWithCutoff(page),
|
||||
'the distance feature is on, but no candidate school has a cut-off — '
|
||||
+ 'the flag is on and the data or the query behind it is broken')
|
||||
.not.toBeNull();
|
||||
});
|
||||
|
||||
test('with the distance feature off, the section is absent rather than empty', async ({ page }) => {
|
||||
// Shipping dark means the page renders as it did before the feature existed,
|
||||
// not as a feature with its content removed.
|
||||
test.skip(await distanceFeatureIsOn(page),
|
||||
'the admission_distance flag is on in this environment');
|
||||
|
||||
// A school that exists, found rather than hardcoded — a 404 page would
|
||||
// satisfy the absent-heading assertion without proving anything.
|
||||
//
|
||||
// A plain loop, not Array.find: find's predicate is synchronous, so an async
|
||||
// one returns a Promise, every Promise is truthy, and it would always hand
|
||||
// back the first URN whether or not that school exists.
|
||||
let urn: number | null = null;
|
||||
for (const candidate of CUTOFF_CANDIDATE_URNS) {
|
||||
if ((await page.request.get(`/api/schools/${candidate}`)).ok()) {
|
||||
urn = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(urn, 'no candidate school resolves in this environment').not.toBeNull();
|
||||
|
||||
await page.goto(`/school/${urn}`);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /How far away are you\?/ }))
|
||||
.toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* A secondary school carrying an EES admissions row, which is what makes its
|
||||
* Admissions section render while the distance feature is dark.
|
||||
*/
|
||||
async function secondarySchoolWithAdmissions(page: Page) {
|
||||
const list = await page.request.get('/api/schools?phase=secondary&page_size=40');
|
||||
if (!list.ok()) return null;
|
||||
const body = await list.json();
|
||||
for (const s of (body?.schools ?? []).slice(0, 25)) {
|
||||
const res = await page.request.get(`/api/schools/${s.urn}`);
|
||||
if (!res.ok()) continue;
|
||||
const detail = await res.json();
|
||||
if (detail?.admissions == null) continue;
|
||||
return { urn: s.urn as number };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('with the distance feature off, a secondary page makes no claim about publication', async ({ page }) => {
|
||||
/*
|
||||
* Shipping dark must not put words in the council's mouth. The secondary
|
||||
* template is the only one that words the absence, and "X has not published
|
||||
* a cut-off distance for this school" is false wherever X does publish and
|
||||
* we are simply withholding it.
|
||||
*
|
||||
* This is why the API omits the key rather than sending null: absent means
|
||||
* "cut-offs are not published at all", null means "this school has none".
|
||||
* Only the second is a fact about the school, and only the second is sayable.
|
||||
*/
|
||||
test.skip(await distanceFeatureIsOn(page),
|
||||
'the admission_distance flag is on in this environment');
|
||||
|
||||
const found = await secondarySchoolWithAdmissions(page);
|
||||
test.skip(found === null, 'no secondary school in the sample has an admissions row');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The Admissions section is still there — this is not a test that the whole
|
||||
// section vanished, which would pass for the wrong reason.
|
||||
await expect(page.locator('#admissions')).toHaveCount(1);
|
||||
|
||||
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Contact the admissions authority/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/api/flags is not reachable from the public internet', async ({ page }) => {
|
||||
// It names every unreleased feature and whether it is on. Next reads it
|
||||
// server-side over the Docker network; the public proxy must deny it.
|
||||
const res = await page.request.get('/api/flags');
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page);
|
||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||
@@ -1649,13 +1769,29 @@ const CANONICAL_ROUTES: Array<[string, string]> = [
|
||||
['/admissions', 'https://www.schoolcompare.co.uk/admissions'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Next normalises canonical URLs against `trailingSlash: false`, so the root
|
||||
* ships as `https://www.schoolcompare.co.uk` with no slash while every other
|
||||
* route keeps its path. Both forms address the same document, and which one
|
||||
* Next emits is its business, not something worth pinning a test to.
|
||||
*
|
||||
* The first cut hardcoded the slash and failed only on the homepage — the
|
||||
* same gap as the doubled brand: it asserted the metadata object rather than
|
||||
* what the page actually renders.
|
||||
*/
|
||||
function sameUrl(a: string | null, b: string): boolean {
|
||||
const strip = (u: string) => u.replace(/\/+$/, '');
|
||||
return strip(a ?? '') === strip(b);
|
||||
}
|
||||
|
||||
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);
|
||||
expect(sameUrl(hrefs[0], expected),
|
||||
`${path} canonical was ${hrefs[0]}, expected ${expected}`).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1663,7 +1799,8 @@ 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/');
|
||||
expect(sameUrl(href, 'https://www.schoolcompare.co.uk/'),
|
||||
`filtered homepage canonical was ${href}`).toBe(true);
|
||||
});
|
||||
|
||||
test('a school page canonicalises to its own slug on the www host', async ({ page }) => {
|
||||
@@ -1714,10 +1851,33 @@ test('staging answers noindex, and stays crawlable so the noindex is seen', asyn
|
||||
// The other half, and the reason this is one test rather than two: a
|
||||
// Disallow would stop Google fetching the page at all, so it would never
|
||||
// see the noindex above. The two only work together.
|
||||
//
|
||||
// Scoped to the `*` group. The first cut matched `Disallow: /` anywhere in
|
||||
// the file and tripped over the AI-crawler groups Cloudflare injects —
|
||||
// ClaudeBot, GPTBot, Amazonbot and friends all carry a blanket disallow,
|
||||
// deliberately, and none of them is Googlebot.
|
||||
const robots = await (await page.request.get('/robots.txt')).text();
|
||||
expect(robots).not.toMatch(/^\s*Disallow:\s*\/\s*$/mi);
|
||||
expect(blocksEverything(robots, '*'),
|
||||
'the * group must not disallow the whole site, or the noindex is never seen')
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
/** True when `agent`'s group in a robots.txt disallows the entire site. */
|
||||
function blocksEverything(robots: string, agent: string): boolean {
|
||||
let current: string | null = null;
|
||||
let blocked = false;
|
||||
for (const raw of robots.split('\n')) {
|
||||
const line = raw.split('#')[0].trim();
|
||||
if (!line) continue;
|
||||
const [key, ...rest] = line.split(':');
|
||||
const value = rest.join(':').trim();
|
||||
const k = key.trim().toLowerCase();
|
||||
if (k === 'user-agent') current = value;
|
||||
else if (current === agent && k === 'disallow' && value === '/') blocked = true;
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
|
||||
const list = await page.request.get('/api/schools?search=primary&per_page=1');
|
||||
const [first] = (await list.json()).schools ?? [];
|
||||
@@ -1726,3 +1886,671 @@ test('a school page on staging is noindexed too, not just the homepage', async (
|
||||
const res = await page.request.get(`/school/${first.urn}-x`);
|
||||
expect(res.headers()['x-robots-tag']).toContain('noindex');
|
||||
});
|
||||
|
||||
/*
|
||||
* W8 — the C1 pages must ship a description, and it must differentiate.
|
||||
*
|
||||
* Baseline was 0.43% CTR at position 6.1 on "compare school performance",
|
||||
* against 9.16% for the brand query from the same neighbourhood. The SERP is
|
||||
* owned by the DfE's own service, so a description that paraphrases it earns
|
||||
* nothing. Google may rewrite a snippet, but it cannot use one we never sent.
|
||||
*/
|
||||
test('every C1 page ships a description, and none opens its title with the brand', async ({ page }) => {
|
||||
for (const path of ['/', '/compare', '/rankings', '/admissions']) {
|
||||
await page.goto(path);
|
||||
|
||||
const desc = await page.locator('meta[name="description"]').first()
|
||||
.getAttribute('content');
|
||||
expect(desc, `${path} must ship a description`).toBeTruthy();
|
||||
expect(desc!.length, `${path} description too short to be worth reading`)
|
||||
.toBeGreaterThan(100);
|
||||
|
||||
const title = await page.title();
|
||||
expect(title.toLowerCase().startsWith('schoolcompare'),
|
||||
`${path} spends its most valuable pixels on the brand`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the homepage snippet names what gov.uk does not publish', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const desc = await page.locator('meta[name="description"]').first()
|
||||
.getAttribute('content');
|
||||
// Admissions distance is the one fact the DfE service has no equivalent for.
|
||||
expect(desc).toMatch(/close you had to live|distance/i);
|
||||
});
|
||||
|
||||
/*
|
||||
* The location layer (spec 2026-08-21, W2).
|
||||
*
|
||||
* Location intent sat at position 49.5 with one click across the whole 16-month
|
||||
* baseline — the site published no page about a place. These assert the four
|
||||
* families render, stay in their own namespaces, and reach the sitemap.
|
||||
*/
|
||||
async function firstPlaceOfKind(page: Page, kind: string) {
|
||||
const res = await page.request.get('/api/places');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { places } = await res.json();
|
||||
const hit = places.find((p: { kind: string }) => p.kind === kind);
|
||||
expect(hit, `no ${kind} in the registry`).toBeTruthy();
|
||||
return hit as { kind: string; slug: string; name: string; count: number };
|
||||
}
|
||||
|
||||
for (const [kind, prefix, article] of [
|
||||
['town', '/schools/', 'a'],
|
||||
['authority', '/schools/authority/', 'an'],
|
||||
['outcode', '/schools/near/', 'an'],
|
||||
] as const) {
|
||||
test(`${article} ${kind} page renders with its school count`, async ({ page }) => {
|
||||
const place = await firstPlaceOfKind(page, kind);
|
||||
await page.goto(`${prefix}${place.slug}`);
|
||||
await expect(page.locator('h1')).toContainText(place.name, { ignoreCase: true });
|
||||
await expect(page.locator('a[href^="/school/"]').first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('a town and an authority sharing a name are different pages', async ({ page }) => {
|
||||
// 67 real collisions, and the authority is the larger set in only 43 — so
|
||||
// one namespace would have published near-duplicates.
|
||||
const { places } = await (await page.request.get('/api/places')).json();
|
||||
const townSlugs = new Set(
|
||||
places.filter((p: { kind: string }) => p.kind === 'town')
|
||||
.map((p: { slug: string }) => p.slug));
|
||||
const clash = places.find((p: { kind: string; slug: string }) =>
|
||||
p.kind === 'authority' && townSlugs.has(p.slug));
|
||||
test.skip(!clash, 'no town/authority name collision in this environment');
|
||||
|
||||
const townRes = await page.request.get(`/api/places/town/${clash.slug}`);
|
||||
const laRes = await page.request.get(`/api/places/authority/${clash.slug}`);
|
||||
expect(townRes.ok() && laRes.ok()).toBeTruthy();
|
||||
const townUrns = (await townRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
|
||||
const laUrns = (await laRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
|
||||
expect(townUrns).not.toEqual(laUrns);
|
||||
});
|
||||
|
||||
test('a place below the threshold has no page', async ({ page }) => {
|
||||
// Crosby holds one school; publishing it would be a page with nothing to say.
|
||||
const res = await page.request.get('/api/places/town/crosby');
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('place pages declare a canonical and reach the sitemap', async ({ page }) => {
|
||||
const place = await firstPlaceOfKind(page, 'town');
|
||||
await page.goto(`/schools/${place.slug}`);
|
||||
const canonical = await page.locator('link[rel="canonical"]').first()
|
||||
.getAttribute('href');
|
||||
expect(canonical).toBe(`https://www.schoolcompare.co.uk/schools/${place.slug}`);
|
||||
|
||||
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
||||
expect(xml).toContain(`/schools/${place.slug}`);
|
||||
});
|
||||
|
||||
test('the place page ships ItemList structured data that parses', async ({ page }) => {
|
||||
const place = await firstPlaceOfKind(page, 'town');
|
||||
await page.goto(`/schools/${place.slug}`);
|
||||
const raw = await page.locator('script[type="application/ld+json"]').first()
|
||||
.textContent();
|
||||
const parsed = JSON.parse(raw!);
|
||||
const types = (parsed['@graph'] ?? []).map((n: { '@type': string }) => n['@type']);
|
||||
expect(types).toContain('ItemList');
|
||||
expect(types).toContain('BreadcrumbList');
|
||||
});
|
||||
|
||||
test('a place page states the local average against England', async ({ page }) => {
|
||||
// The one number a list cannot give, and the reason these pages are not
|
||||
// a name dropped into a template.
|
||||
const place = await firstPlaceOfKind(page, 'town');
|
||||
await page.goto(`/schools/${place.slug}`);
|
||||
await expect(page.getByTestId('local-vs-england')).toContainText(/across England/i);
|
||||
});
|
||||
|
||||
test('a place page links its phase variants, and they resolve', async ({ page }) => {
|
||||
// "primary schools in beccles" is the query shape the baseline showed. The
|
||||
// first cut submitted only the bare place URL and linked nothing, leaving
|
||||
// ~950 variant pages reachable by nothing at all.
|
||||
const res = await page.request.get('/api/places');
|
||||
const { places } = await res.json();
|
||||
const town = places.find((p: { kind: string }) => p.kind === 'town');
|
||||
expect(town).toBeTruthy();
|
||||
|
||||
const detail = await (await page.request.get(`/api/places/town/${town.slug}`)).json();
|
||||
test.skip(!(detail.place.phases ?? []).length, 'no phase clears the threshold here');
|
||||
|
||||
await page.goto(`/schools/${town.slug}`);
|
||||
const phase = detail.place.phases[0];
|
||||
const link = page.locator(`a[href="/schools/${town.slug}/${phase}"]`).first();
|
||||
await expect(link).toBeVisible();
|
||||
|
||||
await link.click();
|
||||
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
|
||||
});
|
||||
|
||||
/*
|
||||
* The table shipped with one column of scores. A parent shortlisting from a
|
||||
* town page needs to know whether a school takes their child's age, whether
|
||||
* it is a faith school, and — for a primary — whether it has a nursery,
|
||||
* before a percentage means anything.
|
||||
*
|
||||
* These assert the column headings rather than the values: nursery_provision
|
||||
* and parliamentary_constituency are optional mart columns, and on an
|
||||
* environment whose pipeline has not rebuilt them the API degrades them to
|
||||
* absent. A value assertion would then fail for a data reason, not a code one.
|
||||
*/
|
||||
async function phasedPlace(page: Page, phase: 'primary' | 'secondary') {
|
||||
const place = await firstPlaceOfKind(page, 'town');
|
||||
const detail = await (await page.request.get(`/api/places/town/${place.slug}`)).json();
|
||||
test.skip(!(detail.place.phases ?? []).includes(phase),
|
||||
`no ${phase} page clears the threshold here`);
|
||||
return place;
|
||||
}
|
||||
|
||||
test('a primary place page names each school as well as scoring it', async ({ page }) => {
|
||||
const place = await phasedPlace(page, 'primary');
|
||||
await page.goto(`/schools/${place.slug}/primary`);
|
||||
for (const heading of ['Ages', 'Religious character', 'Nursery', 'Constituency']) {
|
||||
await expect(page.getByRole('columnheader', { name: heading, exact: true }))
|
||||
.toBeVisible();
|
||||
}
|
||||
// age_range rides in on SCHOOL_COLUMNS and predates the optional columns,
|
||||
// so it is the one attribute safe to assert a value for anywhere.
|
||||
await expect(page.locator('table tbody td').filter({ hasText: /^\d+–\d+$/ }).first())
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test('a secondary place page does not ask about nurseries', async ({ page }) => {
|
||||
const place = await phasedPlace(page, 'secondary');
|
||||
await page.goto(`/schools/${place.slug}/secondary`);
|
||||
await expect(page.getByRole('columnheader', { name: 'Ages', exact: true }))
|
||||
.toBeVisible();
|
||||
await expect(page.getByRole('columnheader', { name: 'Nursery', exact: true }))
|
||||
.toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the measure stays beside the school name, not behind a swipe', async ({ page }) => {
|
||||
// Six columns overflow a phone; .tableWrap turns that into a horizontal
|
||||
// scroll. With the measure last, the number the page exists for is the one
|
||||
// off the screen.
|
||||
const place = await phasedPlace(page, 'primary');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/schools/${place.slug}/primary`);
|
||||
const second = page.locator('table thead th').nth(1);
|
||||
await expect(second).toContainText(/reading, writing/i);
|
||||
await expect(second).toBeInViewport();
|
||||
});
|
||||
|
||||
test('phase variants are submitted in the places sitemap', async ({ page }) => {
|
||||
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
||||
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
|
||||
});
|
||||
|
||||
test('authority phase variants are submitted, and in their own namespace', async ({ page }) => {
|
||||
// 302 of these were in the sitemap for weeks and every one 404'd: the spec
|
||||
// called for the route, the plan built the bare authority page and dropped
|
||||
// it, and the sitemap — written from the registry — kept submitting them.
|
||||
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
||||
expect(xml).toMatch(/\/schools\/authority\/[a-z0-9-]+\/primary</);
|
||||
});
|
||||
|
||||
test('every place link a place page emits resolves', async ({ page }) => {
|
||||
/*
|
||||
* The guard that was missing. Each family built its own links, so a URL
|
||||
* shape belonging to one namespace was used by all four: an authority page
|
||||
* offered "Primary schools in Barnet" pointing at /schools/barnet/primary,
|
||||
* the *town*. For 87 of 151 authorities that 404'd; for the other 64 it
|
||||
* quietly served a different set of schools under the same name.
|
||||
*
|
||||
* Only /schools links are followed. The per-school links are the same
|
||||
* component the school-page journeys already cover, and there are hundreds
|
||||
* of them on a page.
|
||||
*/
|
||||
for (const kind of ['town', 'authority', 'outcode'] as const) {
|
||||
const place = await firstPlaceOfKind(page, kind);
|
||||
const prefix = kind === 'authority' ? '/schools/authority/'
|
||||
: kind === 'outcode' ? '/schools/near/' : '/schools/';
|
||||
await page.goto(`${prefix}${place.slug}`);
|
||||
|
||||
const hrefs = [...new Set(
|
||||
await page.locator('a[href^="/schools"]').evaluateAll(
|
||||
(els) => els.map((e) => e.getAttribute('href')!)))];
|
||||
expect(hrefs.length, `${kind} page links no other place`).toBeGreaterThan(0);
|
||||
|
||||
for (const href of hrefs) {
|
||||
const res = await page.request.get(href);
|
||||
expect(res.status(), `${kind} page links ${href}`).toBe(200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('an outcode page offers no phase link, because no such page exists', async ({ page }) => {
|
||||
// Nobody searches "primary schools in SW11", so the spec gives outcodes no
|
||||
// phase route. The registry computed the variants anyway and the page
|
||||
// linked them, putting two 404s on each of 1,720 outcode pages.
|
||||
const place = await firstPlaceOfKind(page, 'outcode');
|
||||
const detail = await (await page.request.get(
|
||||
`/api/places/outcode/${place.slug}`)).json();
|
||||
expect(detail.place.phases).toEqual([]);
|
||||
|
||||
await page.goto(`/schools/near/${place.slug}`);
|
||||
await expect(page.getByRole('navigation', { name: 'By phase' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no page title repeats the brand', async ({ page }) => {
|
||||
// The root layout appends '| schoolcompare' to a plain-string title. Any
|
||||
// route whose title already carries the brand must opt out with
|
||||
// `absolute`, or it ships '... | schoolcompare | schoolcompare' — which is
|
||||
// how ~2,600 place pages first went out.
|
||||
const res = await page.request.get('/api/places');
|
||||
const { places } = await res.json();
|
||||
const town = places.find((p: { kind: string }) => p.kind === 'town');
|
||||
|
||||
for (const path of ['/', '/rankings', '/admissions', `/schools/${town.slug}`]) {
|
||||
await page.goto(path);
|
||||
const title = await page.title();
|
||||
const brands = (title.match(/schoolcompare/gi) ?? []).length;
|
||||
expect(brands, `${path} repeats the brand: ${title}`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('a place straddling a boundary names every authority it sits in', async ({ page }) => {
|
||||
// A quarter of outcodes and a third of towns cross an authority boundary —
|
||||
// SW19 is mostly Merton but partly Wandsworth. Naming only the largest
|
||||
// asserts something false about the place.
|
||||
const { places } = await (await page.request.get('/api/places')).json();
|
||||
const outcode = places.find((p: { kind: string }) => p.kind === 'outcode');
|
||||
expect(outcode).toBeTruthy();
|
||||
|
||||
// Find any place the registry reports as straddling.
|
||||
let straddling: { kind: string; slug: string } | null = null;
|
||||
for (const p of places.filter((p: { kind: string }) => p.kind === 'outcode').slice(0, 40)) {
|
||||
const d = await (await page.request.get(`/api/places/outcode/${p.slug}`)).json();
|
||||
if ((d.place.authorities ?? []).length > 1) { straddling = p; break; }
|
||||
}
|
||||
test.skip(!straddling, 'no straddling outcode found in the sample');
|
||||
|
||||
const detail = await (await page.request.get(
|
||||
`/api/places/outcode/${straddling!.slug}`)).json();
|
||||
await page.goto(`/schools/near/${straddling!.slug}`);
|
||||
|
||||
for (const a of detail.place.authorities) {
|
||||
if (a.slug) {
|
||||
await expect(page.locator(`a[href="/schools/authority/${a.slug}"]`).first())
|
||||
.toBeVisible();
|
||||
} else {
|
||||
// No page of its own — City of London and the Isles of Scilly are
|
||||
// under the threshold. Named, deliberately not linked.
|
||||
await expect(page.locator('header p')).toContainText(a.name);
|
||||
await expect(page.getByRole('link', { name: a.name })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a place page lists its schools alphabetically', async ({ page }) => {
|
||||
// Someone on a place page is usually looking for a school they can name,
|
||||
// so the order should serve scanning for it. /rankings is where the
|
||||
// league-table ordering lives.
|
||||
const { places } = await (await page.request.get('/api/places')).json();
|
||||
const town = places.find((p: { kind: string; count: number }) =>
|
||||
p.kind === 'town' && p.count >= 5);
|
||||
expect(town).toBeTruthy();
|
||||
|
||||
await page.goto(`/schools/${town.slug}`);
|
||||
|
||||
/*
|
||||
* Per table, not per page.
|
||||
*
|
||||
* An unphased place page renders one table per phase, and an all-through
|
||||
* school legitimately appears in both — so the page's school links are not
|
||||
* one alphabetical run and never were. This assertion used to collect them
|
||||
* all together and only passed because no town it picked happened to hold an
|
||||
* all-through school; when the data gave Abbots Langley one, Breakspeare
|
||||
* School showed up in the primary table and again in the secondary, and the
|
||||
* test failed on correct behaviour.
|
||||
*/
|
||||
const tables = page.locator('table');
|
||||
const tableCount = await tables.count();
|
||||
expect(tableCount).toBeGreaterThan(0);
|
||||
|
||||
let checked = 0;
|
||||
for (let i = 0; i < tableCount; i++) {
|
||||
const names = await tables.nth(i).locator('a[href^="/school/"]').allTextContents();
|
||||
if (names.length < 2) continue; // a one-row table says nothing about order
|
||||
const sorted = [...names].sort((a, b) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
expect(names, `table ${i + 1} is not alphabetical`).toEqual(sorted);
|
||||
checked++;
|
||||
}
|
||||
expect(checked, 'no table had enough rows to check the ordering').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the rankings page still orders by score, not name', async ({ page }) => {
|
||||
// Alphabetical is a place-page decision, not a site-wide one.
|
||||
const res = await page.request.get('/api/rankings?metric=rwm_expected_pct&phase=primary');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const scores = ((await res.json()).rankings ?? [])
|
||||
.map((r: { rwm_expected_pct: number | null }) => r.rwm_expected_pct)
|
||||
.filter((v: number | null) => v != null);
|
||||
expect(scores).toEqual([...scores].sort((a: number, b: number) => b - a));
|
||||
});
|
||||
|
||||
/*
|
||||
* Analytics on the location layer.
|
||||
*
|
||||
* Umami counts a pageview for every one of these URLs already. What it cannot
|
||||
* say is which *kind* of location page earns engagement, because all four
|
||||
* families share the /schools/ prefix — and that is the question that decides
|
||||
* whether to keep investing in them.
|
||||
*/
|
||||
|
||||
/** Capture Umami events, with the real script blocked so it cannot clobber
|
||||
* the stub. Must be called before the first navigation. */
|
||||
async function captureEvents(page: Page) {
|
||||
const events: Array<{ name: string; data: Record<string, unknown> }> = [];
|
||||
await page.route('**/analytics.schoolcompare.co.uk/**', (route) => route.abort());
|
||||
await page.exposeFunction('__capture',
|
||||
(name: string, data: Record<string, unknown>) => { events.push({ name, data }); });
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { umami: unknown }).umami = {
|
||||
track: (name: string, data: unknown) =>
|
||||
(window as unknown as { __capture: (n: string, d: unknown) => void })
|
||||
.__capture(name, data),
|
||||
};
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
test('a location page reports which kind of place it is', async ({ page }) => {
|
||||
const events = await captureEvents(page);
|
||||
const place = await firstPlaceOfKind(page, 'authority');
|
||||
|
||||
await page.goto(`/schools/authority/${place.slug}`);
|
||||
await expect.poll(() => events.find((e) => e.name === 'place_viewed'),
|
||||
{ timeout: 10_000 }).toBeTruthy();
|
||||
|
||||
const event = events.find((e) => e.name === 'place_viewed')!;
|
||||
expect(event.data.kind).toBe('authority');
|
||||
expect(event.data.slug).toBe(place.slug);
|
||||
expect(event.data.phase).toBe('all');
|
||||
});
|
||||
|
||||
test('a school reached from a location page is attributed to it, not to direct', async ({ page }) => {
|
||||
/*
|
||||
* The defect this was written for. getNavigationSource had no case for
|
||||
* /schools/, so every school view that came through the location layer was
|
||||
* filed as 'direct' — the bucket you read as "typed the URL". The one
|
||||
* measurement that says whether ~3,900 SEO pages work was reporting the
|
||||
* wrong answer, confidently.
|
||||
*/
|
||||
const events = await captureEvents(page);
|
||||
const place = await firstPlaceOfKind(page, 'town');
|
||||
|
||||
await page.goto(`/schools/${place.slug}`);
|
||||
await page.locator('a[href^="/school/"]').first().click();
|
||||
await page.waitForURL(/\/school\//);
|
||||
|
||||
await expect.poll(() => events.find((e) => e.name === 'school_viewed'),
|
||||
{ timeout: 10_000 }).toBeTruthy();
|
||||
expect(events.find((e) => e.name === 'school_viewed')!.data.from).toBe('place');
|
||||
});
|
||||
|
||||
/*
|
||||
* School autosuggest (spec 2026-08-26).
|
||||
*/
|
||||
async function autosuggestIsOn(page: Page): Promise<boolean> {
|
||||
await page.goto('/');
|
||||
return (await page.getByRole('combobox').count()) > 0;
|
||||
}
|
||||
|
||||
test('the suggest endpoint answers from Typesense', async ({ page }) => {
|
||||
// Not flagged — the endpoint is live even while the UI is dark, so it can
|
||||
// be smoke-tested before the feature is switched on.
|
||||
const res = await page.request.get('/api/suggest?q=brecknock');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { suggestions } = await res.json();
|
||||
expect(Array.isArray(suggestions)).toBeTruthy();
|
||||
if (suggestions.length) {
|
||||
// Local authority is what tells two "St Mary's" apart.
|
||||
expect(suggestions[0]).toHaveProperty('school_name');
|
||||
expect(suggestions[0]).toHaveProperty('local_authority');
|
||||
}
|
||||
});
|
||||
|
||||
test('a one-character query is answered, not rejected', async ({ page }) => {
|
||||
// The keystroke path never errors on ordinary input.
|
||||
const res = await page.request.get('/api/suggest?q=b');
|
||||
expect(res.status()).toBe(200);
|
||||
expect((await res.json()).suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
test('the suggest response is cacheable', async ({ page }) => {
|
||||
const res = await page.request.get('/api/suggest?q=brecknock');
|
||||
expect(res.headers()['cache-control'] ?? '').toContain('s-maxage');
|
||||
});
|
||||
|
||||
test('typing a school name suggests it, and choosing it opens that school', async ({ page }) => {
|
||||
test.skip(!(await autosuggestIsOn(page)),
|
||||
'the school_autosuggest flag is off in this environment');
|
||||
|
||||
// A school certain to exist in any environment with data.
|
||||
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
|
||||
test.skip(!schools?.length, 'no schools in this environment');
|
||||
const name = schools[0].school_name as string;
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('combobox').first().fill(name.slice(0, 12));
|
||||
const option = page.getByRole('option').first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(page).toHaveURL(/\/school\/\d+/);
|
||||
});
|
||||
|
||||
test('the whole dropdown is reachable, not clipped by the hero', async ({ page }) => {
|
||||
/*
|
||||
* The hero panel had overflow: hidden to clip its artwork to the rounded
|
||||
* corners, and it clipped the dropdown too — 320px of list against 145px of
|
||||
* panel below the input, so roughly half was cut off with nothing to say so.
|
||||
*
|
||||
* toBeVisible() does not catch this: it checks the box is non-empty and not
|
||||
* visibility:hidden, and an ancestor's overflow clips neither. The invariant
|
||||
* that does catch it is that the LAST option is the thing actually painted
|
||||
* at its own coordinates — which fails for clipping and for occlusion alike.
|
||||
*/
|
||||
test.skip(!(await autosuggestIsOn(page)),
|
||||
'the school_autosuggest flag is off in this environment');
|
||||
|
||||
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
|
||||
test.skip(!schools?.length, 'no schools in this environment');
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('combobox').first().fill(
|
||||
(schools[0].school_name as string).slice(0, 6));
|
||||
|
||||
const options = page.getByRole('option');
|
||||
await expect(options.first()).toBeVisible();
|
||||
const count = await options.count();
|
||||
|
||||
const painted = await options.nth(count - 1).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return { inside: el.contains(hit) || el === hit, bottom: Math.round(r.bottom) };
|
||||
});
|
||||
expect(painted.inside,
|
||||
`the last option is not painted at its own coordinates (bottom ${painted.bottom}) `
|
||||
+ '— an ancestor is clipping or covering the dropdown').toBeTruthy();
|
||||
});
|
||||
|
||||
test('the dropdown does not survive into the results it produced', async ({ page }) => {
|
||||
/*
|
||||
* The bug that took the staging gate down, and it was not a test problem:
|
||||
* after a search the results-page bar still holds the term, so the dropdown
|
||||
* reopened on top of the results and swallowed the click on the first one.
|
||||
* Playwright reported it as "<li role=option> intercepts pointer events"; a
|
||||
* reader would simply have found their first result unclickable.
|
||||
*/
|
||||
test.skip(!(await autosuggestIsOn(page)),
|
||||
'the school_autosuggest flag is off in this environment');
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('combobox').first().fill('school');
|
||||
await expect(page.getByRole('option').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Search/i }).first().click();
|
||||
await page.waitForURL(/search=school/);
|
||||
|
||||
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||
// And the results underneath are actually reachable, which is the point.
|
||||
await page.locator('a[href^="/school/"]').first().click({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/school\//);
|
||||
});
|
||||
|
||||
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
|
||||
test.skip(await autosuggestIsOn(page),
|
||||
'the school_autosuggest flag is on in this environment');
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('combobox')).toHaveCount(0);
|
||||
// And the box still works: the existing search must be untouched.
|
||||
await page.getByPlaceholder(/School name or postcode/i).first().fill('abbey');
|
||||
await page.getByRole('button', { name: /Search/i }).first().click();
|
||||
await expect(page).toHaveURL(/search=abbey/);
|
||||
});
|
||||
|
||||
// ── Destination measures ───────────────────────────────────────────────────
|
||||
//
|
||||
// Two failure modes have to be told apart here, and conflating them is how
|
||||
// this suite would either hide a regression or block the promotion pipeline:
|
||||
//
|
||||
// * the backend does not serve the `destinations` field at all — a code
|
||||
// regression, or a deploy that did not land. FAILS.
|
||||
// * the field is served but every school is empty — the annual EES DAG has
|
||||
// not run on this environment yet. SKIPS, loudly.
|
||||
//
|
||||
// The second is a data-load precondition, not a defect, and it is true for
|
||||
// every commit between this merging and the DAG being triggered. Failing on it
|
||||
// would redden the staging gate for unrelated work. This is not the quiet skip
|
||||
// 4f01fbd removed from the distance journeys: that one hid a broken feature
|
||||
// behind a flag check, whereas the assertion that the code is deployed and
|
||||
// correctly shaped still runs here on every commit.
|
||||
|
||||
async function secondaryWithDestinations(page: Page): Promise<{
|
||||
urn: string; destinations: any;
|
||||
}> {
|
||||
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const urns: string[] = (body.schools ?? [])
|
||||
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||
s.phase === 'Secondary' && s.attainment_8_score != null)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThan(0);
|
||||
|
||||
let served = false;
|
||||
for (const urn of urns.slice(0, 25)) {
|
||||
const detail = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!detail.ok()) continue;
|
||||
const data = await detail.json();
|
||||
// The key must exist, even as null. Its absence means the backend in front
|
||||
// of us does not know about destinations at all.
|
||||
if ('destinations' in data) served = true;
|
||||
if (data.destinations?.ks4) return { urn, destinations: data.destinations };
|
||||
}
|
||||
|
||||
expect(served,
|
||||
'GET /api/schools/{urn} served no `destinations` key at all — the backend '
|
||||
+ 'is missing this feature, not merely missing its data').toBeTruthy();
|
||||
|
||||
test.skip(true,
|
||||
'No school has destination data yet: the annual EES DAG has not run on '
|
||||
+ 'this environment. The API shape is correct, so this is a data-load '
|
||||
+ 'precondition rather than a regression.');
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
|
||||
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
|
||||
const { urn } = await secondaryWithDestinations(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
|
||||
const section = page.locator('#destinations');
|
||||
await expect(section).toBeVisible({ timeout: 15_000 });
|
||||
await expect(section.getByRole('heading', { name: 'After Year 11' })).toBeVisible();
|
||||
// The section must date its own cohort: destinations run about two GCSE
|
||||
// years behind the results above them, and an undated figure reads as stale.
|
||||
await expect(section).toContainText(/20\d{2}\/\d{2}/);
|
||||
});
|
||||
|
||||
test('the destinations bar is absent entirely whenever a figure is withheld', async ({ page }) => {
|
||||
const { urn, destinations } = await secondaryWithDestinations(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
const section = page.locator('#destinations');
|
||||
await expect(section).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const allGroup = destinations.ks4.groups.all;
|
||||
const suppressed = (allGroup?.categories ?? [])
|
||||
.filter((c: { status: string }) => c.status === 'suppressed');
|
||||
|
||||
if (suppressed.length > 0) {
|
||||
// R1: a bar drawn from the published segments leaves a gap whose width is
|
||||
// the withheld figure, readable straight off the axis.
|
||||
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
|
||||
await expect(section.getByText(/withheld/i).first()).toBeVisible();
|
||||
} else {
|
||||
const published = (allGroup?.categories ?? [])
|
||||
.filter((c: { status: string }) => c.status === 'published');
|
||||
await expect(section.locator('[data-destination-segment]'))
|
||||
.toHaveCount(published.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to disadvantaged pupils never reveals a withheld figure', async ({ page }) => {
|
||||
const { urn, destinations } = await secondaryWithDestinations(page);
|
||||
const disadvantaged = destinations.ks4.groups.disadvantaged;
|
||||
test.skip(!disadvantaged, 'this school publishes no disadvantaged breakdown');
|
||||
|
||||
await page.goto(`/school/${urn}`);
|
||||
const section = page.locator('#destinations');
|
||||
await expect(section).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const radio = section.getByRole('radio', { name: /disadvantaged/i });
|
||||
await expect(radio).toBeVisible();
|
||||
await radio.click();
|
||||
|
||||
const suppressed = (disadvantaged.categories ?? [])
|
||||
.filter((c: { status: string }) => c.status === 'suppressed');
|
||||
if (suppressed.length > 0) {
|
||||
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
|
||||
|
||||
// The residual must appear nowhere on the page — it is the withheld figure.
|
||||
const cohort: number = disadvantaged.cohort;
|
||||
const publishedTotal = (disadvantaged.categories ?? [])
|
||||
.filter((c: { status: string }) => c.status === 'published')
|
||||
.reduce((sum: number, c: { pupils: number }) => sum + c.pupils, 0);
|
||||
const residual = cohort - publishedTotal;
|
||||
const text = (await section.textContent()) ?? '';
|
||||
expect(text).not.toMatch(new RegExp(`\\b${residual}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
|
||||
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||
const body = await res.json();
|
||||
const noSixthForm = (body.schools ?? [])
|
||||
.filter((s: { phase?: string; has_sixth_form?: boolean }) =>
|
||||
s.phase === 'Secondary' && s.has_sixth_form === false)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
test.skip(noSixthForm.length === 0, 'no sixth-form-less secondary in this dataset');
|
||||
|
||||
await page.goto(`/school/${noSixthForm[0]}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
// Absence is the correct statement, so there must be no placeholder either.
|
||||
await expect(page.locator('#post16-destinations')).toHaveCount(0);
|
||||
await expect(page.getByText(/destination data coming soon/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the destinations section never claims a pupil stayed at this school', async ({ page }) => {
|
||||
const { urn } = await secondaryWithDestinations(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
const section = page.locator('#destinations');
|
||||
await expect(section).toBeVisible({ timeout: 15_000 });
|
||||
// The published file records the TYPE of place a leaver went to, never which
|
||||
// one, so the page can never say a pupil stayed on here.
|
||||
const text = (await section.textContent()) ?? '';
|
||||
expect(text).not.toMatch(/stayed on (here|at this school)/i);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The /api/* proxy is public. Anything it forwards is on the internet.
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
// The docblock above is load-bearing. jest.config.js sets jsdom globally, and
|
||||
// NextRequest/NextResponse need the Web Fetch API globals that only the node
|
||||
// environment provides — under jsdom this suite fails on import, not on an
|
||||
// assertion.
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET } from '@/app/api/[...path]/route';
|
||||
|
||||
function request(path: string) {
|
||||
return new NextRequest(`http://localhost:3000/api/${path}`);
|
||||
}
|
||||
|
||||
describe('public API proxy', () => {
|
||||
it('refuses to forward internal-only paths', async () => {
|
||||
// /api/flags names every unreleased feature and its state. Forwarding it
|
||||
// publishes the thing shipping dark exists to keep quiet.
|
||||
const res = await GET(request('flags'), { params: Promise.resolve({ path: ['flags'] }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('does not deny a path that merely starts with the same letters', async () => {
|
||||
// A prefix match would take /api/flagship down with /api/flags.
|
||||
const res = await GET(
|
||||
request('flagship'), { params: Promise.resolve({ path: ['flagship'] }) });
|
||||
expect(res.status).not.toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,80 @@ describe('/compare indexability', () => {
|
||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* W8 — snippet copy for the C1 cluster.
|
||||
*
|
||||
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
||||
* page one and converting at a tenth of the normal rate: "compare school
|
||||
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
||||
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
||||
* "Compare school performance" service, so the job of this copy is to say
|
||||
* what that service does not offer, without losing intent match on the title.
|
||||
*
|
||||
* These tests guard the mechanics that make a snippet work — length, intent
|
||||
* keyword, differentiator, no brand-first — not the exact wording, which
|
||||
* should stay free to iterate.
|
||||
*/
|
||||
|
||||
// Google truncates titles near 60 characters and descriptions near 155.
|
||||
const TITLE_MAX = 60;
|
||||
const DESC_MIN = 110;
|
||||
const DESC_MAX = 155;
|
||||
|
||||
type Meta = { title?: unknown; description?: unknown };
|
||||
const titleOf = (m: Meta): string => {
|
||||
const t = m.title as string | { absolute?: string } | undefined;
|
||||
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
||||
};
|
||||
|
||||
describe('C1 snippet copy', () => {
|
||||
const pages: Array<[string, Meta, RegExp]> = [
|
||||
['home', homeMetadata as Meta, /compare schools/i],
|
||||
['rankings', rankingsMetadata as Meta, /league table/i],
|
||||
['admissions', admissionsMetadata as Meta, /admission/i],
|
||||
];
|
||||
|
||||
for (const [name, meta, intent] of pages) {
|
||||
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
||||
const t = titleOf(meta);
|
||||
expect(t).toMatch(intent);
|
||||
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
||||
});
|
||||
|
||||
it(`${name}: title does not open with the brand`, () => {
|
||||
// The measured 0.43% CTR came from a brand-first title. The most
|
||||
// valuable pixels go to the thing the searcher typed.
|
||||
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
||||
});
|
||||
|
||||
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
||||
const d = meta.description as string;
|
||||
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
||||
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
||||
});
|
||||
}
|
||||
|
||||
it('the homepage description names what gov.uk does not publish', () => {
|
||||
// Admissions distance is the one fact the DfE service has no equivalent
|
||||
// for. If it ever leaves this description, the snippet is competing with
|
||||
// gov.uk on gov.uk's own ground.
|
||||
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
||||
});
|
||||
|
||||
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
||||
// Two pages chasing one phrase is how a site competes with itself.
|
||||
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
||||
expect(m.title).toMatch(/comparison tool/i);
|
||||
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
||||
});
|
||||
});
|
||||
|
||||
it('no C1 page claims a school count that will drift', () => {
|
||||
// The corpus moves with every data refresh; this repo has already shipped
|
||||
// one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
|
||||
for (const [, meta] of pages) {
|
||||
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||
|
||||
jest.mock('@/lib/places', () => ({
|
||||
...jest.requireActual('@/lib/places'),
|
||||
fetchPlace: jest.fn(async (kind: string, slug: string) =>
|
||||
slug === 'atlantis' ? null : ({
|
||||
place: { kind, slug, name: 'Brentwood', count: 29,
|
||||
parent_authority: 'Essex' },
|
||||
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
})),
|
||||
fetchPlaces: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe('place page metadata', () => {
|
||||
it('titles the page the way the place is searched', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
|
||||
});
|
||||
|
||||
it('canonicalises to its own path on the www host', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect(m.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
||||
});
|
||||
|
||||
it('opts out of the layout template, which would double the brand', () => {
|
||||
// The root layout appends '| schoolcompare' to a plain string title, and
|
||||
// these titles already carry it — every place page shipped reading
|
||||
// '... | schoolcompare | schoolcompare' until this was made absolute.
|
||||
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
|
||||
.then((m) => {
|
||||
expect(typeof m.title).toBe('object');
|
||||
expect((m.title as { absolute: string }).absolute)
|
||||
.not.toMatch(/schoolcompare.*schoolcompare/);
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
||||
expect(m.title).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DestinationsSection } from '@/components/school/DestinationsSection';
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
import type { DestinationCategory, DestinationStatus } from '@/lib/destinations';
|
||||
|
||||
const cell = (
|
||||
category: DestinationCategory,
|
||||
pupils: number | null,
|
||||
status: DestinationStatus = 'published',
|
||||
) => ({
|
||||
category, pupils,
|
||||
percentage: pupils === null ? null : (pupils / 180) * 100,
|
||||
status,
|
||||
});
|
||||
|
||||
const ALL_PUBLISHED = [
|
||||
cell('school_sixth_form', 75), cell('sixth_form_college', 21),
|
||||
cell('further_education', 55), cell('other_education', 6),
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
];
|
||||
|
||||
const fullPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED } },
|
||||
};
|
||||
|
||||
const suppressedPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 180,
|
||||
categories: [
|
||||
cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'),
|
||||
cell('further_education', 55), cell('other_education', 6),
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('DestinationsSection', () => {
|
||||
it('dates its own cohort so it is not read as stale next to the GCSE section', () => {
|
||||
render(<DestinationsSection destinations={fullPhase} />);
|
||||
expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one bar segment per published category', () => {
|
||||
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
||||
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('renders NO bar at all when a category is withheld', () => {
|
||||
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// R1: a bar with a gap in it publishes the withheld figure by its width.
|
||||
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0);
|
||||
expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never states the remainder for a partially suppressed group', () => {
|
||||
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere.
|
||||
expect(container.textContent).not.toMatch(/\b21\b/);
|
||||
});
|
||||
|
||||
it('shows a card value for a group whose components are all published', () => {
|
||||
render(<DestinationsSection destinations={fullPhase} />);
|
||||
// academic route = 75 + 21 = 96 of 180 = 53%
|
||||
expect(screen.getByText('53%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses a card value when one of its components is withheld', () => {
|
||||
render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// academic route needs sixth_form_college, which is suppressed.
|
||||
expect(screen.getByText(/not published/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('53%')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never claims a pupil stayed at this school', () => {
|
||||
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
||||
// The published file reports destination TYPE, never destination institution.
|
||||
expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i);
|
||||
});
|
||||
|
||||
it('renders nothing when no group carries categories', () => {
|
||||
const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
|
||||
const { container } = render(<DestinationsSection destinations={empty} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the detail table keeps the three statuses apart', () => {
|
||||
// 'suppressed' and 'not_applicable' are different claims, and the mart, the
|
||||
// SQLAlchemy model and the serialiser all preserve the difference. The table
|
||||
// used to key its Share column off `percentage === null`, which is true for
|
||||
// both, so a category that simply does not apply was labelled "withheld" —
|
||||
// while the Pupils column beside it rendered blank.
|
||||
const mixedPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 180,
|
||||
categories: [
|
||||
cell('school_sixth_form', 75),
|
||||
cell('sixth_form_college', null, 'suppressed'),
|
||||
cell('further_education', null, 'suppressed'),
|
||||
cell('apprenticeship', null, 'not_applicable'),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rowFor = (container: HTMLElement, category: string) =>
|
||||
Array.from(container.querySelectorAll('tbody tr'))
|
||||
.find(tr => tr.textContent?.includes(category));
|
||||
|
||||
it('never labels a not-applicable category as withheld', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'Apprenticeship');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.textContent).not.toMatch(/withheld/i);
|
||||
});
|
||||
|
||||
it('labels a genuinely suppressed category as withheld in both columns', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'Sixth-form college');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.querySelectorAll('td')).toHaveLength(2);
|
||||
Array.from(row!.querySelectorAll('td')).forEach(td =>
|
||||
expect(td.textContent).toMatch(/withheld/i));
|
||||
});
|
||||
|
||||
it('the two columns of a row never disagree about what the row is', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
Array.from(container.querySelectorAll('tbody tr')).forEach(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'))
|
||||
.map(td => /withheld/i.test(td.textContent ?? ''));
|
||||
expect(new Set(cells).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a published category its real figures', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'State-funded school sixth form');
|
||||
expect(row!.textContent).toMatch(/75/);
|
||||
expect(row!.textContent).toMatch(/42%/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FilterBar } from '@/components/FilterBar';
|
||||
|
||||
const push = jest.fn();
|
||||
let searchParams = new URLSearchParams();
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
const FILTERS = {
|
||||
local_authorities: [], school_types: [], years: [], phases: [],
|
||||
genders: [], admissions_policies: [],
|
||||
};
|
||||
|
||||
const realFetch = global.fetch;
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ suggestions: [{
|
||||
urn: 100010, school_name: 'Brecknock Primary School',
|
||||
local_authority: 'Camden', postcode: 'NW1 1AA',
|
||||
phase: 'Primary', school_type: 'Community school' }] }),
|
||||
})) as unknown as typeof fetch;
|
||||
push.mockClear();
|
||||
searchParams = new URLSearchParams();
|
||||
});
|
||||
afterEach(() => { global.fetch = realFetch; });
|
||||
|
||||
describe('FilterBar autosuggest', () => {
|
||||
it('is a combobox only when the flag is on', () => {
|
||||
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
rerender(<FilterBar filters={FILTERS} autosuggest />);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes no request while the flag is off', async () => {
|
||||
// Off means off: no listener, no fetch, no markup.
|
||||
render(<FilterBar filters={FILTERS} autosuggest={false} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i),
|
||||
'brecknock');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows suggestions and navigates when one is chosen', async () => {
|
||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||
await userEvent.type(screen.getByRole('combobox'), 'brecknock');
|
||||
const option = await screen.findByRole('option', { name: /Brecknock/ });
|
||||
await userEvent.click(option);
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/school/100010'));
|
||||
});
|
||||
|
||||
it('suppresses suggestions once the value is a postcode', async () => {
|
||||
// The box takes a name OR a postcode; suggestions must get out of the way.
|
||||
//
|
||||
// fireEvent.change, not userEvent.type: typing sets "N", "NW", "NW1"... and
|
||||
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
|
||||
// the settled value is the assertion, so set it in one go.
|
||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } });
|
||||
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Enter with no active option still submits the free-text search', async () => {
|
||||
// The existing behaviour is preserved, not replaced.
|
||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.type(input, 'brecknock{Enter}');
|
||||
// updateURL pushes inside startTransition, so the call is not synchronous.
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith(
|
||||
expect.stringContaining('search=brecknock')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterBar autosuggest does not reopen over results', () => {
|
||||
it('stays shut when the input arrives pre-filled from the URL', async () => {
|
||||
/*
|
||||
* The results-page bar renders with the search term already in the input.
|
||||
* Opening on that would drop the dropdown on top of the results the search
|
||||
* just produced — which is exactly what happened: the first result became
|
||||
* unclickable, because the list sat over it and swallowed the pointer.
|
||||
*
|
||||
* Suggestions answer typing, not the presence of a value.
|
||||
*/
|
||||
searchParams = new URLSearchParams('search=brecknock');
|
||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveValue('brecknock');
|
||||
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the dropdown when the search is submitted', async () => {
|
||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||
const input = screen.getByRole('combobox');
|
||||
|
||||
await userEvent.type(input, 'brecknock');
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, '{Enter}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import type { PlaceDetail } from '@/lib/places';
|
||||
|
||||
const detail: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
|
||||
parent_authority: 'Essex', phases: ['primary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
|
||||
ofsted_grade: 1, phase: 'Primary' } as never,
|
||||
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
|
||||
ofsted_grade: 3, phase: 'Primary' } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
describe('PlaceView', () => {
|
||||
it('leads with an H1 that matches how the place is searched', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByRole('heading', { level: 1 }))
|
||||
.toHaveTextContent(/primary schools in brentwood/i);
|
||||
});
|
||||
|
||||
it('states the count so the page says something before the table', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('compares the local average against England, which a list cannot', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
|
||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
|
||||
});
|
||||
|
||||
it('links every school in scope, which is what de-orphans them', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('links to the parent authority so the place sits in a hierarchy', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /Essex/i }))
|
||||
.toHaveAttribute('href', '/schools/authority/essex');
|
||||
});
|
||||
|
||||
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to neighbouring places so the page is not a dead end', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
|
||||
expect(screen.getByRole('link', { name: /Romford/ }))
|
||||
.toHaveAttribute('href', '/schools/romford');
|
||||
});
|
||||
|
||||
it('says nothing about an average it does not have', () => {
|
||||
render(<PlaceView detail={{ ...detail, averages:
|
||||
{ rwm_expected_pct: null, attainment_8_score: null } }}
|
||||
phase="primary" englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView structured data', () => {
|
||||
function jsonLd() {
|
||||
const { container } = render(<PlaceView detail={detail} phase="primary"
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const el = container.querySelector('script[type="application/ld+json"]');
|
||||
return JSON.parse(el!.textContent!);
|
||||
}
|
||||
|
||||
it('declares the page as a ranked list, not prose', () => {
|
||||
const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']);
|
||||
expect(types).toContain('ItemList');
|
||||
expect(types).toContain('BreadcrumbList');
|
||||
});
|
||||
|
||||
it('gives every listed school an absolute URL on the canonical host', () => {
|
||||
const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
|
||||
expect(list.itemListElement).toHaveLength(2);
|
||||
for (const item of list.itemListElement) {
|
||||
expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView phase variants', () => {
|
||||
it('links the phase variants that exist', () => {
|
||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /Primary schools in Brentwood/i }))
|
||||
.toHaveAttribute('href', '/schools/brentwood/primary');
|
||||
});
|
||||
|
||||
it('links no variant for a phase below its own threshold', () => {
|
||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not link sideways from a variant page to itself', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView presentation', () => {
|
||||
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
|
||||
// one primary-only measure for a list that also holds secondaries.
|
||||
const mixed: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
|
||||
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
||||
rwm_expected_pct: null, attainment_8_score: 47 } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
||||
};
|
||||
|
||||
it('gives each phase its own table rather than one column of blanks', () => {
|
||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('82%')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the measure in plain words, not jargon', () => {
|
||||
// The first cut said "RWM expected", which appears nowhere else on the site.
|
||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
|
||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says a missing result is unpublished rather than showing a bare dash', () => {
|
||||
const noResult: PlaceDetail = {
|
||||
...mixed,
|
||||
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
|
||||
rwm_expected_pct: null, attainment_8_score: null } as never],
|
||||
};
|
||||
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByText('Not published')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('styles school links to the site convention rather than browser default', () => {
|
||||
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
const link = container.querySelector('a[href^="/school/"]');
|
||||
expect(link?.className).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a phased page shows one table and no phase headings', () => {
|
||||
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView table alignment', () => {
|
||||
const aligned: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
|
||||
parent_authority: 'Essex', phases: ['primary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('aligns the measure heading and its values with the same class', () => {
|
||||
// They were aligned by two different selectors whose specificity did not
|
||||
// match: `.table th:last-child` (0,2,1) won and went right, while `.num`
|
||||
// (0,1,0) lost to `.table td` (0,1,1) and stayed left. Sharing one class
|
||||
// is what makes them impossible to drift apart.
|
||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
const th = container.querySelectorAll('th')[1];
|
||||
const td = container.querySelectorAll('tbody td')[1];
|
||||
expect(th.className).toBeTruthy();
|
||||
expect(td.className).toBe(th.className);
|
||||
});
|
||||
|
||||
it('leaves the school-name column unclassed so it takes the spare width', () => {
|
||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(container.querySelectorAll('th')[0].className).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView authorities', () => {
|
||||
const straddling: PlaceDetail = {
|
||||
place: { kind: 'outcode', slug: 'sw19', name: 'SW19', count: 33,
|
||||
parent_authority: 'Merton', phases: ['primary'],
|
||||
authorities: [
|
||||
{ name: 'Merton', slug: 'merton', count: 26 },
|
||||
{ name: 'Wandsworth', slug: 'wandsworth', count: 7 },
|
||||
] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('names every authority the place straddles, not just the largest', () => {
|
||||
// SW19 is mostly Merton but partly Wandsworth. Naming one asserts
|
||||
// something false about a quarter of outcodes.
|
||||
render(<PlaceView detail={straddling} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: 'Merton' }))
|
||||
.toHaveAttribute('href', '/schools/authority/merton');
|
||||
expect(screen.getByRole('link', { name: 'Wandsworth' }))
|
||||
.toHaveAttribute('href', '/schools/authority/wandsworth');
|
||||
});
|
||||
|
||||
it('joins them readably rather than as a bare list', () => {
|
||||
// Asserted on the summary line's whole text: a loose /and/ matcher also
|
||||
// hits "Wandsworth".
|
||||
const { container } = render(<PlaceView detail={straddling}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const summary = container.querySelector('header p');
|
||||
expect(summary?.textContent).toContain('Merton and Wandsworth');
|
||||
});
|
||||
|
||||
it('falls back to the single parent when the field is absent', () => {
|
||||
// A cached API response predating the authorities field must not blank
|
||||
// the line entirely.
|
||||
const legacy = { ...straddling,
|
||||
place: { ...straddling.place, authorities: undefined } };
|
||||
render(<PlaceView detail={legacy} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: 'Merton' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView list ordering', () => {
|
||||
const detail3: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
|
||||
parent_authority: 'Essex', phases: ['primary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 40, attainment_8_score: null } as never,
|
||||
{ urn: 2, school_name: 'Beta Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 90, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 65, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('renders schools in the order the API sent them, not by score', () => {
|
||||
// The API sorts alphabetically now; the component must not re-sort.
|
||||
render(<PlaceView detail={detail3} englandAverage={61} neighbours={[]} />);
|
||||
const links = screen.getAllByRole('link', { name: /Primary$/ });
|
||||
expect(links.map((l) => l.textContent))
|
||||
.toEqual(['Alpha Primary', 'Beta Primary']);
|
||||
});
|
||||
|
||||
it('declares the list as ascending rather than implying a ranking', () => {
|
||||
// An ItemList carrying `position` reads as a ranking unless it says
|
||||
// otherwise, and the table is A-Z.
|
||||
const { container } = render(<PlaceView detail={detail3} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
const ld = JSON.parse(
|
||||
container.querySelector('script[type="application/ld+json"]')!.textContent!);
|
||||
const list = ld['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
|
||||
expect(list.itemListOrder).toBe('https://schema.org/ItemListOrderAscending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView phase links', () => {
|
||||
const authority: PlaceDetail = {
|
||||
place: { kind: 'authority', slug: 'barnet', name: 'Barnet', count: 156,
|
||||
parent_authority: null, phases: ['primary', 'secondary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('keeps an authority phase link in the authority namespace', () => {
|
||||
// The link was built as `/schools/${slug}/${phase}` for every kind, so an
|
||||
// authority page pointed into the town namespace. For 87 of 151
|
||||
// authorities that 404'd; for the other 64 it silently landed on the town
|
||||
// page of the same name — a different set of schools, and exactly the
|
||||
// duplicate the two namespaces exist to prevent. Barnet is one of the 64.
|
||||
render(<PlaceView detail={authority} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /^Primary schools in Barnet$/ }))
|
||||
.toHaveAttribute('href', '/schools/authority/barnet/primary');
|
||||
expect(screen.getByRole('link', { name: /^Secondary schools in Barnet$/ }))
|
||||
.toHaveAttribute('href', '/schools/authority/barnet/secondary');
|
||||
});
|
||||
|
||||
it('still uses the bare namespace for a town', () => {
|
||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /^Primary schools in Brentwood$/ }))
|
||||
.toHaveAttribute('href', '/schools/brentwood/primary');
|
||||
});
|
||||
|
||||
it('offers no phase link when the place publishes none', () => {
|
||||
// Outcodes are the case: no phase route exists for them, so the registry
|
||||
// reports no phases and the nav does not render.
|
||||
const outcode = { ...detail,
|
||||
place: { ...detail.place, kind: 'outcode', slug: 'cm13', name: 'CM13',
|
||||
phases: [] } };
|
||||
render(<PlaceView detail={outcode} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.queryByRole('navigation', { name: 'By phase' }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView unlinkable authorities', () => {
|
||||
const withUnpublished: PlaceDetail = {
|
||||
place: { kind: 'outcode', slug: 'tr21', name: 'TR21', count: 8,
|
||||
parent_authority: 'Cornwall', phases: [],
|
||||
authorities: [
|
||||
{ name: 'Cornwall', slug: 'cornwall', count: 6 },
|
||||
{ name: 'Isles Of Scilly', slug: null, count: 2 },
|
||||
] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('names an authority with no page without linking it', () => {
|
||||
// City of London and the Isles of Scilly hold fewer schools than a page
|
||||
// needs. Saying where the place is stays right; linking there would 404.
|
||||
const { container } = render(<PlaceView detail={withUnpublished}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: 'Cornwall' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Isles Of Scilly' }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(container.querySelector('header p')?.textContent)
|
||||
.toContain('Isles Of Scilly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView school attributes', () => {
|
||||
/*
|
||||
* The table shipped with one column of scores, which answers "how did they
|
||||
* do" and nothing about whether the school is one a family could use. Age
|
||||
* range, faith, nursery and constituency are the four facts a parent
|
||||
* filters on before they look at a number at all.
|
||||
*/
|
||||
const withAttributes: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'chelmsford', name: 'Chelmsford', count: 3,
|
||||
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null,
|
||||
age_range: '4-11', religious_denomination: 'Church of England',
|
||||
nursery_provision: true,
|
||||
parliamentary_constituency: 'Chelmsford' } as never,
|
||||
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
||||
rwm_expected_pct: null, attainment_8_score: 47,
|
||||
age_range: '11-16', religious_denomination: 'Does not apply',
|
||||
nursery_provision: false,
|
||||
parliamentary_constituency: 'Witham' } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
||||
};
|
||||
|
||||
function headings(container: HTMLElement, table = 0): string[] {
|
||||
return Array.from(container.querySelectorAll('table')[table]
|
||||
.querySelectorAll('thead th')).map((th) => th.textContent ?? '');
|
||||
}
|
||||
|
||||
it('heads a primary table with all four attributes', () => {
|
||||
const { container } = render(<PlaceView detail={withAttributes}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
expect(headings(container)).toEqual([
|
||||
'School', 'Reading, writing & maths',
|
||||
'Ages', 'Religious character', 'Nursery', 'Constituency',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits nursery from a secondary table, where it does not apply', () => {
|
||||
const { container } = render(<PlaceView detail={withAttributes}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
expect(headings(container, 1)).toEqual([
|
||||
'School', 'Attainment 8', 'Ages', 'Religious character', 'Constituency',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the measure beside the school name, where a phone can see it', () => {
|
||||
// Six columns overflow a phone and .tableWrap turns that into a swipe.
|
||||
// With the measure last, the one number the page exists for is the one
|
||||
// scrolled off the screen.
|
||||
const { container } = render(<PlaceView detail={withAttributes}
|
||||
phase="primary" englandAverage={61} neighbours={[]} />);
|
||||
expect(headings(container)[1]).toBe('Reading, writing & maths');
|
||||
});
|
||||
|
||||
it('shows the age range without repeating the column heading', () => {
|
||||
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByText('4–11')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Ages 4–11')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the faith of a faith school', () => {
|
||||
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByText('Church of England')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads "Does not apply" as no religious character, not as a value', () => {
|
||||
// GIAS spells the absence of a faith as "Does not apply", which is a
|
||||
// database answer rather than an English one. The school page already
|
||||
// suppresses it; the two must not disagree about the same school.
|
||||
const { container } = render(<PlaceView detail={withAttributes}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const secondary = container.querySelectorAll('table')[1]
|
||||
.querySelectorAll('tbody td');
|
||||
expect(secondary[3].textContent).toBe('—');
|
||||
expect(screen.queryByText(/Does not apply/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a nursery as such and a school without one as not', () => {
|
||||
const { container } = render(<PlaceView detail={withAttributes}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const cells = container.querySelectorAll('table')[0]
|
||||
.querySelectorAll('tbody td');
|
||||
expect(cells[4].textContent).toBe('Yes');
|
||||
});
|
||||
|
||||
it('names the constituency of each school', () => {
|
||||
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByText('Chelmsford', { selector: 'td' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Witham', { selector: 'td' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dashes an attribute the data does not carry', () => {
|
||||
// nursery_provision and parliamentary_constituency are absent from marts
|
||||
// the pipeline has not rebuilt, and the API degrades them to null rather
|
||||
// than failing. A row must survive that.
|
||||
const bare: PlaceDetail = {
|
||||
...withAttributes,
|
||||
schools: [{ urn: 3, school_name: 'Gamma Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 70 } as never],
|
||||
};
|
||||
const { container } = render(<PlaceView detail={bare} phase="primary"
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const cells = Array.from(container.querySelectorAll('tbody td'))
|
||||
.map((td) => td.textContent);
|
||||
expect(cells.slice(2)).toEqual(['—', '—', '—', '—']);
|
||||
});
|
||||
|
||||
it('gives an all-through school its nursery under primary only', () => {
|
||||
// All-through schools render in both groups. Nursery belongs to the
|
||||
// primary reading of the same school, not the secondary one.
|
||||
const allThrough: PlaceDetail = {
|
||||
...withAttributes,
|
||||
schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through',
|
||||
rwm_expected_pct: 66, attainment_8_score: 51,
|
||||
age_range: '4-18', religious_denomination: 'None',
|
||||
nursery_provision: true,
|
||||
parliamentary_constituency: 'Chelmsford' } as never],
|
||||
};
|
||||
const { container } = render(<PlaceView detail={allThrough}
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const tables = container.querySelectorAll('table');
|
||||
expect(tables[0].textContent).toContain('Yes');
|
||||
expect(tables[1].textContent).not.toContain('Yes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Post16DestinationsSection } from '@/components/school/Post16DestinationsSection';
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
|
||||
const phase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 96,
|
||||
categories: [
|
||||
{ category: 'higher_education', pupils: 56, percentage: 58.3, status: 'published' },
|
||||
{ category: 'further_education', pupils: 12, percentage: 12.5, status: 'published' },
|
||||
{ category: 'apprenticeship', pupils: 9, percentage: 9.4, status: 'published' },
|
||||
{ category: 'employment', pupils: 13, percentage: 13.5, status: 'published' },
|
||||
{ category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Post16DestinationsSection', () => {
|
||||
it('names the Year 13 cohort, not Year 11', () => {
|
||||
const { container } = render(<Post16DestinationsSection destinations={phase} />);
|
||||
expect(container.textContent).toMatch(/Year 13/);
|
||||
expect(container.textContent).not.toMatch(/Year 11/);
|
||||
});
|
||||
|
||||
it('reports higher education destinations', () => {
|
||||
render(<Post16DestinationsSection destinations={phase} />);
|
||||
expect(screen.getByText(/UK higher education/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses its own anchor so the nav does not collide with After Year 11', () => {
|
||||
const { container } = render(<Post16DestinationsSection destinations={phase} />);
|
||||
expect(container.querySelector('#post16-destinations')).toBeTruthy();
|
||||
expect(container.querySelector('#destinations')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when no group carries categories', () => {
|
||||
const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
|
||||
const { container } = render(<Post16DestinationsSection destinations={empty} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The trail has to be written by something, and it has to be written on every
|
||||
* route — not only the ones that happen to track an event.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
const recordVisitedPath = jest.fn();
|
||||
let pathname = '/schools/brentwood';
|
||||
|
||||
jest.mock('next/navigation', () => ({ usePathname: () => pathname }));
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
recordVisitedPath: (p: string) => recordVisitedPath(p),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { RouteTrail } = require('@/components/RouteTrail');
|
||||
|
||||
describe('RouteTrail', () => {
|
||||
beforeEach(() => recordVisitedPath.mockClear());
|
||||
|
||||
it('records the page it is mounted on', () => {
|
||||
render(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenCalledWith('/schools/brentwood');
|
||||
});
|
||||
|
||||
it('records each new route as the user moves through the app', () => {
|
||||
const { rerender } = render(<RouteTrail />);
|
||||
pathname = '/school/115429-brentwood-school';
|
||||
rerender(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenLastCalledWith(
|
||||
'/school/115429-brentwood-school');
|
||||
});
|
||||
|
||||
it('renders nothing, so it can sit anywhere in the layout', () => {
|
||||
const { container } = render(<RouteTrail />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SuggestList, suggestOptionId } from '@/components/SuggestList';
|
||||
|
||||
const ROWS = [
|
||||
{ urn: 1, school_name: "St Mary's Primary", local_authority: 'Camden',
|
||||
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Voluntary aided school' },
|
||||
{ urn: 2, school_name: "St Mary's Primary", local_authority: 'Barnet',
|
||||
postcode: 'EN5 2AA', phase: 'Primary', school_type: 'Community school' },
|
||||
];
|
||||
|
||||
describe('SuggestList', () => {
|
||||
it('is a listbox of options', () => {
|
||||
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
|
||||
onPick={() => {}} onHover={() => {}} />);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the local authority, which is what tells two schools apart', () => {
|
||||
// Both rows are "St Mary's Primary". Without the authority the list is
|
||||
// unusable for exactly the query autosuggest exists to serve.
|
||||
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
|
||||
onPick={() => {}} onHover={() => {}} />);
|
||||
expect(screen.getByText('Camden')).toBeInTheDocument();
|
||||
expect(screen.getByText('Barnet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks only the active option selected', () => {
|
||||
render(<SuggestList id="s" suggestions={ROWS} activeIndex={1}
|
||||
onPick={() => {}} onHover={() => {}} />);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0]).toHaveAttribute('aria-selected', 'false');
|
||||
expect(options[1]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('gives each option the id the input will point at', () => {
|
||||
// aria-activedescendant on the input has to name a real element id, or
|
||||
// a screen reader announces nothing as the user arrows through.
|
||||
render(<SuggestList id="s" suggestions={ROWS} activeIndex={0}
|
||||
onPick={() => {}} onHover={() => {}} />);
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute(
|
||||
'id', suggestOptionId('s', 0));
|
||||
});
|
||||
|
||||
it('renders nothing when there is nothing to suggest', () => {
|
||||
const { container } = render(<SuggestList id="s" suggestions={[]}
|
||||
activeIndex={-1} onPick={() => {}} onHover={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { TrackPlaceView } from '@/components/places/TrackPlaceView';
|
||||
|
||||
const trackMock = jest.fn();
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: (...args: unknown[]) => trackMock(...args),
|
||||
getNavigationSource: () => 'search',
|
||||
}));
|
||||
|
||||
describe('TrackPlaceView', () => {
|
||||
beforeEach(() => trackMock.mockClear());
|
||||
|
||||
it('reports which kind of location page was viewed', () => {
|
||||
/*
|
||||
* `kind` is the reason this event exists. Whether to keep investing in the
|
||||
* location layer turns on which *sort* of page earns engagement — towns,
|
||||
* authorities or postcode districts — and a bare pageview cannot say,
|
||||
* because all four families share the /schools/ prefix.
|
||||
*/
|
||||
render(<TrackPlaceView kind="authority" slug="kent" count={412} />);
|
||||
expect(trackMock).toHaveBeenCalledWith('place_viewed', {
|
||||
kind: 'authority', slug: 'kent', phase: 'all',
|
||||
school_count: 412, from: 'search',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the phase when the page is a phase variant', () => {
|
||||
render(<TrackPlaceView kind="town" slug="brentwood" count={29} phase="primary" />);
|
||||
expect(trackMock).toHaveBeenCalledWith('place_viewed',
|
||||
expect.objectContaining({ phase: 'primary' }));
|
||||
});
|
||||
|
||||
it('fires once, not once per render', () => {
|
||||
const { rerender } = render(
|
||||
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||
rerender(<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||
expect(trackMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders nothing', () => {
|
||||
const { container } = render(
|
||||
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Guards against light-theme-only CSS.
|
||||
*
|
||||
* The site themes entirely through tokens redefined under
|
||||
* `@media (prefers-color-scheme: dark)`. A hardcoded colour therefore does not
|
||||
* fail loudly — it renders perfectly in the theme it was written for and
|
||||
* quietly wrongly in the other, which nobody sees unless they happen to be in
|
||||
* dark mode when they look.
|
||||
*
|
||||
* Both rules below are drawn from real defects in SchoolHeroMap.module.css,
|
||||
* found by eye rather than by any test:
|
||||
*
|
||||
* - the map's fade to the header ramped through hardcoded white and landed on
|
||||
* `var(--bg-card)`. Invisible in light; a bright band across the full width
|
||||
* of a near-black card in dark.
|
||||
* - the controls floating over the map paired a hardcoded white background
|
||||
* with `color: var(--text-primary)`, which resolves to #E9EEF0 in dark —
|
||||
* near-white text on a near-white button.
|
||||
*/
|
||||
|
||||
const COMPONENTS = path.join(__dirname, '..', '..', 'components');
|
||||
|
||||
function stylesheets(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return stylesheets(full);
|
||||
return entry.name.endsWith('.module.css') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Innermost `selector { body }` pairs. Nested at-rules never match as rules,
|
||||
* because their body contains braces.
|
||||
*
|
||||
* Comments are stripped before matching rather than after, so that the whole
|
||||
* selector survives. Taking only its last line — which is what stripping a
|
||||
* leading comment used to require — silently discarded every selector in a
|
||||
* grouped rule but the final one, and a safety guard that cannot see half its
|
||||
* input fails open. */
|
||||
function rules(css: string): Array<{ selector: string; body: string }> {
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return Array.from(bare.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().replace(/\s*\n\s*/g, ' '),
|
||||
body: m[2],
|
||||
}));
|
||||
}
|
||||
|
||||
const HARDCODED_WHITE_BG = /background[^;]*(?:255,\s*255,\s*255|#fff\b|#ffffff\b)/i;
|
||||
const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/;
|
||||
|
||||
const files = stylesheets(COMPONENTS);
|
||||
|
||||
/** Component sources, for the third-party-surface rule below. */
|
||||
function sources(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return sources(full);
|
||||
return entry.name.endsWith('.tsx') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('dark-theme safety', () => {
|
||||
it('finds stylesheets to check', () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never pairs a hardcoded white background with a themed text colour', () => {
|
||||
const offenders = files.flatMap((file) =>
|
||||
rules(fs.readFileSync(file, 'utf8'))
|
||||
.filter((r) => HARDCODED_WHITE_BG.test(r.body) && THEMED_COLOR.test(r.body))
|
||||
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
|
||||
|
||||
// Either the surface follows the theme and so should the text, or it does
|
||||
// not and the text must be literal too. Mixing them is how near-white text
|
||||
// ends up on a near-white button.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('never fades to a themed colour through a hardcoded one', () => {
|
||||
const offenders = files.flatMap((file) =>
|
||||
rules(fs.readFileSync(file, 'utf8'))
|
||||
.filter((r) => /linear-gradient/.test(r.body)
|
||||
&& /var\(--bg-(card|primary|secondary)\)/.test(r.body)
|
||||
&& /255,\s*255,\s*255|#fff\b/i.test(r.body))
|
||||
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
|
||||
|
||||
// A gradient that lands on a token has to be made of that token, or the
|
||||
// ramp and its destination disagree in one theme. Use the matching
|
||||
// `--*-rgb` token for the transparent stops.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same defect one stylesheet further out.
|
||||
*
|
||||
* The rules above scan our own CSS modules. They cannot see a surface painted
|
||||
* by a third-party sheet: leaflet.css hardcodes `background: white` on
|
||||
* `.leaflet-popup-content-wrapper` and `.leaflet-popup-tip`, and
|
||||
* LeafletMapInner builds its popup as an HTML string with inline
|
||||
* `color: var(--text-primary)`. Neither half lives in a .module.css, so the
|
||||
* module scan passed while dark mode rendered #E9EEF0 on #FFFFFF — 1.17:1,
|
||||
* with the school name and the headline figure effectively invisible.
|
||||
*
|
||||
* globals.css already pulls the rest of Leaflet's chrome onto the tokens (the
|
||||
* attribution bar, the zoom controls) for exactly this reason. The popup was
|
||||
* simply missed.
|
||||
*/
|
||||
describe('third-party surfaces under themed text', () => {
|
||||
const GLOBALS = path.join(__dirname, '..', '..', 'app', 'globals.css');
|
||||
|
||||
/** Leaflet surfaces our own code writes token-coloured text onto. */
|
||||
const LEAFLET_POPUP_SURFACES = [
|
||||
'.leaflet-popup-content-wrapper',
|
||||
'.leaflet-popup-tip',
|
||||
];
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// Guards the rule below against passing vacuously if the popups are ever
|
||||
// rewritten as React components rather than HTML strings.
|
||||
const themed = sources(COMPONENTS).filter((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
||||
});
|
||||
|
||||
expect(themed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('themes the Leaflet popup surface, because the text on it is themed', () => {
|
||||
const globals = rules(fs.readFileSync(GLOBALS, 'utf8'));
|
||||
|
||||
const unthemed = LEAFLET_POPUP_SURFACES.filter((surface) => {
|
||||
const rule = globals.find((r) => r.selector.includes(surface));
|
||||
return !rule || !/background[^;]*var\(--/.test(rule.body);
|
||||
});
|
||||
|
||||
// Leaflet's white is not a colour this site owns. Either the surface
|
||||
// follows the theme or the text on it must be literal — and the text is
|
||||
// already themed.
|
||||
expect(unthemed).toEqual([]);
|
||||
});
|
||||
|
||||
it('never puts a literal white label on a themed fill', () => {
|
||||
/*
|
||||
* The mirror image of the module-CSS rule above, and the half of the popup
|
||||
* that theming the card does not reach. "View Details" is
|
||||
* `background:var(--status-above);color:white`; --status-above is #36743F
|
||||
* in light but #7FCB8A in dark, so the label went from 5.63:1 to 1.94:1.
|
||||
*
|
||||
* --text-inverse is the token for ink on a saturated fill — #FFFFFF in
|
||||
* light, #111A20 in dark — and the popup's Ofsted badge already uses it.
|
||||
*/
|
||||
const offenders = sources(COMPONENTS).flatMap((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return Array.from(
|
||||
src.matchAll(/background:\s*var\(--[^;"']*;[^"']*?color:\s*(white|#fff\b|#ffffff\b)/gi),
|
||||
() => path.relative(COMPONENTS, file));
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Destination measures add the first new colour family since the palette was
|
||||
* set. The tokens have to exist in both blocks or the section renders one
|
||||
* theme's fills on the other theme's ground — the exact failure the suite
|
||||
* above exists to catch, but for tokens rather than literals.
|
||||
*/
|
||||
describe('destination tokens', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8');
|
||||
|
||||
const TOKENS = [
|
||||
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
|
||||
'--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch',
|
||||
];
|
||||
|
||||
const DARK_AT = css.indexOf('@media (prefers-color-scheme: dark)');
|
||||
|
||||
it('defines every destination token in the light palette', () => {
|
||||
const light = css.slice(0, DARK_AT);
|
||||
expect(TOKENS.filter((t) => !light.includes(`${t}:`))).toEqual([]);
|
||||
});
|
||||
|
||||
it('redefines every destination token for dark', () => {
|
||||
const dark = css.slice(DARK_AT);
|
||||
expect(TOKENS.filter((t) => !dark.includes(`${t}:`))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* The hero search and the results filter bar are the same component in two
|
||||
* costumes. `.filterBar` is the card — background, border, shadow, padding —
|
||||
* and `.heroMode` strips all of it so the search sits directly on the hero
|
||||
* panel.
|
||||
*
|
||||
* Both selectors have specificity (0,1,0), so **source order decides**, and
|
||||
* `.heroMode` only wins because it is declared immediately after. Any later
|
||||
* bare `.filterBar` rule — which in practice means one inside a media query —
|
||||
* silently wins instead, and the hero grows a card's padding back.
|
||||
*
|
||||
* That is exactly what happened: `@media (max-width: 768px) { .filterBar {
|
||||
* padding: 0.875rem } }` re-added 14px in hero mode, indenting the search box,
|
||||
* the hint and the location link 14px past the headline above them and costing
|
||||
* the search field 28px of width on a 390px screen. The two rules directly
|
||||
* below it in the same block were correctly written as
|
||||
* `.filterBar:not(.heroMode)`; this one was missed, and nothing caught it
|
||||
* because the result is a plausible-looking layout rather than a broken one.
|
||||
*/
|
||||
|
||||
const CSS = path.join(__dirname, '..', '..', 'components', 'FilterBar.module.css');
|
||||
|
||||
/** Properties `.heroMode` resets. A later bare `.filterBar` rule setting any
|
||||
* of these puts the card back on the hero. */
|
||||
const RESET_BY_HERO_MODE = [
|
||||
'background', 'border', 'border-radius', 'box-shadow', 'padding',
|
||||
];
|
||||
|
||||
/**
|
||||
* Comments are stripped before anything is parsed.
|
||||
*
|
||||
* A `{` or `}` inside a comment would otherwise desynchronise the brace walk
|
||||
* below and the rule regex alike, and the selector text captured for each rule
|
||||
* would carry the preceding comment along with it.
|
||||
*/
|
||||
function withoutComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The individual selectors in a rule's prelude.
|
||||
*
|
||||
* Split on commas, because a selector list is a list: `.filterBar, .other { }`
|
||||
* applies to `.filterBar` just as surely as `.filterBar { }` does, and an
|
||||
* earlier version of this guard compared the whole prelude against the literal
|
||||
* string '.filterBar' — so writing the regression as a comma list, or across
|
||||
* two lines, would have walked straight past it.
|
||||
*/
|
||||
function selectorsOf(prelude: string): string[] {
|
||||
return prelude.split(',').map((sel) => sel.trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function mediaQueryBodies(css: string): string[] {
|
||||
const bodies: string[] = [];
|
||||
const re = /@media[^{]*\{/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css)) !== null) {
|
||||
// Walk braces from the opening one to find this at-rule's whole body.
|
||||
let depth = 1;
|
||||
let i = m.index + m[0].length;
|
||||
const start = i;
|
||||
while (i < css.length && depth > 0) {
|
||||
if (css[i] === '{') depth++;
|
||||
else if (css[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
bodies.push(css.slice(start, i - 1));
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
describe('FilterBar hero-mode scoping', () => {
|
||||
const css = withoutComments(fs.readFileSync(CSS, 'utf8'));
|
||||
|
||||
it('confirms heroMode still resets the card, which is what makes this matter', () => {
|
||||
const hero = css.match(/\.heroMode\s*\{([^}]*)\}/);
|
||||
expect(hero).not.toBeNull();
|
||||
expect(hero![1]).toMatch(/padding:\s*0/);
|
||||
});
|
||||
|
||||
it('never re-applies card styling to the hero from inside a media query', () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const body of mediaQueryBodies(css)) {
|
||||
for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
// Only a *bare* .filterBar is dangerous, and it is dangerous wherever
|
||||
// it appears in a selector list. Scoped variants
|
||||
// (`.filterBar:not(.heroMode)`) and descendants are fine.
|
||||
const selectors = selectorsOf(rule[1]);
|
||||
if (!selectors.includes('.filterBar')) continue;
|
||||
|
||||
for (const prop of RESET_BY_HERO_MODE) {
|
||||
if (new RegExp(`(^|[;\\s])${prop}\\s*:`).test(rule[2])) {
|
||||
offenders.push(`${rule[1].trim()} sets ${prop}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix by scoping the rule as `.filterBar:not(.heroMode)`, the way the
|
||||
// neighbouring rules in the same block already are.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,17 @@ describe('secondary detail page', () => {
|
||||
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes no claim about publication when the feature is switched off', () => {
|
||||
// Absent, not null. The API omits the key entirely while the
|
||||
// admission_distance flag is off, and "Islington has not published a
|
||||
// cut-off distance" is then a statement about us, not about Islington —
|
||||
// false wherever the authority does publish one.
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: undefined });
|
||||
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Contact the admissions authority/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
|
||||
|
||||
const realFetch = global.fetch;
|
||||
|
||||
function mockFetch(rows: unknown[], delayMs = 0) {
|
||||
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
if (delayMs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, delayMs);
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t);
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => ({ suggestions: rows }) };
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const ROW = {
|
||||
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
|
||||
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
|
||||
};
|
||||
|
||||
describe('useSchoolSuggest', () => {
|
||||
beforeEach(() => { jest.useFakeTimers(); });
|
||||
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
|
||||
|
||||
it('does not fetch below the minimum query length', () => {
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('b', true));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch at all when disabled', () => {
|
||||
// The flag being off must mean no request, not a hidden dropdown.
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('brecknock', false));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('debounces rather than firing per keystroke', () => {
|
||||
mockFetch([ROW]);
|
||||
const { rerender } = renderHook(
|
||||
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
|
||||
rerender({ q: 'bre' });
|
||||
rerender({ q: 'brec' });
|
||||
act(() => { jest.advanceTimersByTime(199); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
act(() => { jest.advanceTimersByTime(2); });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens with results once they arrive', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
|
||||
expect(result.current.open).toBe(true);
|
||||
});
|
||||
|
||||
it('close() hides the list without clearing the query', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.open).toBe(true));
|
||||
act(() => { result.current.close(); });
|
||||
expect(result.current.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getNavigationSource } from '@/lib/analytics';
|
||||
|
||||
/** jsdom's document.referrer is read-only; redefining it is the way in. */
|
||||
function referrer(url: string) {
|
||||
Object.defineProperty(document, 'referrer', { value: url, configurable: true });
|
||||
}
|
||||
|
||||
const ORIGIN = 'http://localhost';
|
||||
|
||||
describe('getNavigationSource', () => {
|
||||
afterEach(() => referrer(''));
|
||||
|
||||
it('attributes a visit from a location page to the place layer', () => {
|
||||
/*
|
||||
* The one this was added for.
|
||||
*
|
||||
* W2 published ~3,900 location pages whose entire purpose is to funnel
|
||||
* search traffic onto school pages. Before this case existed they fell
|
||||
* through to 'direct' — so the location layer's contribution was not
|
||||
* merely missing from the funnel, it was being counted in the bucket you
|
||||
* read as "typed the URL". The measurement that decides whether W2 worked
|
||||
* was confidently reporting the wrong answer.
|
||||
*/
|
||||
referrer(`${ORIGIN}/schools/barnet`);
|
||||
expect(getNavigationSource()).toBe('place');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/schools/authority/kent', 'authority'],
|
||||
['/schools/near/sw11', 'outcode'],
|
||||
['/schools/brentwood/primary', 'phase variant'],
|
||||
])('covers %s (%s)', (path) => {
|
||||
referrer(`${ORIGIN}${path}`);
|
||||
expect(getNavigationSource()).toBe('place');
|
||||
});
|
||||
|
||||
it('still calls a school page "detail", one character away', () => {
|
||||
// /school/ and /schools/ differ by one letter and mean different things.
|
||||
// A prefix test written in the wrong order silently merges them.
|
||||
referrer(`${ORIGIN}/school/100010-brecknock-primary-school`);
|
||||
expect(getNavigationSource()).toBe('detail');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/', 'search'],
|
||||
['/rankings', 'rankings'],
|
||||
['/compare?urns=1,2', 'compare'],
|
||||
])('leaves %s attributed as %s', (path, expected) => {
|
||||
referrer(`${ORIGIN}${path}`);
|
||||
expect(getNavigationSource()).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats an external referrer as direct', () => {
|
||||
// Umami records the real referrer on the pageview; this field is only
|
||||
// about internal navigation.
|
||||
referrer('https://www.google.com/search?q=schools+in+barnet');
|
||||
expect(getNavigationSource()).toBe('direct');
|
||||
});
|
||||
|
||||
it('treats no referrer as direct', () => {
|
||||
referrer('');
|
||||
expect(getNavigationSource()).toBe('direct');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* The defect the existing suite could not see.
|
||||
*
|
||||
* Every test above sets document.referrer, which the browser writes only when
|
||||
* a *document* loads. Every internal navigation in this app is an App Router
|
||||
* soft navigation — history.pushState, no new document — so document.referrer
|
||||
* keeps naming whatever opened the tab for the whole session. Verified on
|
||||
* staging: /schools/brentwood → click a school → URL changes to /school/…
|
||||
* and document.referrer is still "".
|
||||
*
|
||||
* So `from` reported 'direct' for essentially every in-app journey, and the
|
||||
* suite passed because it only ever exercised the full-page-load path.
|
||||
*/
|
||||
function freshAnalytics() {
|
||||
let mod!: typeof import('@/lib/analytics');
|
||||
jest.isolateModules(() => {
|
||||
mod = require('@/lib/analytics');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
function at(path: string) {
|
||||
window.history.pushState({}, '', path);
|
||||
}
|
||||
|
||||
describe('getNavigationSource across a soft navigation', () => {
|
||||
afterEach(() => {
|
||||
referrer('');
|
||||
at('/');
|
||||
});
|
||||
|
||||
it('attributes a school view to the place page the user actually came from', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/schools/brentwood');
|
||||
recordVisitedPath('/schools/brentwood');
|
||||
|
||||
at('/school/115429-brentwood-school');
|
||||
recordVisitedPath('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('place');
|
||||
});
|
||||
|
||||
it('does not depend on whether the new path was recorded first', () => {
|
||||
// The trail is written by a layout-level effect and read by a page-level
|
||||
// one. React orders those by tree position, which is not a contract worth
|
||||
// resting a measurement on, so the answer must be the same either way.
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
recordVisitedPath('/rankings');
|
||||
at('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('rankings');
|
||||
});
|
||||
|
||||
it('names the previous page, not the current one, when both are schools', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/school/100010-brecknock-primary-school');
|
||||
recordVisitedPath('/school/100010-brecknock-primary-school');
|
||||
|
||||
at('/school/115429-brentwood-school');
|
||||
recordVisitedPath('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('detail');
|
||||
});
|
||||
|
||||
it('looks past a return visit to the page the user came back from', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
for (const p of ['/schools/brentwood', '/school/115429-brentwood-school',
|
||||
'/schools/brentwood']) {
|
||||
at(p);
|
||||
recordVisitedPath(p);
|
||||
}
|
||||
expect(source()).toBe('detail');
|
||||
});
|
||||
|
||||
it('falls back to the referrer on a real document load, where it is true', () => {
|
||||
// A fresh module is a fresh document: nothing has been recorded, and
|
||||
// document.referrer is meaningful again.
|
||||
const { getNavigationSource: source } = freshAnalytics();
|
||||
at('/school/115429-brentwood-school');
|
||||
referrer(`${ORIGIN}/schools/barnet`);
|
||||
|
||||
expect(source()).toBe('place');
|
||||
});
|
||||
|
||||
it('still reads an arrival from outside as direct', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/schools/brentwood');
|
||||
recordVisitedPath('/schools/brentwood');
|
||||
referrer('https://www.google.com/search?q=schools+in+brentwood');
|
||||
|
||||
expect(source()).toBe('direct');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
canAggregate, aggregateCells,
|
||||
canRenderBar, toBarSegments, CARD_GROUPS,
|
||||
type DestinationCell, type DestinationGroup, type DestinationCategory,
|
||||
} from '@/lib/destinations';
|
||||
|
||||
const pub = (category: DestinationCategory, pupils: number, cohort: number): DestinationCell => ({
|
||||
category, pupils, percentage: (pupils / cohort) * 100, status: 'published',
|
||||
});
|
||||
const sup = (category: DestinationCategory): DestinationCell => ({
|
||||
category, pupils: null, percentage: null, status: 'suppressed',
|
||||
});
|
||||
|
||||
const fullGroup = (): DestinationGroup => ({
|
||||
cohort: 180,
|
||||
cells: [
|
||||
pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180),
|
||||
pub('further_education', 55, 180), pub('other_education', 6, 180),
|
||||
pub('apprenticeship', 8, 180), pub('employment', 6, 180),
|
||||
pub('not_sustained', 5, 180), pub('not_captured', 4, 180),
|
||||
],
|
||||
});
|
||||
|
||||
describe('canAggregate — R2, computing from components', () => {
|
||||
it('allows a sum when every component is published', () => {
|
||||
expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a sum when any component is suppressed', () => {
|
||||
expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a sum when every component is suppressed', () => {
|
||||
expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateCells', () => {
|
||||
it('sums published cells and derives a percentage from the cohort', () => {
|
||||
expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180))
|
||||
.toEqual({ pupils: 14, percentage: (14 / 180) * 100 });
|
||||
});
|
||||
|
||||
it('returns null rather than a partial sum when a component is suppressed', () => {
|
||||
expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRenderBar — R1', () => {
|
||||
it('allows a bar when the whole group is published', () => {
|
||||
expect(canRenderBar(fullGroup())).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a bar when a single category is suppressed', () => {
|
||||
const g = fullGroup();
|
||||
g.cells[1] = sup('sixth_form_college');
|
||||
expect(canRenderBar(g)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBarSegments', () => {
|
||||
it('derives widths from counts, not from rounded percentages', () => {
|
||||
const segs = toBarSegments(fullGroup());
|
||||
expect(segs).toHaveLength(8);
|
||||
expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10);
|
||||
expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it('throws rather than silently leaving a gap when the group is suppressed', () => {
|
||||
const g = fullGroup();
|
||||
g.cells[1] = sup('sixth_form_college');
|
||||
expect(() => toBarSegments(g)).toThrow(/suppressed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CARD_GROUPS', () => {
|
||||
it('partitions every destination category exactly once, plus the absence', () => {
|
||||
const grouped = Object.values(CARD_GROUPS).flat();
|
||||
expect(new Set(grouped).size).toBe(grouped.length);
|
||||
expect(grouped).toEqual(expect.arrayContaining([
|
||||
'school_sixth_form', 'sixth_form_college', 'further_education',
|
||||
'other_education', 'apprenticeship', 'employment',
|
||||
]));
|
||||
expect(grouped).not.toContain('not_sustained');
|
||||
expect(grouped).not.toContain('not_captured');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getFlags } from '@/lib/flags';
|
||||
|
||||
// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach
|
||||
// to — assign it and restore the original afterwards. This is the first test
|
||||
// here to mock fetch; later ones should follow this shape.
|
||||
const realFetch = global.fetch;
|
||||
|
||||
function mockFetch(impl: () => Promise<unknown>) {
|
||||
global.fetch = jest.fn(impl) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('getFlags', () => {
|
||||
afterEach(() => { global.fetch = realFetch; });
|
||||
|
||||
it('returns the flags the API reports', async () => {
|
||||
mockFetch(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ admission_distance: true }),
|
||||
}));
|
||||
await expect(getFlags()).resolves.toEqual({ admission_distance: true });
|
||||
});
|
||||
|
||||
it('returns no flags rather than throwing when the API is down', async () => {
|
||||
// A page that cannot read flags must render everything dark, not 500.
|
||||
// Fail-closed is the same direction as the backend's default.
|
||||
mockFetch(async () => { throw new Error('ECONNREFUSED'); });
|
||||
await expect(getFlags()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('returns no flags rather than throwing on a non-200', async () => {
|
||||
mockFetch(async () => ({ ok: false, status: 503 }));
|
||||
await expect(getFlags()).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections';
|
||||
import type { School, SchoolDestinations } from '@/lib/types';
|
||||
|
||||
const schoolInfo = {
|
||||
urn: 137083, school_name: 'Northbrook Academy', phase: 'Secondary',
|
||||
has_sixth_form: true,
|
||||
} as unknown as School;
|
||||
|
||||
const base = { schoolInfo, yearlyData: [], deprivation: null, finance: null };
|
||||
|
||||
const phase = (categories = 1) => ({
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 180,
|
||||
categories: Array.from({ length: categories }, () => ({
|
||||
category: 'school_sixth_form' as const,
|
||||
pupils: 75, percentage: 41.7, status: 'published' as const,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ks4Only: SchoolDestinations = { ks4: phase(), ks5: null };
|
||||
const both: SchoolDestinations = { ks4: phase(), ks5: phase() };
|
||||
|
||||
describe('computeSecondaryFlags — destinations', () => {
|
||||
it('flags KS4 destinations when the block carries categories', () => {
|
||||
const flags = computeSecondaryFlags({ ...base, destinations: ks4Only });
|
||||
expect(flags.hasKs4Destinations).toBe(true);
|
||||
expect(flags.hasKs5Destinations).toBe(false);
|
||||
});
|
||||
|
||||
it('flags both phases when both are present', () => {
|
||||
const flags = computeSecondaryFlags({ ...base, destinations: both });
|
||||
expect(flags.hasKs4Destinations).toBe(true);
|
||||
expect(flags.hasKs5Destinations).toBe(true);
|
||||
});
|
||||
|
||||
it('flags neither when the block is absent', () => {
|
||||
const flags = computeSecondaryFlags({ ...base, destinations: null });
|
||||
expect(flags.hasKs4Destinations).toBe(false);
|
||||
expect(flags.hasKs5Destinations).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag a phase whose groups carry no categories', () => {
|
||||
const empty: SchoolDestinations = {
|
||||
ks4: { cohort_year: '2022/23', groups: {} }, ks5: null,
|
||||
};
|
||||
expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag a phase whose only group has an empty category list', () => {
|
||||
const empty: SchoolDestinations = { ks4: phase(0), ks5: null };
|
||||
expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSecondaryNavItems — destinations', () => {
|
||||
const navInput = {
|
||||
ofsted: null, admissions: null, admissionDistance: null,
|
||||
hasLocation: false, yearlyDataLength: 0,
|
||||
};
|
||||
|
||||
it('adds both entries, after GCSEs', () => {
|
||||
const flags = computeSecondaryFlags({ ...base, destinations: both });
|
||||
const ids = buildSecondaryNavItems({ ...flags, hasResults: true }, navInput)
|
||||
.map(i => i.id);
|
||||
expect(ids).toContain('destinations');
|
||||
expect(ids).toContain('post16-destinations');
|
||||
expect(ids.indexOf('destinations')).toBeGreaterThan(ids.indexOf('gcse'));
|
||||
expect(ids.indexOf('post16-destinations')).toBe(ids.indexOf('destinations') + 1);
|
||||
});
|
||||
|
||||
it('adds no entry for a phase that will not render — the nav must not link to a missing anchor', () => {
|
||||
const flags = computeSecondaryFlags({ ...base, destinations: null });
|
||||
const ids = buildSecondaryNavItems(flags, navInput).map(i => i.id);
|
||||
expect(ids).not.toContain('destinations');
|
||||
expect(ids).not.toContain('post16-destinations');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
metricKind,
|
||||
shortName,
|
||||
computeYBounds,
|
||||
formatAgeRange,
|
||||
formatAgeSpan,
|
||||
} from '@/lib/utils';
|
||||
|
||||
describe('formatPercentage', () => {
|
||||
@@ -320,3 +322,27 @@ describe('shortName', () => {
|
||||
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAgeSpan', () => {
|
||||
it('normalises a hyphenated range to an en dash, without a label', () => {
|
||||
// The place table carries "Ages" in the column heading, so repeating it
|
||||
// in every cell is noise. formatAgeRange keeps the label for the contexts
|
||||
// that have no heading to hang it on.
|
||||
expect(formatAgeSpan('4-11')).toBe('4–11');
|
||||
});
|
||||
|
||||
it('leaves a range it does not recognise alone rather than mangling it', () => {
|
||||
expect(formatAgeSpan('3-19 (SEN)')).toBe('3-19 (SEN)');
|
||||
});
|
||||
|
||||
it('returns an empty string for a missing range', () => {
|
||||
expect(formatAgeSpan(null)).toBe('');
|
||||
expect(formatAgeSpan(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAgeRange', () => {
|
||||
it('keeps its label, so the two helpers stay distinguishable', () => {
|
||||
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import { AdmissionsView } from '@/components/AdmissionsView';
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Admissions Guide',
|
||||
// Deadlines and offer days are what gets searched, and what this page is
|
||||
// genuinely best at — the countdowns are live.
|
||||
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
||||
description:
|
||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
||||
alternates: { canonical: absoluteUrl('/admissions') },
|
||||
};
|
||||
|
||||
|
||||
@@ -26,8 +26,27 @@ function backendBase(): string {
|
||||
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
||||
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/*
|
||||
* API paths this public proxy must not forward.
|
||||
*
|
||||
* Matched on the first segment, exactly — a prefix match would take
|
||||
* /api/flagship down with /api/flags.
|
||||
*
|
||||
* `flags` is here because GET /api/flags names every unreleased feature the
|
||||
* codebase knows about, along with whether it is on. Publishing that defeats
|
||||
* the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
|
||||
* the Docker network, which never transits this route.
|
||||
*
|
||||
* Anything else internal-only belongs here too.
|
||||
*/
|
||||
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);
|
||||
|
||||
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
|
||||
return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
|
||||
@@ -30,9 +30,12 @@ export async function generateMetadata(
|
||||
const { urns } = await searchParams;
|
||||
|
||||
const base: Metadata = {
|
||||
title: 'Compare Schools',
|
||||
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||
// schools" is how a site competes with itself; this one takes the tool
|
||||
// phrasing instead.
|
||||
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
||||
description:
|
||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
alternates: { canonical: absoluteUrl('/compare') },
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
--bg-primary: #FAFAF8; /* Warm White */
|
||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||
--bg-card: #FFFFFF;
|
||||
/* For gradients that have to fade to the card colour. A hardcoded white
|
||||
ramp reads as a bright band against a dark card. */
|
||||
--bg-card-rgb: 255, 255, 255;
|
||||
--surface-inverse: #0F766E;
|
||||
|
||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||
@@ -102,6 +105,23 @@
|
||||
--series-7: #0E7A86;
|
||||
--series-8: #8A4A6B;
|
||||
|
||||
/* ── Destination measures ───────────────────────────────────────────
|
||||
Education is one hue in three steps (school-like -> college-like) so the
|
||||
education destinations read as one family; apprenticeship and employment
|
||||
are separate hues. The absence is neutral and HATCHED, never a colour:
|
||||
"activity not captured" covers independent schools, moving abroad and
|
||||
training DfE holds no data on, so rendering it as a bad outcome would be
|
||||
a factual error. The hatch is also the secondary encoding that rescues
|
||||
the neutral/blue pair, which separates at only dE 7.6 as flat fills.
|
||||
Every other adjacent pair clears dE 10.9 under protanopia. */
|
||||
--dest-sixthform: #0F766E;
|
||||
--dest-sfcollege: #4A9E96;
|
||||
--dest-fecollege: #7CBFB8;
|
||||
--dest-apprentice: #806200;
|
||||
--dest-employment: #2F6F8F;
|
||||
--dest-none: #6B7580;
|
||||
--dest-none-hatch: rgba(107, 117, 128, 0.34);
|
||||
|
||||
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
||||
--phase-primary: #0F766E;
|
||||
--phase-primary-bg: rgba(167, 215, 197, 0.40);
|
||||
@@ -234,6 +254,7 @@
|
||||
--bg-primary: #111A20;
|
||||
--bg-secondary: #16222A;
|
||||
--bg-card: #18242C;
|
||||
--bg-card-rgb: 24, 36, 44;
|
||||
--surface-inverse: #E9EEF0;
|
||||
|
||||
--text-primary: #E9EEF0;
|
||||
@@ -289,6 +310,17 @@
|
||||
--series-7: #6FD0DC;
|
||||
--series-8: #D99BB8;
|
||||
|
||||
/* Destinations. Not a naive inversion: the education ramp reverses
|
||||
direction so its darkest step stays the one furthest from the
|
||||
school, and each step is re-checked against the dark card. */
|
||||
--dest-sixthform: #5FC7BB;
|
||||
--dest-sfcollege: #3E9B92;
|
||||
--dest-fecollege: #2A716B;
|
||||
--dest-apprentice: #EFC658;
|
||||
--dest-employment: #8FB4D9;
|
||||
--dest-none: #8B9AA1;
|
||||
--dest-none-hatch: rgba(139, 154, 161, 0.34);
|
||||
|
||||
--phase-primary: #5FC7BB;
|
||||
--phase-primary-bg: rgba(95, 199, 187, 0.16);
|
||||
--phase-primary-text: #8ADACF;
|
||||
@@ -584,6 +616,35 @@ html .leaflet-bar a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* The popup, which leaflet.css paints `background: white; color: #333` on both
|
||||
* the card and its tip. The content LeafletMapInner binds into it is themed —
|
||||
* the school name and the headline figure are `var(--text-primary)` — so in
|
||||
* dark mode that was #E9EEF0 on #FFFFFF, a contrast ratio of 1.17:1. The name
|
||||
* and the number were the two least readable things on the page.
|
||||
*
|
||||
* Moving the surface onto --bg-card fixes every foreground at once rather than
|
||||
* one at a time: the muted phase line goes 2.90:1 -> 5.45:1, the vs-national
|
||||
* delta 1.94:1 -> 8.14:1, the Ofsted badge 1.74:1 -> 9.11:1. In light mode
|
||||
* --bg-card is #FFFFFF, so the popup looks as it always did.
|
||||
*/
|
||||
html .leaflet-popup-content-wrapper,
|
||||
html .leaflet-popup-tip {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Leaflet's own selector is `.leaflet-container a.leaflet-popup-close-button`
|
||||
at 0,2,1 — an `html` prefix alone would lose to it. */
|
||||
html .leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .leaflet-container a.leaflet-popup-close-button:hover,
|
||||
html .leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Main content column */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { RouteTrail } from '@/components/RouteTrail';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
import './globals.css';
|
||||
@@ -48,10 +49,11 @@ export const metadata: Metadata = {
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'schoolcompare | Compare School Performance',
|
||||
default: 'Compare Schools Side by Side | schoolcompare',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
@@ -61,16 +63,18 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
url: SITE_URL,
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,6 +115,10 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{/* Records every route so funnel attribution has a previous page to
|
||||
name. document.referrer cannot: a soft navigation creates no
|
||||
document, so the browser never updates it. */}
|
||||
<RouteTrail />
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
+23
-2
@@ -8,6 +8,7 @@ import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
|
||||
@@ -34,8 +35,21 @@ interface HomePageProps {
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||
description: 'Search and compare school performance across England',
|
||||
/*
|
||||
* Intent in the title, differentiator in the description.
|
||||
*
|
||||
* These queries are owned by the DfE's own "Compare school performance"
|
||||
* service, and the old title — brand first, then a near-paraphrase of that
|
||||
* service's name — gave a searcher no reason to pick us over it. It drew
|
||||
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
||||
* neighbourhood, so the ranking was never the problem.
|
||||
*
|
||||
* The title now matches what people type. The description carries the one
|
||||
* fact gov.uk does not publish: how close you had to live to get a place.
|
||||
*/
|
||||
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
// This page reads eleven search params. They filter a result set; they do
|
||||
// not make a new document. Collapsing every combination onto "/" stops the
|
||||
// homepage competing with itself for its own head terms.
|
||||
@@ -50,6 +64,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
// Await search params (Next.js 15 requirement)
|
||||
const params = await searchParams;
|
||||
|
||||
// Server-read: no flag value reaches the browser bundle. Threaded down to
|
||||
// both FilterBar instances via HomeView.
|
||||
const flags = await getFlags();
|
||||
const autosuggest = flags.school_autosuggest === true;
|
||||
|
||||
// Parse search params
|
||||
const page = parseInt(params.page || '1');
|
||||
const radius = params.radius ? parseFloat(params.radius) : undefined;
|
||||
@@ -98,6 +117,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
autosuggest={autosuggest}
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
@@ -118,6 +138,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
return (
|
||||
<HomeView
|
||||
autosuggest={autosuggest}
|
||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
|
||||
@@ -18,8 +18,11 @@ interface RankingsPageProps {
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Rankings',
|
||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||
// phrase parents actually search, and it spikes each results day.
|
||||
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
||||
description:
|
||||
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||
// now. W3 replaces them with real indexable paths.
|
||||
|
||||
@@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -171,6 +171,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
destinations: destinations ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
@@ -232,10 +233,11 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistance={admission_distance}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
destinations={destinations ?? null}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Phase variants of a place page.
|
||||
*
|
||||
* Phase is part of the query — "primary schools in beccles", "secondary
|
||||
* schools in brentwood" — not a filter applied afterwards, so each gets its
|
||||
* own indexable path. A place with no schools of the phase has no page: the
|
||||
* per-phase threshold, not an error.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string; phase: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
const PHASES = ['primary', 'secondary'] as const;
|
||||
type Phase = (typeof PHASES)[number];
|
||||
|
||||
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
||||
|
||||
async function resolve(slug: string, phase: Phase) {
|
||||
return (await fetchPlace('town', slug, phase))
|
||||
?? (await fetchPlace('locality', slug, phase));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||
|
||||
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||
const { name } = detail.place;
|
||||
return {
|
||||
// Not "Ranked": the table is alphabetical, so the word would be a claim
|
||||
// the page does not keep.
|
||||
title: { absolute: `${word} Schools in ${name} | schoolcompare` },
|
||||
description:
|
||||
`Every ${phase} school in ${name}, with results, Ofsted grades and the local `
|
||||
+ `average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePhasePage({ params }: Props) {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) notFound();
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
const englandAverage = phase === 'secondary'
|
||||
? national?.secondary?.attainment_8_score ?? null
|
||||
: national?.primary?.rwm_expected_pct ?? null;
|
||||
|
||||
return <PlaceView detail={detail} phase={phase}
|
||||
englandAverage={englandAverage} neighbours={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Town and locality pages.
|
||||
*
|
||||
* A place below the five-school threshold is not in the registry, so
|
||||
* fetchPlace returns null and the request 404s rather than rendering a page
|
||||
* with nothing to say.
|
||||
*/
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string }> }
|
||||
|
||||
// ISR: place aggregates change only when the pipeline runs.
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
||||
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||
.map((p) => ({ place: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(slug: string) {
|
||||
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
||||
}
|
||||
|
||||
/** Other towns in the same authority — the cheapest honest definition of
|
||||
* "nearby", and enough to stop each place page being a dead end. */
|
||||
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
||||
if (!detail.place.parent_authority) return [];
|
||||
const all = await fetchPlaces();
|
||||
return all
|
||||
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
// absolute: the root layout's template appends '| schoolcompare' to a
|
||||
// plain string, and this title already carries it. Without this every
|
||||
// place title read '... | schoolcompare | schoolcompare'.
|
||||
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
|
||||
description:
|
||||
`Every school in ${name}, with SATs and GCSE results, Ofsted grades, the local `
|
||||
+ `average against England, and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePage({ params }: Props) {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) notFound();
|
||||
|
||||
// Global constraint: no page without a local average. A place with too few
|
||||
// schools carrying results has nothing to say that a list does not, so it
|
||||
// defers to its authority rather than publishing a thin page.
|
||||
if (detail.averages.rwm_expected_pct == null
|
||||
&& detail.averages.attainment_8_score == null) {
|
||||
// The API's own slug, which is null when that authority is itself under
|
||||
// the threshold and has no page. Re-slugifying the name here would send
|
||||
// the reader to a 404 instead of telling them the place has no page.
|
||||
const target = detail.place.authorities?.[0]?.slug
|
||||
?? (detail.place.parent_authority
|
||||
? authoritySlug(detail.place.parent_authority)
|
||||
: null);
|
||||
if (target) redirect(`/schools/authority/${target}`);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
||||
// — not flat. Reading it flat silently yields undefined and the page renders
|
||||
// with no comparison, which is the one thing that makes it not a list.
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={await neighboursOf(detail)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Phase variants of an authority page.
|
||||
*
|
||||
* The spec called for these; the plan built the bare authority route and
|
||||
* dropped them. Nothing caught it, because the sitemap is written from the
|
||||
* place registry — which was right about them all along — while the routes
|
||||
* were written by hand. 302 authority phase URLs were submitted to Google and
|
||||
* every one 404'd, and every authority page linked to a phase page in the
|
||||
* *town* namespace, which is a different set of schools entirely.
|
||||
*
|
||||
* "Primary schools in Kent" is the query these serve, and it is a real one:
|
||||
* admissions are authority-run, so the authority is the unit a parent thinks
|
||||
* in when they have not settled on a town.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ la: string; phase: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
const PHASES = ['primary', 'secondary'] as const;
|
||||
type Phase = (typeof PHASES)[number];
|
||||
|
||||
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la, phase } = await params;
|
||||
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||
const detail = await fetchPlace('authority', la, phase);
|
||||
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||
|
||||
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||
const { name } = detail.place;
|
||||
return {
|
||||
// "Local Authority" stays in the title for the same reason it is on the
|
||||
// bare authority page: 67 town names collide with an authority name, and
|
||||
// a reader landing on both needs to know which set each covers.
|
||||
title: { absolute: `${word} Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`Every ${phase} school in the ${name} local authority, with results, Ofsted `
|
||||
+ `grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}/${phase}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPhasePage({ params }: Props) {
|
||||
const { la, phase } = await params;
|
||||
if (!isPhase(phase)) notFound();
|
||||
const detail = await fetchPlace('authority', la, phase);
|
||||
if (!detail || detail.schools.length === 0) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
const englandAverage = phase === 'secondary'
|
||||
? national?.secondary?.attainment_8_score ?? null
|
||||
: national?.primary?.rwm_expected_pct ?? null;
|
||||
|
||||
return <PlaceView detail={detail} phase={phase}
|
||||
englandAverage={englandAverage} neighbours={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Local authority pages.
|
||||
*
|
||||
* A separate namespace from /schools/[place] because 67 town names collide
|
||||
* with an authority name and neither set contains the other — Bedford the
|
||||
* town holds 104 schools, Bedford the authority 86, because postal towns
|
||||
* cross authority boundaries. The title says "Local Authority" so a reader
|
||||
* landing on both knows which set each covers.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ la: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||
// Gated like every other prerender in this app. There are only ~154
|
||||
// authorities, but "few enough to always build" still means the API must be
|
||||
// reachable at build time, and in CI it is not — the build fails with
|
||||
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||
// school route uses.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'authority')
|
||||
.map((p) => ({ la: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`All ${count} schools in the ${name} local authority, with SATs and GCSE results, `
|
||||
+ `Ofsted grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPage({ params }: Props) {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Postcode district pages.
|
||||
*
|
||||
* No phase variants: nobody searches "primary schools in SW11", so the
|
||||
* variants would be pages without demand. These exist to catch
|
||||
* "schools near <postcode>" and to give London districts a geographic page
|
||||
* where the GIAS town field cannot.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ outcode: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||
// 1,760 of these; same CI budget argument as the town routes.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'outcode')
|
||||
.map((p) => ({ outcode: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools near ${name} | schoolcompare` },
|
||||
description:
|
||||
`${count} schools in the ${name} postcode district, with results, Ofsted grades `
|
||||
+ `and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OutcodePage({ params }: Props) {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const runtime = 'nodejs';
|
||||
* validated here rather than passed through, so this route cannot be used to
|
||||
* reach arbitrary backend paths.
|
||||
*/
|
||||
const CHILD = /^(static|schools-\d+)\.xml$/;
|
||||
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* The suggestion dropdown is absolutely positioned against this box. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||
@@ -411,7 +413,17 @@
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
/*
|
||||
* Scoped, like the two rules below it.
|
||||
*
|
||||
* The results filter bar is a card — background, border, shadow — and needs
|
||||
* inner padding. The hero's search is not a card: .heroMode zeroes the
|
||||
* padding, border and background so the search sits directly on the panel.
|
||||
* Unscoped, this rule put 14px back, which indented the search box, the hint
|
||||
* and the location link 14px past the headline they sit under, and cost the
|
||||
* search field 28px of width on a 390px screen.
|
||||
*/
|
||||
.filterBar:not(.heroMode) {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -455,6 +467,14 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Optical alignment: the button's own 6px of padding is what makes its
|
||||
label start further right than the hint above it, even once both boxes
|
||||
share a left edge. Pulling the padding back off lines the text up while
|
||||
keeping the tap target. */
|
||||
.heroMode .nearMeBtn {
|
||||
margin-left: -0.375rem;
|
||||
}
|
||||
|
||||
.geoError {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { isValidPostcode, schoolUrl } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
|
||||
import { SuggestList, suggestOptionId } from "./SuggestList";
|
||||
import type { Suggestion } from "@/lib/suggest";
|
||||
import type { Filters, ResultFilters } from "@/lib/types";
|
||||
import styles from "./FilterBar.module.css";
|
||||
|
||||
@@ -17,6 +20,8 @@ interface FilterBarProps {
|
||||
onNearMe?: () => void;
|
||||
geoState?: "idle" | "requesting" | "error";
|
||||
geoError?: string | null;
|
||||
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
|
||||
autosuggest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +53,7 @@ export function FilterBar({
|
||||
onNearMe,
|
||||
geoState = "idle",
|
||||
geoError,
|
||||
autosuggest = false,
|
||||
}: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -62,6 +68,59 @@ export function FilterBar({
|
||||
|
||||
const [omniValue, setOmniValue] = useState(initialOmniValue);
|
||||
|
||||
const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`;
|
||||
|
||||
/*
|
||||
* Suggestions answer typing, not the mere presence of a value.
|
||||
*
|
||||
* Without this the results-page bar reopened the dropdown over the results:
|
||||
* after a search the input still holds the term, so on every render the
|
||||
* query was >= 2 characters and the list opened again — on top of the very
|
||||
* results the search had just produced, swallowing the click on the first
|
||||
* one. The E2E gate caught it as "<li role=option> intercepts pointer
|
||||
* events", but a reader would just have found the page unclickable.
|
||||
*/
|
||||
const [hasTyped, setHasTyped] = useState(false);
|
||||
|
||||
// Suppressed once the value parses as a postcode: the box takes a school
|
||||
// name OR a postcode, and suggesting schools during postcode entry fights
|
||||
// the user rather than helping them.
|
||||
const suggestEnabled = autosuggest && hasTyped && !isValidPostcode(omniValue);
|
||||
const { suggestions, open, activeIndex, setActiveIndex, close } =
|
||||
useSchoolSuggest(omniValue, suggestEnabled);
|
||||
|
||||
const pickSuggestion = (s: Suggestion) => {
|
||||
setHasTyped(false);
|
||||
close();
|
||||
track('search_submitted', {
|
||||
query: s.school_name.toLowerCase(),
|
||||
via: 'suggestion',
|
||||
urn: s.urn,
|
||||
has_postcode: false,
|
||||
filters_active: '',
|
||||
filters_count: 0,
|
||||
});
|
||||
router.push(schoolUrl(s.urn, s.school_name));
|
||||
};
|
||||
|
||||
const handleOmniKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(activeIndex + 1 >= suggestions.length ? 0 : activeIndex + 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1);
|
||||
} else if (e.key === "Escape") {
|
||||
close();
|
||||
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||
// Only when an option is active. With none, the event falls through to
|
||||
// the form's submit handler and searches the typed text, as it does now.
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestions[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const currentLA = searchParams.get("local_authority") || "";
|
||||
const currentType = searchParams.get("school_type") || "";
|
||||
const currentPhase = searchParams.get("phase") || "";
|
||||
@@ -124,6 +183,9 @@ export function FilterBar({
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// The search has been made; the suggestions that led to it are spent.
|
||||
setHasTyped(false);
|
||||
close();
|
||||
if (!omniValue.trim()) {
|
||||
updateURL({ search: "", postcode: "", radius: "" });
|
||||
return;
|
||||
@@ -226,9 +288,20 @@ export function FilterBar({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={omniValue}
|
||||
onChange={(e) => setOmniValue(e.target.value)}
|
||||
onChange={(e) => { setOmniValue(e.target.value); setHasTyped(true); }}
|
||||
onKeyDown={handleOmniKeyDown}
|
||||
onBlur={close}
|
||||
placeholder="School name or postcode"
|
||||
className={styles.omniInput}
|
||||
{...(autosuggest ? {
|
||||
role: "combobox",
|
||||
"aria-expanded": open,
|
||||
"aria-controls": suggestId,
|
||||
"aria-autocomplete": "list" as const,
|
||||
"aria-activedescendant":
|
||||
activeIndex >= 0 ? suggestOptionId(suggestId, activeIndex) : undefined,
|
||||
autoComplete: "off",
|
||||
} : {})}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -237,6 +310,15 @@ export function FilterBar({
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
{autosuggest && open && (
|
||||
<SuggestList
|
||||
id={suggestId}
|
||||
suggestions={suggestions}
|
||||
activeIndex={activeIndex}
|
||||
onPick={pickSuggestion}
|
||||
onHover={setActiveIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isHero && (
|
||||
<>
|
||||
|
||||
@@ -90,7 +90,18 @@
|
||||
isolation: isolate;
|
||||
background: var(--hero-ground);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
/*
|
||||
* Deliberately NOT overflow: hidden.
|
||||
*
|
||||
* It used to be, to clip the artwork and the scrim to the rounded corners —
|
||||
* and it also clipped the search box's suggestion dropdown, which is 320px
|
||||
* tall against 145px of panel below the input. Roughly half the list was cut
|
||||
* off with no indication anything was missing.
|
||||
*
|
||||
* The two things that actually needed clipping round themselves instead, so
|
||||
* the panel can let a dropdown out. Anything absolutely positioned inside
|
||||
* this panel and taller than the space below it depends on this.
|
||||
*/
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
@@ -107,6 +118,10 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
/* Rounds itself, because the panel no longer clips it. inset: 0 makes this
|
||||
exactly the panel's own corners. */
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroArt picture,
|
||||
@@ -143,6 +158,9 @@
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
/* Same reason as .heroArt: the panel stopped clipping, so the scrim keeps
|
||||
its own corners rather than squaring off over the panel's. */
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--hero-ground) 0%,
|
||||
@@ -331,6 +349,10 @@
|
||||
position: static;
|
||||
order: -1;
|
||||
height: 13rem;
|
||||
/* Top corners only. Here the artwork is a band flush with the top of the
|
||||
panel, not a layer covering it — inheriting all four would leave it
|
||||
floating with rounded bottom corners against the copy below. */
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
}
|
||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
||||
|
||||
@@ -29,6 +29,8 @@ interface HomeViewProps {
|
||||
// show (e.g. an active search).
|
||||
howItWorks?: React.ReactNode;
|
||||
editorial?: React.ReactNode;
|
||||
/** Server-read feature flag, threaded to both FilterBar instances. */
|
||||
autosuggest?: boolean;
|
||||
}
|
||||
|
||||
function daysUntil(month: number, day: number): number {
|
||||
@@ -193,7 +195,7 @@ const VALUE_PROPS: ValueProp[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -462,6 +464,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
autosuggest={autosuggest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -500,6 +503,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
autosuggest={autosuggest}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:var(--text-inverse);border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Writes the in-app navigation trail that funnel attribution reads.
|
||||
*
|
||||
* Renders nothing. It exists because document.referrer cannot answer "which
|
||||
* page did they come from" in an App Router app: a soft navigation creates no
|
||||
* document, so the browser never updates it. See the trail comment in
|
||||
* lib/analytics.ts.
|
||||
*
|
||||
* Mounted once in the root layout, so every route is recorded — including the
|
||||
* ones that fire no event of their own, which are still somebody else's
|
||||
* previous page.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { recordVisitedPath } from '@/lib/analytics';
|
||||
|
||||
export function RouteTrail() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
recordVisitedPath(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -47,7 +47,13 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||
/* Sweeps toward the card colour, which is a shade lighter than this
|
||||
ground in both themes. Hardcoded white was a bright flash across a
|
||||
dark page every 1.4s while the tiles loaded. */
|
||||
linear-gradient(100deg,
|
||||
rgba(var(--bg-card-rgb), 0) 40%,
|
||||
rgba(var(--bg-card-rgb), .5) 50%,
|
||||
rgba(var(--bg-card-rgb), 0) 60%) var(--bg-secondary);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -76,6 +82,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Controls that float ON the map.
|
||||
*
|
||||
* The map tiles are light in both themes, so these deliberately do NOT follow
|
||||
* the theme — they follow the map. The literal ink below is the point: paired
|
||||
* with a hardcoded white background, `color: var(--text-primary)` resolved to
|
||||
* #E9EEF0 in the dark theme and put near-white text on a near-white button.
|
||||
* A themed token is the wrong tool for a surface that never changes.
|
||||
*/
|
||||
.openHint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -85,7 +100,8 @@
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
/* See "Controls that float ON the map" above. */
|
||||
color: #1C2731;
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -113,11 +129,18 @@
|
||||
on top of the blend. */
|
||||
z-index: 450;
|
||||
pointer-events: none;
|
||||
/* The card colour, not white.
|
||||
This ramp was hardcoded white and ended at var(--bg-card). In the light
|
||||
theme that is white into white and invisible, as intended. In the dark
|
||||
theme it climbed to 95% WHITE and then met a near-black card — a bright
|
||||
band across the full width, right where the map is supposed to dissolve
|
||||
into the header. Fading to the same colour the gradient lands on is the
|
||||
whole trick, and it only works if that colour is a token. */
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
rgba(var(--bg-card-rgb), 0) 0%,
|
||||
rgba(var(--bg-card-rgb), .35) 35%,
|
||||
rgba(var(--bg-card-rgb), .75) 62%,
|
||||
rgba(var(--bg-card-rgb), .95) 82%,
|
||||
var(--bg-card) 100%);
|
||||
}
|
||||
|
||||
@@ -134,7 +157,8 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--text-primary);
|
||||
/* See "Controls that float ON the map" above. */
|
||||
color: #1C2731;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Anchored to .omniBoxContainer, which is position: relative for this reason.
|
||||
*
|
||||
* Every colour is a token, so the dropdown follows the theme. The dark theme
|
||||
* redefines --bg-card, --border, --text-muted and --shadow-soft, and this
|
||||
* inherits all four without a second rule.
|
||||
*/
|
||||
.list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Above the sticky filter bar (10) and the hero layers (0–2), below the
|
||||
skip-link (10000) and the modal overlay (1000). */
|
||||
z-index: 40;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Hover and keyboard share one style: the active option is the active option
|
||||
however it became active. Two rules would drift. */
|
||||
.option:hover,
|
||||
.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The autosuggest dropdown. Presentational only — it fetches nothing and owns
|
||||
* no state, so the fetching rules and the ARIA rules can be read separately.
|
||||
*/
|
||||
|
||||
import type { Suggestion } from '@/lib/suggest';
|
||||
import styles from './SuggestList.module.css';
|
||||
|
||||
/** The id the input's aria-activedescendant points at. */
|
||||
export function suggestOptionId(id: string, index: number): string {
|
||||
return `${id}-option-${index}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Shared with the input's aria-controls. */
|
||||
id: string;
|
||||
suggestions: Suggestion[];
|
||||
activeIndex: number;
|
||||
onPick: (s: Suggestion) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
export function SuggestList({ id, suggestions, activeIndex, onPick, onHover }: Props) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className={styles.list} id={id} role="listbox">
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={s.urn}
|
||||
id={suggestOptionId(id, i)}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`${styles.option} ${i === activeIndex ? styles.active : ''}`}
|
||||
/*
|
||||
* onMouseDown, not onClick: the input's blur handler closes the list,
|
||||
* and blur fires before click — so a click handler never runs. This
|
||||
* is the classic autosuggest bug where the dropdown is unclickable
|
||||
* with a mouse while working perfectly with a keyboard.
|
||||
*/
|
||||
onMouseDown={(e) => { e.preventDefault(); onPick(s); }}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span className={styles.name}>{s.school_name}</span>
|
||||
{/* Not decoration: there are many "St Mary's". */}
|
||||
<span className={styles.meta}>{s.local_authority}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* Tokens only — see globals.css. Follows RankingsView's conventions, and in
|
||||
particular its link treatment: table links take --text-primary with no
|
||||
underline and a brand-coloured hover, not the browser default. The first
|
||||
cut used bare <Link> with no class at all, which rendered as default blue
|
||||
underlined links and read as unstyled beside the rest of the site. */
|
||||
.container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Links in running copy: brand colour, underline on hover only. */
|
||||
.inlineLink {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.inlineLink:hover {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Phase variants are separate indexable pages, so the bare place page has to
|
||||
link them — a sitemap entry alone leaves them with no internal path in. */
|
||||
.phaseLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.phaseLink {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.phaseLink:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* The one number a list cannot give you, so it gets its own band. */
|
||||
.compare {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.875rem 1.125rem;
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ofsted {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.groupHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Wide content scrolls in its own container so the page body never does. */
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Header and value share one class and one rule, so they cannot drift apart.
|
||||
*
|
||||
* The first cut aligned them with two different selectors: `.table th:last-child`
|
||||
* at (0,2,1) beat the element rule and went right, while `.num` at (0,1,0) lost
|
||||
* to `.table td` at (0,1,1) and stayed left. The heading and its numbers sat on
|
||||
* opposite edges of the column.
|
||||
*
|
||||
* width:1% with nowrap makes the measure column hug its content so the school
|
||||
* name takes the remaining width — without it the two columns split evenly and
|
||||
* the gap between heading and value reads as misalignment on a wide screen.
|
||||
*/
|
||||
.table th.num,
|
||||
.table td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Attribute columns. Muted, because they qualify the row rather than compete
|
||||
* with the measure for it, and hugging their content so the school name keeps
|
||||
* the spare width — the same width:1% trick as .num, which is what stops six
|
||||
* columns from splitting evenly and squeezing the names into two lines each.
|
||||
*
|
||||
* .attr never wraps: "4–11" and "Yes" broken across lines read as two values.
|
||||
* .attrWide may — "Church of England" and some constituency names are long
|
||||
* enough that forcing one line would push the measure off a phone screen.
|
||||
*/
|
||||
.table th.attr,
|
||||
.table td.attr,
|
||||
.table th.attrWide,
|
||||
.table td.attrWide {
|
||||
color: var(--text-secondary);
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.table th.attr,
|
||||
.table td.attr {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table th.attrWide,
|
||||
.table td.attrWide {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
/* The measure is spelled out; the tooltip carries the definition. */
|
||||
.metricHead {
|
||||
text-decoration: none;
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted var(--border-strong);
|
||||
}
|
||||
|
||||
/* Table links: site convention is body colour, brand on hover. */
|
||||
.schoolLink {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolLink:hover {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* "Not published" is a fact about the school, not an error. */
|
||||
.noData {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.neighbours {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.neighbours h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.neighbours ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* One place page, shared by all four families.
|
||||
*
|
||||
* They differ in what fills the registry, not in what the page shows, so a
|
||||
* second component would be a second place to forget the same change.
|
||||
*
|
||||
* The local-versus-England comparison is the reason this page is not a list:
|
||||
* it is the one number a parent cannot get by reading the schools one by one,
|
||||
* and it is what keeps the page from reading as a name dropped into a
|
||||
* template.
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
|
||||
import { placeUrl, authoritySlug } from '@/lib/places';
|
||||
import type { School } from '@/lib/types';
|
||||
import { schoolUrl, formatAgeSpan } from '@/lib/utils';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { TrackPlaceView } from './TrackPlaceView';
|
||||
import styles from './PlaceView.module.css';
|
||||
|
||||
interface Props {
|
||||
detail: PlaceDetail;
|
||||
phase?: 'primary' | 'secondary';
|
||||
englandAverage: number | null;
|
||||
/** Nearby places, so the page links onward instead of dead-ending. */
|
||||
neighbours: PlaceSummary[];
|
||||
}
|
||||
|
||||
// Ofsted grades in the order they are reported.
|
||||
const OFSTED_LABELS: Array<[number, string]> = [
|
||||
[1, 'Outstanding'], [2, 'Good'],
|
||||
[3, 'Requires improvement'], [4, 'Inadequate'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Column headings, taken from the site's own metric dictionary rather than
|
||||
* invented here — see METRIC_DEFINITIONS in backend/schemas.py, surfaced at
|
||||
* /api/metrics. The first cut said "RWM expected", which is jargon that
|
||||
* appears nowhere else on the site.
|
||||
*/
|
||||
const METRICS = {
|
||||
primary: {
|
||||
key: 'rwm_expected_pct' as const,
|
||||
heading: 'Reading, writing & maths',
|
||||
hint: '% meeting the expected standard in reading, writing and maths',
|
||||
unit: '%',
|
||||
},
|
||||
secondary: {
|
||||
key: 'attainment_8_score' as const,
|
||||
heading: 'Attainment 8',
|
||||
hint: "Average grade across a pupil's best 8 GCSEs, including English and maths",
|
||||
unit: '',
|
||||
},
|
||||
};
|
||||
|
||||
type PhaseKey = keyof typeof METRICS;
|
||||
|
||||
/** All-through schools sit in both phases, matching the search filters. */
|
||||
function isPhase(school: School, phase: PhaseKey): boolean {
|
||||
const p = (school.phase ?? '').toLowerCase();
|
||||
if (p === 'all-through') return true;
|
||||
return phase === 'secondary'
|
||||
? p.includes('secondary') || p === '16 plus'
|
||||
: p.includes('primary') || p.includes('middle');
|
||||
}
|
||||
|
||||
/*
|
||||
* GIAS spells the absence of a faith as "Does not apply", and sometimes
|
||||
* "None" or "Not applicable" — database answers, not English ones. The school
|
||||
* page and the comparison already suppress all three; this is the same rule,
|
||||
* so the two surfaces cannot disagree about the same school.
|
||||
*/
|
||||
const NO_FAITH = /^(none|does not apply|not applicable)$/i;
|
||||
|
||||
/** An attribute the data does not carry. Distinct from the measure's "Not
|
||||
* published": four of those per row would drown the row it qualifies. */
|
||||
const NO_VALUE = '—';
|
||||
|
||||
function faithOf(school: School): string {
|
||||
const denom = school.religious_denomination ?? '';
|
||||
return denom && !NO_FAITH.test(denom) ? denom : NO_VALUE;
|
||||
}
|
||||
|
||||
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
|
||||
const metric = METRICS[phase];
|
||||
/*
|
||||
* Nursery is a primary question. An all-through school renders in both
|
||||
* groups, and its nursery belongs to the primary reading of it — under
|
||||
* "Secondary schools" the column would be a fact about a different intake.
|
||||
*/
|
||||
const showNursery = phase === 'primary';
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">School</th>
|
||||
{/* Same class as the value cell below: one rule aligns both, so
|
||||
they cannot drift apart. */}
|
||||
<th scope="col" className={styles.num}>
|
||||
<abbr className={styles.metricHead} title={metric.hint}>
|
||||
{metric.heading}
|
||||
</abbr>
|
||||
</th>
|
||||
{/* The measure sits second, not last. Six columns overflow a
|
||||
phone and .tableWrap turns that into a swipe; last would put
|
||||
the one number the page exists for off the screen. */}
|
||||
<th scope="col" className={styles.attr}>Ages</th>
|
||||
<th scope="col" className={styles.attrWide}>Religious character</th>
|
||||
{showNursery && <th scope="col" className={styles.attr}>Nursery</th>}
|
||||
<th scope="col" className={styles.attrWide}>Constituency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schools.map((s) => {
|
||||
const value = s[metric.key];
|
||||
return (
|
||||
<tr key={s.urn}>
|
||||
<td>
|
||||
<Link href={schoolUrl(s.urn, s.school_name)} className={styles.schoolLink}>
|
||||
{s.school_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={styles.num}>
|
||||
{value == null
|
||||
? <span className={styles.noData}>Not published</span>
|
||||
: `${Math.round(Number(value))}${metric.unit}`}
|
||||
</td>
|
||||
<td className={styles.attr}>{formatAgeSpan(s.age_range) || NO_VALUE}</td>
|
||||
<td className={styles.attrWide}>{faithOf(s)}</td>
|
||||
{showNursery && (
|
||||
<td className={styles.attr}>
|
||||
{/* Undefined is a mart the pipeline has not rebuilt, and
|
||||
false is a school without one. Neither is a "Yes", and
|
||||
neither is worth two different words. */}
|
||||
{s.nursery_provision ? 'Yes' : NO_VALUE}
|
||||
</td>
|
||||
)}
|
||||
<td className={styles.attrWide}>
|
||||
{s.parliamentary_constituency || NO_VALUE}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
|
||||
const { place, schools, averages } = detail;
|
||||
// Fall back to the single parent when the API predates the authorities
|
||||
// field, so a stale cache never blanks the line entirely.
|
||||
const authorities = place.authorities?.length
|
||||
? place.authorities
|
||||
: place.parent_authority
|
||||
? [{ name: place.parent_authority, slug: authoritySlug(place.parent_authority), count: 0 }]
|
||||
: [];
|
||||
const local = averages[METRICS[phase ?? 'primary'].key];
|
||||
const phaseWord = phase === 'secondary' ? 'Secondary schools'
|
||||
: phase === 'primary' ? 'Primary schools' : 'Schools';
|
||||
const graded = OFSTED_LABELS
|
||||
.map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
|
||||
.filter(([, n]) => n > 0);
|
||||
|
||||
/*
|
||||
* An unphased page holds both primaries and secondaries, and they are
|
||||
* scored on different measures — a percentage and a 0-90 score. Showing one
|
||||
* column for both left 30% of rows blank on /schools/brentwood and put two
|
||||
* incomparable scales in one column when it did not.
|
||||
*
|
||||
* So the phases get a table each. A blank cell inside one now means the
|
||||
* school genuinely has no published result, which is worth saying.
|
||||
*/
|
||||
const groups: Array<[PhaseKey, School[]]> = phase
|
||||
? [[phase, schools]]
|
||||
: (['primary', 'secondary'] as PhaseKey[])
|
||||
.map((p) => [p, schools.filter((s) => isPhase(s, p))] as [PhaseKey, School[]])
|
||||
.filter(([, list]) => list.length > 0);
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'ItemList',
|
||||
name: `${phaseWord} in ${place.name}`,
|
||||
numberOfItems: schools.length,
|
||||
// Alphabetical, and said so. Without this an ItemList carrying
|
||||
// `position` reads as a ranking, which would be a claim the page
|
||||
// stopped making when the table became A-Z.
|
||||
itemListOrder: 'https://schema.org/ItemListOrderAscending',
|
||||
itemListElement: schools.slice(0, 20).map((s, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
url: absoluteUrl(schoolUrl(s.urn, s.school_name)),
|
||||
name: s.school_name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Schools', item: absoluteUrl('/') },
|
||||
{ '@type': 'ListItem', position: 2, name: place.name },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* One line, and all four place families are measured, because they all
|
||||
render through this component. */}
|
||||
<TrackPlaceView kind={place.kind} slug={place.slug}
|
||||
count={place.count} phase={phase} />
|
||||
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<header className={styles.header}>
|
||||
<h1>{phaseWord} in {place.name}</h1>
|
||||
<p className={styles.summary}>
|
||||
{place.count} schools
|
||||
{authorities.length > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
{/* Every authority, not just the largest. A quarter of outcodes
|
||||
and a third of towns cross a boundary: SW19 is mostly Merton
|
||||
but partly Wandsworth, and naming one asserts otherwise. */}
|
||||
{authorities.map((a, i) => (
|
||||
<span key={a.name}>
|
||||
{i > 0 && (i === authorities.length - 1 ? ' and ' : ', ')}
|
||||
{/* No slug means no page: City of London and the Isles of
|
||||
Scilly hold too few schools for one. Saying where the
|
||||
place is stays right; linking there would 404. */}
|
||||
{a.slug
|
||||
? (
|
||||
<Link href={`/schools/authority/${a.slug}`} className={styles.inlineLink}>
|
||||
{a.name}
|
||||
</Link>
|
||||
)
|
||||
: a.name}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!phase && (place.phases ?? []).length > 0 && (
|
||||
<nav className={styles.phaseLinks} aria-label="By phase">
|
||||
{(place.phases ?? []).map((ph) => (
|
||||
/* placeUrl, not a template: the bare `/schools/[slug]/[phase]`
|
||||
shape belongs to towns alone, and using it everywhere sent
|
||||
every authority page into the town namespace. */
|
||||
<Link key={ph} href={placeUrl(place.kind, place.slug, ph)}
|
||||
className={styles.phaseLink}>
|
||||
{ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{local != null && englandAverage != null && (
|
||||
<p className={styles.compare} data-testid="local-vs-england">
|
||||
{place.name} averages <strong>{Math.round(local)}</strong> against{' '}
|
||||
<strong>{Math.round(englandAverage)}</strong> across England.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{graded.length > 0 && (
|
||||
<ul className={styles.ofsted} data-testid="ofsted-distribution">
|
||||
{graded.map(([label, n]) => (
|
||||
<li key={label}>{label}: <strong>{n}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{groups.map(([p, list]) => (
|
||||
<section key={p} className={styles.group}>
|
||||
{groups.length > 1 && (
|
||||
<h2 className={styles.groupHeading}>
|
||||
{p === 'secondary' ? 'Secondary schools' : 'Primary schools'}
|
||||
<span className={styles.groupCount}>{list.length}</span>
|
||||
</h2>
|
||||
)}
|
||||
<SchoolTable schools={list} phase={p} />
|
||||
</section>
|
||||
))}
|
||||
|
||||
{neighbours.length > 0 && (
|
||||
<nav className={styles.neighbours} aria-label="Nearby places">
|
||||
<h2>Nearby</h2>
|
||||
<ul>
|
||||
{neighbours.map((n) => (
|
||||
<li key={n.kind + n.slug}>
|
||||
<Link href={placeUrl(n.kind, n.slug)} className={styles.inlineLink}>{n.name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Fires `place_viewed` once per location page.
|
||||
*
|
||||
* A separate client component because PlaceView is a server component and
|
||||
* cannot call into the browser. It renders nothing — its whole job is the
|
||||
* effect, which keeps the page itself server-rendered.
|
||||
*
|
||||
* Umami already counts a pageview for every one of these URLs, so this is not
|
||||
* about traffic. It is about `kind`: whether to keep investing in the location
|
||||
* layer turns on which *sort* of page earns engagement — towns, authorities,
|
||||
* London localities or postcode districts — and a pageview cannot say, because
|
||||
* all four families share the /schools/ prefix and only the registry knows
|
||||
* which is which.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
|
||||
interface Props {
|
||||
kind: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
phase?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export function TrackPlaceView({ kind, slug, count, phase }: Props) {
|
||||
useEffect(() => {
|
||||
track('place_viewed', {
|
||||
kind,
|
||||
slug,
|
||||
// "all" rather than omitting it, so the unphased page is a value in the
|
||||
// same field rather than a gap that has to be interpreted.
|
||||
phase: phase ?? 'all',
|
||||
school_count: count,
|
||||
// Internal navigation only. An arrival from Google reads as 'direct'
|
||||
// here; Umami's own pageview referrer is where external attribution
|
||||
// lives, and these pages exist to be arrived at externally.
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// Once per place, not once per render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kind, slug, phase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* DestinationsSection — where a school's Year 11 leavers went. Server component.
|
||||
*
|
||||
* The headline is deliberately NOT the sustained-destination rate. That figure
|
||||
* sits between 92% and 97% for nearly every school in England, so leading with
|
||||
* it would say nothing; the mix is what actually varies between schools.
|
||||
*
|
||||
* The section dates its own cohort because destination measures are published
|
||||
* about two GCSE years behind the results in the section above — undated, the
|
||||
* figures read as stale rather than as a different question.
|
||||
*/
|
||||
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
import { Section, sectionStyles } from './sectionShared';
|
||||
import { DestinationsView } from './DestinationsView';
|
||||
|
||||
export function DestinationsSection({ destinations }: { destinations: DestinationPhase }) {
|
||||
const all = destinations.groups.all;
|
||||
const hasContent = Object.values(destinations.groups)
|
||||
.some(group => (group?.categories?.length ?? 0) > 0);
|
||||
if (!hasContent) return null;
|
||||
|
||||
const cohort = all?.cohort ?? null;
|
||||
const year = destinations.cohort_year;
|
||||
|
||||
return (
|
||||
<Section id="destinations">
|
||||
<h2 className={sectionStyles.sectionTitle}>After Year 11</h2>
|
||||
<p className={sectionStyles.sectionSubtitle}>
|
||||
Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11
|
||||
{year ? ` in ${year}` : ''} were during the following year. The Department
|
||||
for Education tracks leavers for two terms, so these figures cover an
|
||||
earlier year group than the GCSE results above.
|
||||
</p>
|
||||
<DestinationsView destinations={destinations} phase="ks4" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* DestinationsView — the interactive body of both destination sections.
|
||||
*
|
||||
* Three question cards over one bar, with the cards acting as a lens on the
|
||||
* bar rather than a summary beside it: focusing a card dims everything the
|
||||
* card is not made of, so the grouping we chose is inspectable rather than
|
||||
* asserted.
|
||||
*
|
||||
* Everything here defers to lib/destinations.ts for what may be shown. In
|
||||
* particular the bar is rendered only when canRenderBar() allows it: the
|
||||
* destination categories sum to the cohort, so a bar drawn from the published
|
||||
* segments leaves a gap whose width IS the withheld figure.
|
||||
*
|
||||
* The one client component in this directory besides AdmissionsViewToggle.
|
||||
* The all-pupils view is what the server renders into the HTML; the switch and
|
||||
* the hover linkage are the only parts that need the browser.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DestinationPhase, DestinationGroupPayload } from '@/lib/types';
|
||||
import {
|
||||
CARD_GROUPS, CARD_QUESTIONS, CATEGORY_LABELS, CATEGORY_ORDER,
|
||||
aggregateCells, canRenderBar, toBarSegments, cardGroupFor,
|
||||
type CardGroup, type DestinationCell, type DestinationGroup, type PupilGroup,
|
||||
} from '@/lib/destinations';
|
||||
import styles from './destinations.module.css';
|
||||
|
||||
const GROUP_LABELS: Record<PupilGroup, string> = {
|
||||
all: 'All pupils',
|
||||
disadvantaged: 'Disadvantaged',
|
||||
other: 'All other pupils',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: PupilGroup[] = ['all', 'disadvantaged', 'other'];
|
||||
|
||||
function toGroup(payload: DestinationGroupPayload): DestinationGroup {
|
||||
return {
|
||||
cohort: payload.cohort ?? 0,
|
||||
cells: payload.categories,
|
||||
};
|
||||
}
|
||||
|
||||
function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
|
||||
const wanted = new Set(CARD_GROUPS[card]);
|
||||
return group.cells.filter(c => wanted.has(c.category));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of the detail table.
|
||||
*
|
||||
* The three statuses are three different statements and the table has to keep
|
||||
* them apart, because the whole pipeline does — the mart, the SQLAlchemy model
|
||||
* and the serialiser all preserve the difference deliberately:
|
||||
*
|
||||
* published the figure
|
||||
* suppressed DfE withheld it to protect a small number of pupils
|
||||
* not_applicable this destination does not apply to this school at all
|
||||
*
|
||||
* An earlier version keyed the share column off `percentage === null`, which is
|
||||
* also true for not_applicable, so a category that simply does not apply was
|
||||
* labelled "withheld" — while the pupils column beside it rendered blank. Both
|
||||
* columns now derive from `status`, so they cannot disagree.
|
||||
*/
|
||||
function cellValue(
|
||||
cell: DestinationCell, cohort: number, kind: 'pupils' | 'share',
|
||||
) {
|
||||
if (cell.status === 'suppressed') {
|
||||
return <span className={styles.withheldMark}>withheld</span>;
|
||||
}
|
||||
const notApplicable = (
|
||||
<span className={styles.notApplicable} title="Does not apply to this school">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
|
||||
if (cell.status !== 'published' || cell.pupils === null) return notApplicable;
|
||||
if (kind === 'pupils') return cell.pupils;
|
||||
|
||||
// Percentages come from the mart, but a published count with no published
|
||||
// percentage is recoverable from the cohort — both halves are published, so
|
||||
// nothing withheld is involved. Same derivation the bar widths use.
|
||||
const share = cell.percentage ?? (cohort > 0 ? (cell.pupils / cohort) * 100 : null);
|
||||
return share === null ? notApplicable : `${Math.round(share)}%`;
|
||||
}
|
||||
|
||||
export function DestinationsView({
|
||||
destinations, phase,
|
||||
}: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) {
|
||||
const available = GROUP_ORDER.filter(
|
||||
g => (destinations.groups[g]?.categories?.length ?? 0) > 0,
|
||||
);
|
||||
const [selected, setSelected] = useState<PupilGroup>(available[0] ?? 'all');
|
||||
const [focused, setFocused] = useState<CardGroup | null>(null);
|
||||
|
||||
const payload = destinations.groups[selected];
|
||||
if (!payload) return null;
|
||||
const group = toGroup(payload);
|
||||
|
||||
const barDrawable = canRenderBar(group);
|
||||
const segments = barDrawable ? toBarSegments(group) : [];
|
||||
const withheld = group.cells.filter(c => c.status === 'suppressed');
|
||||
|
||||
const dimmed = (card: CardGroup | null) => focused !== null && focused !== card;
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
{available.length > 1 && (
|
||||
<div className={styles.switchRow}>
|
||||
<span className={styles.switchLabel} id={`${phase}-cohort-label`}>Show</span>
|
||||
<div
|
||||
className={styles.switchButtons}
|
||||
role="radiogroup"
|
||||
aria-labelledby={`${phase}-cohort-label`}
|
||||
>
|
||||
{available.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === g}
|
||||
className={styles.switchButton}
|
||||
onClick={() => { setSelected(g); setFocused(null); }}
|
||||
>
|
||||
{GROUP_LABELS[g]}
|
||||
<span className={styles.switchCount}>
|
||||
{destinations.groups[g]?.cohort ?? ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cards}>
|
||||
{(Object.keys(CARD_GROUPS) as CardGroup[]).map(card => {
|
||||
const cells = cellsFor(group, card);
|
||||
if (cells.length === 0) return null;
|
||||
const total = aggregateCells(cells, group.cohort);
|
||||
const { question, hint } = CARD_QUESTIONS[card];
|
||||
const keys = cells.map(c => (
|
||||
<span key={c.category} className={`${styles.swatch} ${styles[c.category]}`} />
|
||||
));
|
||||
|
||||
if (total === null) {
|
||||
return (
|
||||
<div key={card} className={`${styles.card} ${styles.cardWithheld}`}>
|
||||
<span className={styles.cardQuestion}>{question}</span>
|
||||
<span className={styles.cardWithheldValue}>Not published</span>
|
||||
<span className={styles.cardHint}>
|
||||
Too few pupils went to {hint} for the Department for Education
|
||||
to release a figure.
|
||||
</span>
|
||||
<span className={styles.cardKeys}>{keys}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card}
|
||||
type="button"
|
||||
className={`${styles.card} ${dimmed(card) ? styles.dim : ''}`}
|
||||
data-group={card}
|
||||
onMouseEnter={() => setFocused(card)}
|
||||
onMouseLeave={() => setFocused(null)}
|
||||
onFocus={() => setFocused(card)}
|
||||
onBlur={() => setFocused(null)}
|
||||
>
|
||||
<span className={styles.cardQuestion}>{question}</span>
|
||||
<span className={styles.cardValue}>{Math.round(total.percentage)}%</span>
|
||||
<span className={styles.cardHint}>went to {hint}.</span>
|
||||
<span className={styles.cardKeys}>{keys}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{barDrawable ? (
|
||||
<div className={styles.barBlock}>
|
||||
<div className={styles.bar}>
|
||||
{segments.map(seg => {
|
||||
const card = cardGroupFor(seg.category);
|
||||
return (
|
||||
<div
|
||||
key={seg.category}
|
||||
data-destination-segment={seg.category}
|
||||
data-group={card ?? 'none'}
|
||||
className={`${styles.segment} ${styles[seg.category]} ${dimmed(card) ? styles.dim : ''}`}
|
||||
style={{ width: `${seg.widthPct}%` }}
|
||||
title={`${CATEGORY_LABELS[seg.category]} — ${seg.labelPct}% (${seg.pupils} pupils)`}
|
||||
>
|
||||
{seg.widthPct >= 9 ? `${seg.labelPct}%` : ''}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.barScale}>
|
||||
<span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.withheldPanel}>
|
||||
<strong className={styles.withheldTitle}>
|
||||
No breakdown chart for this group
|
||||
</strong>
|
||||
<p className={styles.withheldBody}>
|
||||
{withheld.length === 1
|
||||
? 'One of the destinations is withheld'
|
||||
: `${withheld.length} of the destinations are withheld`}
|
||||
{' '}because too few pupils went there. These destinations add up to
|
||||
the whole year group, so drawing the rest as a chart would give the
|
||||
withheld figures away. The table below shows what was published,
|
||||
and nothing more.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Destination</th>
|
||||
<th scope="col">Pupils</th>
|
||||
<th scope="col">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.flatMap(category => {
|
||||
const cell = group.cells.find(c => c.category === category);
|
||||
if (!cell) return [];
|
||||
const card = cardGroupFor(category);
|
||||
return [(
|
||||
<tr
|
||||
key={category}
|
||||
data-group={card ?? 'none'}
|
||||
data-status={cell.status}
|
||||
className={dimmed(card) ? styles.dim : ''}
|
||||
>
|
||||
<th scope="row" className={styles.rowName}>
|
||||
<span className={`${styles.swatch} ${styles[category]}`} />
|
||||
{CATEGORY_LABELS[category]}
|
||||
</th>
|
||||
<td>{cellValue(cell, group.cohort, 'pupils')}</td>
|
||||
<td>{cellValue(cell, group.cohort, 'share')}</td>
|
||||
</tr>
|
||||
)];
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className={styles.footnote}>
|
||||
Shares are rounded and may not add up to 100%. A pupil counted under a
|
||||
school sixth form may have moved to a different school's sixth
|
||||
form — the published data records the type of place, not which one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function DistanceSection({
|
||||
admissionDistance,
|
||||
schoolInfo,
|
||||
}: {
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
schoolInfo: School;
|
||||
}) {
|
||||
// Without a figure there is nothing to compare against, and without
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Post16DestinationsSection — where a school's Year 13 leavers went.
|
||||
* Server component.
|
||||
*
|
||||
* A separate publication, a separate cohort and a separate question from
|
||||
* After Year 11, so it is a separate section rather than a tab: a parent
|
||||
* choosing a secondary and a student choosing a sixth form are not the same
|
||||
* reader.
|
||||
*
|
||||
* Not rendered at all for a school without post-16 provision. A "no data"
|
||||
* placeholder there would imply something is missing, when the truthful
|
||||
* statement is that the question does not apply — which is why the old
|
||||
* "Post-16 destination data coming soon" note is gone rather than reworded.
|
||||
*/
|
||||
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
import { Section, sectionStyles } from './sectionShared';
|
||||
import { DestinationsView } from './DestinationsView';
|
||||
|
||||
export function Post16DestinationsSection({
|
||||
destinations,
|
||||
}: { destinations: DestinationPhase }) {
|
||||
const hasContent = Object.values(destinations.groups)
|
||||
.some(group => (group?.categories?.length ?? 0) > 0);
|
||||
if (!hasContent) return null;
|
||||
|
||||
const cohort = destinations.groups.all?.cohort ?? null;
|
||||
const year = destinations.cohort_year;
|
||||
|
||||
return (
|
||||
<Section id="post16-destinations">
|
||||
<h2 className={sectionStyles.sectionTitle}>After the sixth form</h2>
|
||||
<p className={sectionStyles.sectionSubtitle}>
|
||||
Where {cohort ? `the ${cohort} students` : 'the students'} who finished
|
||||
Year 13{year ? ` in ${year}` : ''} went next.
|
||||
</p>
|
||||
<DestinationsView destinations={destinations} phase="ks5" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -15,17 +15,22 @@ import {
|
||||
} from './lastDistanceOffered';
|
||||
|
||||
export function SecondaryAdmissionsSection({
|
||||
admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
|
||||
admissions, admissionsHistory, admissionDistance, schoolInfo,
|
||||
}: {
|
||||
/* Nullable for the same reason as the primary section: a school can have a
|
||||
published cut-off and no EES admissions row. */
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
schoolInfo: School;
|
||||
hasSixthForm: boolean;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
/* Absent means cut-offs are not being published at all; null means this
|
||||
school has no published cut-off. Only the second is a fact about the
|
||||
school, and only the second can be stated. Saying "X has not published a
|
||||
cut-off" while the feature is dark describes us, and is false wherever the
|
||||
authority does publish one. */
|
||||
const featureOn = admissionDistance !== undefined;
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
@@ -102,7 +107,7 @@ export function SecondaryAdmissionsSection({
|
||||
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
||||
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
||||
</p>
|
||||
) : (
|
||||
) : featureOn ? (
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
{describeCutoffAbsence({
|
||||
localAuthority: schoolInfo.local_authority,
|
||||
@@ -110,13 +115,8 @@ export function SecondaryAdmissionsSection({
|
||||
admissionsHistory,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,14 @@
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
SchoolDestinations,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||
import { OfstedSection } from './OfstedSection';
|
||||
import { GcseSection } from './GcseSection';
|
||||
import { DestinationsSection } from './DestinationsSection';
|
||||
import { Post16DestinationsSection } from './Post16DestinationsSection';
|
||||
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||
import { DistanceSection } from './DistanceSection';
|
||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||
@@ -36,17 +39,21 @@ export interface SecondarySchoolSectionsProps {
|
||||
/** 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;
|
||||
/** Absent — not null — while the admission_distance flag is off. The two
|
||||
* mean different things to the reader and must stay distinguishable:
|
||||
* see SecondaryAdmissionsSection, which words the absence. */
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
destinations: SchoolDestinations | null;
|
||||
flags: SecondaryFlags;
|
||||
}
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, admissionsHistory, admissionDistance,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
deprivation, finance, nationalAvg, destinations, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
@@ -85,6 +92,18 @@ export function SecondarySchoolSections({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Destinations follow attainment: they answer "and then what happened",
|
||||
which only lands once the results are in view. */}
|
||||
{flags.hasKs4Destinations && destinations?.ks4 && (
|
||||
<DestinationsSection destinations={destinations.ks4} />
|
||||
)}
|
||||
|
||||
{/* Sixth-form schools only. Absent, not placeheld, for a school with no
|
||||
post-16 provision — the question simply does not apply there. */}
|
||||
{flags.hasKs5Destinations && destinations?.ks5 && (
|
||||
<Post16DestinationsSection destinations={destinations.ks5} />
|
||||
)}
|
||||
|
||||
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
||||
sources, so either one warrants the section. */}
|
||||
{(admissions || admissionDistance) && (
|
||||
@@ -93,7 +112,6 @@ export function SecondarySchoolSections({
|
||||
admissionDistance={admissionDistance}
|
||||
admissionsHistory={admissionsHistory}
|
||||
schoolInfo={schoolInfo}
|
||||
hasSixthForm={flags.hasSixthForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Destination sections.
|
||||
*
|
||||
* Every colour comes from the --dest-* tokens in globals.css, which are
|
||||
* defined in both themes. Nothing here is a literal colour — see
|
||||
* __tests__/components/darkThemeSafety.test.ts for why.
|
||||
*/
|
||||
|
||||
.view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Cohort switch ─────────────────────────────────────────────────────── */
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switchButtons {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.switchButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.switchButton:hover { color: var(--text-primary); }
|
||||
.switchButton:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||
|
||||
.switchButton[aria-checked='true'] {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.switchCount {
|
||||
margin-left: 0.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Question cards ────────────────────────────────────────────────────── */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
background: var(--brand-bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
.card:hover { border-color: var(--brand); }
|
||||
.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
.cardWithheld {
|
||||
background: var(--bg-secondary);
|
||||
border-style: dashed;
|
||||
border-color: var(--border-strong);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cardQuestion {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.cardValue {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-3);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cardWithheldValue {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cardHint {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cardKeys {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.cardKeys .swatch {
|
||||
width: 1.5rem;
|
||||
height: 0.35rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Bar ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.barBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 2px surface gaps between segments so adjacent fills stay distinguishable
|
||||
without a border darkening the palette. */
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 3rem;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 2px;
|
||||
overflow: hidden;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.barScale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Category colours ──────────────────────────────────────────────────── */
|
||||
|
||||
.higher_education,
|
||||
.school_sixth_form { background: var(--dest-sixthform); }
|
||||
.sixth_form_college { background: var(--dest-sfcollege); }
|
||||
.further_education,
|
||||
.other_education { background: var(--dest-fecollege); color: var(--text-primary); }
|
||||
.apprenticeship { background: var(--dest-apprentice); }
|
||||
.employment { background: var(--dest-employment); }
|
||||
|
||||
/* The absence is hatched neutral, never a colour: "activity not captured"
|
||||
covers independent schools, moving abroad and training the department holds
|
||||
no data on, so a red segment would state something false. The hatch is also
|
||||
the secondary encoding that separates it from the employment blue. */
|
||||
.not_sustained,
|
||||
.not_captured {
|
||||
background-color: var(--bg-card);
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--dest-none-hatch) 0 3px,
|
||||
transparent 3px 7px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--dest-none);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 3px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Dimming, for the card-to-bar linkage ──────────────────────────────── */
|
||||
|
||||
.dim { opacity: 0.3; }
|
||||
|
||||
/* ── Withheld panel ────────────────────────────────────────────────────── */
|
||||
|
||||
.withheldPanel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.withheldTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.withheldBody {
|
||||
margin: 0;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.withheldMark {
|
||||
display: inline-block;
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Table ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tableWrap { overflow-x: auto; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table th:first-child { text-align: left; }
|
||||
|
||||
.rowName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 0;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* A destination that does not apply to this school. Deliberately not the
|
||||
withheld badge: "we are not told" and "there is nothing to tell" are
|
||||
different statements, and the rest of the pipeline keeps them apart. */
|
||||
.notApplicable {
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
}
|
||||
@@ -1883,15 +1883,7 @@
|
||||
color: var(--phase-secondary-text);
|
||||
border: 1px solid rgba(var(--status-above-rgb), 0.2);
|
||||
}
|
||||
.sixthFormNote {
|
||||
margin-top: 1rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-secondary);
|
||||
border-left: 3px solid var(--brand);
|
||||
}
|
||||
|
||||
.genderSplitHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
@@ -2107,7 +2099,6 @@
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fetchSuggestions, SUGGEST_MIN_QUERY, type Suggestion } from '@/lib/suggest';
|
||||
|
||||
/*
|
||||
* Long enough that a fast typist does not fire a request per character, short
|
||||
* enough that the list feels attached to the keyboard.
|
||||
*/
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export function useSchoolSuggest(query: string, enabled: boolean) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
// Set when the user dismisses the list, so a re-render does not reopen it.
|
||||
const dismissed = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!enabled || q.length < SUGGEST_MIN_QUERY || dismissed.current === q) {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Abort the superseded request on every keystroke. This is correctness,
|
||||
* not economy: without it a slow response for "st" can land after the fast
|
||||
* one for "st marys" and replace a correct list with a stale one.
|
||||
*/
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
const rows = await fetchSuggestions(q, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setSuggestions(rows);
|
||||
setActiveIndex(-1);
|
||||
setOpen(rows.length > 0);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, enabled]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
open,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
close: () => {
|
||||
dismissed.current = query.trim();
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,12 @@ jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Everything below this line is browser furniture, and this file runs for
|
||||
// every suite — including the ones that declare `@jest-environment node` to
|
||||
// test route handlers, where NextRequest needs Fetch API globals jsdom does
|
||||
// not provide. There is no `window` there, so guard rather than assume one.
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -52,3 +58,5 @@ const localStorageMock = {
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
} // end: browser-only globals
|
||||
@@ -19,6 +19,7 @@ export type EventName =
|
||||
| 'empty_results'
|
||||
// Engagement
|
||||
| 'school_viewed'
|
||||
| 'place_viewed'
|
||||
| 'section_nav_used'
|
||||
| 'chart_metric_changed'
|
||||
| 'metric_compared_in_rankings'
|
||||
@@ -56,17 +57,80 @@ export function track(name: EventName, data?: Payload): void {
|
||||
* Categorise where the user navigated from, for funnel attribution
|
||||
* (mostly used on school_viewed). Only checks same-origin referrers.
|
||||
*/
|
||||
export function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'detail' | 'direct' {
|
||||
export type NavigationSource =
|
||||
'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct';
|
||||
|
||||
/*
|
||||
* The in-app trail.
|
||||
*
|
||||
* document.referrer is written by the browser only when a *document* loads.
|
||||
* Every internal navigation here is an App Router soft navigation —
|
||||
* history.pushState, no new document — so document.referrer goes on naming
|
||||
* whatever opened the tab (usually nothing, or a search engine) for the whole
|
||||
* session. Reading it to answer "which page did they come from" therefore
|
||||
* returned 'direct' for essentially every in-app journey, including the one
|
||||
* the location layer exists to produce.
|
||||
*
|
||||
* Verified on staging: /schools/brentwood, click a school, the URL becomes
|
||||
* /school/… and document.referrer is still "".
|
||||
*
|
||||
* A module-level trail is the counterpart with exactly the right lifetime. It
|
||||
* survives soft navigation, and it dies on a real document load — which is
|
||||
* precisely when document.referrer becomes meaningful again, so the two cover
|
||||
* each other with no overlap.
|
||||
*/
|
||||
const TRAIL_LIMIT = 4;
|
||||
const trail: string[] = [];
|
||||
|
||||
/** Record a path the user is now on. Called by RouteTrail on every route. */
|
||||
export function recordVisitedPath(path: string): void {
|
||||
if (trail[trail.length - 1] === path) return;
|
||||
trail.push(path);
|
||||
if (trail.length > TRAIL_LIMIT) trail.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent path that is not the one being viewed.
|
||||
*
|
||||
* Skipping the current path rather than taking trail[length - 2] is what
|
||||
* makes the answer independent of ordering: the trail is written by a
|
||||
* layout-level effect and read by a page-level one, and React orders those by
|
||||
* tree position — not a contract worth resting a measurement on. It also
|
||||
* gives the right answer when the user goes back to a page they came from.
|
||||
*/
|
||||
function previousInAppPath(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const current = window.location.pathname;
|
||||
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
||||
if (trail[i] !== current) return trail[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyPath(p: string): NavigationSource {
|
||||
if (p === '/' || p === '') return 'search';
|
||||
if (p.startsWith('/rankings')) return 'rankings';
|
||||
if (p.startsWith('/compare')) return 'compare';
|
||||
// `/schools/` before `/school/`: they differ by one letter and mean
|
||||
// different things — the location layer versus a single school. Checked
|
||||
// first so the narrower-looking prefix cannot shadow it if either string
|
||||
// is ever edited.
|
||||
if (p.startsWith('/schools/')) return 'place';
|
||||
if (p.startsWith('/school/')) return 'detail';
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
export function getNavigationSource(): NavigationSource {
|
||||
const internal = previousInAppPath();
|
||||
if (internal) return classifyPath(internal);
|
||||
|
||||
// No trail means this is the first page of the document, so the referrer is
|
||||
// the only witness — and an honest one.
|
||||
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
||||
try {
|
||||
const ref = new URL(document.referrer);
|
||||
if (ref.origin !== window.location.origin) return 'direct';
|
||||
const p = ref.pathname;
|
||||
if (p === '/' || p === '') return 'search';
|
||||
if (p.startsWith('/rankings')) return 'rankings';
|
||||
if (p.startsWith('/compare')) return 'compare';
|
||||
if (p.startsWith('/school/')) return 'detail';
|
||||
return 'direct';
|
||||
return classifyPath(ref.pathname);
|
||||
} catch {
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Destination measures — categories, the card grouping, and the disclosure
|
||||
* guards.
|
||||
*
|
||||
* DfE suppresses individual cells with `c`, and the destination categories sum
|
||||
* to the cohort. So subtracting the published cells from the cohort total
|
||||
* recovers a lone suppressed cell exactly — which is the case on 22% of
|
||||
* mainstream secondaries.
|
||||
*
|
||||
* The guards here are the SECOND line of defence, not the first. Not drawing a
|
||||
* number does nothing to stop it being computed, so the real fix lives in
|
||||
* backend/data_loader.py::_mask_for_disclosure, which withholds a companion
|
||||
* cell before the figures ever leave the server. These functions keep the UI
|
||||
* honest about what it draws from an already-safe payload.
|
||||
*
|
||||
* See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
*/
|
||||
|
||||
export type DestinationCategory =
|
||||
| 'school_sixth_form'
|
||||
| 'sixth_form_college'
|
||||
| 'further_education'
|
||||
| 'other_education'
|
||||
| 'apprenticeship'
|
||||
| 'employment'
|
||||
| 'not_sustained'
|
||||
| 'not_captured'
|
||||
// 16-18 only.
|
||||
| 'higher_education';
|
||||
|
||||
export type PupilGroup = 'all' | 'disadvantaged' | 'other';
|
||||
|
||||
export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable';
|
||||
|
||||
export type CardGroup = 'academic' | 'college' | 'work';
|
||||
|
||||
export interface DestinationCell {
|
||||
category: DestinationCategory;
|
||||
pupils: number | null;
|
||||
percentage: number | null;
|
||||
status: DestinationStatus;
|
||||
}
|
||||
|
||||
export interface DestinationGroup {
|
||||
cohort: number;
|
||||
cells: DestinationCell[];
|
||||
}
|
||||
|
||||
/** Display order, which is also bar order: education, then work, then absence. */
|
||||
export const CATEGORY_ORDER: DestinationCategory[] = [
|
||||
'higher_education',
|
||||
'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education',
|
||||
'apprenticeship', 'employment', 'not_sustained', 'not_captured',
|
||||
];
|
||||
|
||||
/**
|
||||
* Our grouping, not DfE's — the single most arguable thing on the page, which
|
||||
* is why it lives in exactly one place. `not_sustained` and `not_captured` are
|
||||
* deliberately absent: they are the absence of a destination, not a route, and
|
||||
* "activity not captured" includes independent schools and moving abroad.
|
||||
*/
|
||||
export const CARD_GROUPS: Record<CardGroup, DestinationCategory[]> = {
|
||||
academic: ['higher_education', 'school_sixth_form', 'sixth_form_college'],
|
||||
college: ['further_education', 'other_education'],
|
||||
work: ['apprenticeship', 'employment'],
|
||||
};
|
||||
|
||||
export function suppressedCount(cells: DestinationCell[]): number {
|
||||
return cells.filter(c => c.status === 'suppressed').length;
|
||||
}
|
||||
|
||||
/** R2: a sum computed from components is safe only if every component is published. */
|
||||
export function canAggregate(cells: DestinationCell[]): boolean {
|
||||
return cells.length > 0 && cells.every(c => c.status === 'published');
|
||||
}
|
||||
|
||||
export function aggregateCells(
|
||||
cells: DestinationCell[], cohort: number,
|
||||
): { pupils: number; percentage: number } | null {
|
||||
if (!canAggregate(cells) || cohort <= 0) return null;
|
||||
const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0);
|
||||
return { pupils, percentage: (pupils / cohort) * 100 };
|
||||
}
|
||||
|
||||
/** R1: a bar is drawable only when nothing in the group is withheld. */
|
||||
export function canRenderBar(group: DestinationGroup): boolean {
|
||||
return group.cohort > 0 && group.cells.every(c => c.status === 'published');
|
||||
}
|
||||
|
||||
export interface BarSegment {
|
||||
category: DestinationCategory;
|
||||
pupils: number;
|
||||
/** Exact width from the count — never the rounded percentage. */
|
||||
widthPct: number;
|
||||
/** Rounded value for the segment label. */
|
||||
labelPct: number;
|
||||
}
|
||||
|
||||
export function toBarSegments(group: DestinationGroup): BarSegment[] {
|
||||
if (!canRenderBar(group)) {
|
||||
throw new Error(
|
||||
'toBarSegments: refusing to draw a bar for a group with suppressed categories — '
|
||||
+ 'the gap left behind would disclose the withheld figure (R1).',
|
||||
);
|
||||
}
|
||||
const byCategory = new Map(group.cells.map(c => [c.category, c]));
|
||||
return CATEGORY_ORDER.flatMap<BarSegment>(category => {
|
||||
const cell = byCategory.get(category);
|
||||
if (!cell || cell.pupils === null) return [];
|
||||
const widthPct = (cell.pupils / group.cohort) * 100;
|
||||
return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }];
|
||||
});
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<DestinationCategory, string> = {
|
||||
higher_education: 'UK higher education',
|
||||
school_sixth_form: 'State-funded school sixth form',
|
||||
sixth_form_college: 'Sixth-form college',
|
||||
further_education: 'FE and other colleges',
|
||||
other_education: 'Other education destination',
|
||||
apprenticeship: 'Apprenticeship',
|
||||
employment: 'Employment',
|
||||
not_sustained: 'Not recorded as a sustained destination',
|
||||
not_captured: 'Activity not captured',
|
||||
};
|
||||
|
||||
export const CARD_QUESTIONS: Record<CardGroup, { question: string; hint: string }> = {
|
||||
academic: {
|
||||
question: 'Do leavers stay on an academic route?',
|
||||
hint: 'a school sixth form or a sixth-form college',
|
||||
},
|
||||
college: {
|
||||
question: 'Or move to a college?',
|
||||
hint: 'an FE or other college',
|
||||
},
|
||||
work: {
|
||||
question: 'Or straight into work?',
|
||||
hint: 'an apprenticeship or a job',
|
||||
},
|
||||
};
|
||||
|
||||
/** Which card a category belongs to, or null for the two absence categories. */
|
||||
export function cardGroupFor(category: DestinationCategory): CardGroup | null {
|
||||
for (const [group, categories] of Object.entries(CARD_GROUPS) as [CardGroup, DestinationCategory[]][]) {
|
||||
if (categories.includes(category)) return group;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Reading feature flags.
|
||||
*
|
||||
* Server-side only. No flag value reaches the browser bundle, and there is no
|
||||
* Unleash dependency in package.json — the SDK lives in FastAPI, which already
|
||||
* owns every other piece of data this app renders.
|
||||
*
|
||||
* Flags are declared in backend/flags.py. A purely front-end flag still has to
|
||||
* be declared there; it is a flat data edit, and the return is that one list
|
||||
* answers "what flags exist" for the whole system.
|
||||
*/
|
||||
|
||||
export type Flags = Record<string, boolean>;
|
||||
|
||||
/*
|
||||
* Reading flags pins the calling route to this ISR floor: Next uses the LOWEST
|
||||
* revalidate among a route's fetches to set the whole route's revalidation
|
||||
* frequency. 300s matches what /school/[slug] already sits at, so a page that
|
||||
* reads flags is no more dynamic than a school page already is.
|
||||
*
|
||||
* It is also what makes a flip propagate without a webhook: five minutes on
|
||||
* school pages, an hour on place pages, against flags that flip monthly.
|
||||
*/
|
||||
export const FLAGS_REVALIDATE = 300;
|
||||
|
||||
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
||||
|| 'http://localhost:8000/api';
|
||||
|
||||
/** Every flag and its value. Never throws: an unreadable flag is a dark one. */
|
||||
export async function getFlags(): Promise<Flags> {
|
||||
try {
|
||||
const res = await fetch(`${API}/flags`, {
|
||||
next: { revalidate: FLAGS_REVALIDATE },
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Client for the places API.
|
||||
*
|
||||
* Two namespaces, matching the backend: towns and localities share
|
||||
* /schools/[place]; authorities take /schools/authority/[la]. 67 town names
|
||||
* collide with an authority name and neither set contains the other, so one
|
||||
* namespace would publish near-duplicate pages.
|
||||
*/
|
||||
import type { School } from '@/lib/types';
|
||||
|
||||
export interface PlaceSummary {
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
count: number;
|
||||
/** Phases that clear the threshold on their own, so the page links
|
||||
* variants that exist rather than 404s. Absent on the registry listing. */
|
||||
phases?: string[];
|
||||
}
|
||||
|
||||
export interface PlaceAuthority {
|
||||
name: string;
|
||||
/** null when that authority has no page of its own — two English
|
||||
* authorities hold fewer schools than the threshold. */
|
||||
slug: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PlaceDetail {
|
||||
place: PlaceSummary & {
|
||||
parent_authority: string | null;
|
||||
/** Every authority the place meaningfully sits in, largest first. SW19 is
|
||||
* mostly Merton but partly Wandsworth. */
|
||||
authorities?: PlaceAuthority[];
|
||||
};
|
||||
schools: School[];
|
||||
averages: {
|
||||
rwm_expected_pct: number | null;
|
||||
attainment_8_score: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function placeUrl(kind: string, slug: string, phase?: string): string {
|
||||
const base =
|
||||
kind === 'authority' ? `/schools/authority/${slug}`
|
||||
: kind === 'outcode' ? `/schools/near/${slug}`
|
||||
: `/schools/${slug}`;
|
||||
return phase ? `${base}/${phase}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* An authority name as it appears in a URL.
|
||||
*
|
||||
* Only a fallback: the API sends the slug it built, and that is what should
|
||||
* be used. This mirrors `_slugify` in backend/app.py, collapsed runs and
|
||||
* trimmed hyphens included, so the two cannot disagree about a name like
|
||||
* "Bristol, City of".
|
||||
*/
|
||||
export function authoritySlug(name: string): string {
|
||||
return name.toLowerCase().trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
||||
|| 'http://localhost:8000/api';
|
||||
|
||||
export async function fetchPlaces(): Promise<PlaceSummary[]> {
|
||||
const res = await fetch(`${API}/places`, { next: { revalidate: 604800 } });
|
||||
if (!res.ok) return [];
|
||||
return (await res.json()).places ?? [];
|
||||
}
|
||||
|
||||
export async function fetchPlace(
|
||||
kind: string, slug: string, phase?: string,
|
||||
): Promise<PlaceDetail | null> {
|
||||
const q = phase ? `?phase=${encodeURIComponent(phase)}` : '';
|
||||
const res = await fetch(`${API}/places/${kind}/${slug}${q}`,
|
||||
{ next: { revalidate: 604800 } });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, SchoolCensus,
|
||||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
||||
SchoolDestinations, DestinationPhase,
|
||||
} from './types';
|
||||
import { isSpecialSchool } from './utils';
|
||||
|
||||
@@ -20,6 +21,7 @@ export interface SchoolFlagsInput {
|
||||
census: SchoolCensus | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
destinations?: SchoolDestinations | null;
|
||||
}
|
||||
|
||||
export interface SchoolFlags {
|
||||
@@ -183,10 +185,22 @@ export interface SecondaryFlags {
|
||||
p8Suspended: boolean;
|
||||
isSpecial: boolean;
|
||||
suppressComparison: boolean;
|
||||
/** Whether a destinations phase has anything to render. A block can exist
|
||||
* with empty groups when the pipeline has run but the school has no rows,
|
||||
* and a nav entry for a section that never renders links to nothing. */
|
||||
hasKs4Destinations: boolean;
|
||||
hasKs5Destinations: boolean;
|
||||
}
|
||||
|
||||
/** A phase is renderable only if some pupil group actually carries categories. */
|
||||
function phaseHasContent(phase: DestinationPhase | null | undefined): boolean {
|
||||
if (!phase) return false;
|
||||
return Object.values(phase.groups ?? {})
|
||||
.some(group => (group?.categories?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function computeSecondaryFlags({
|
||||
schoolInfo, yearlyData, deprivation, finance,
|
||||
schoolInfo, yearlyData, deprivation, finance, destinations,
|
||||
}: Omit<SchoolFlagsInput, 'absenceData' | 'census'>): SecondaryFlags {
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
@@ -212,6 +226,8 @@ export function computeSecondaryFlags({
|
||||
return {
|
||||
latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation,
|
||||
hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison,
|
||||
hasKs4Destinations: phaseHasContent(destinations?.ks4),
|
||||
hasKs5Destinations: phaseHasContent(destinations?.ks5),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,6 +244,12 @@ export function buildSecondaryNavItems(
|
||||
const navItems: NavItem[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
// Destinations sit straight after attainment: they answer "and then what
|
||||
// happened", which only makes sense once the results are in view.
|
||||
if (flags.hasKs4Destinations) navItems.push({ id: 'destinations', label: 'After Year 11' });
|
||||
if (flags.hasKs5Destinations) {
|
||||
navItems.push({ id: 'post16-destinations', label: 'After sixth form' });
|
||||
}
|
||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (admissionDistance?.distance_m != null && hasLocation) {
|
||||
navItems.push({ id: 'distance', label: 'Distance' });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Client for /api/suggest.
|
||||
*
|
||||
* No `cache: "no-store"`. The compare modal's search uses it, and copying that
|
||||
* here would discard both the browser cache and the ETag 304s the backend's
|
||||
* CacheAndETagMiddleware already provides — on the one endpoint where prefix
|
||||
* queries repeat most.
|
||||
*/
|
||||
|
||||
export interface Suggestion {
|
||||
urn: number;
|
||||
school_name: string;
|
||||
local_authority: string;
|
||||
postcode: string;
|
||||
phase: string;
|
||||
school_type: string;
|
||||
}
|
||||
|
||||
/** Below this the response is thousands of schools and worth no round trip. */
|
||||
export const SUGGEST_MIN_QUERY = 2;
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
/** Suggestions for `q`. Never throws: no suggestions is a fine outcome. */
|
||||
export async function fetchSuggestions(
|
||||
q: string, signal?: AbortSignal,
|
||||
): Promise<Suggestion[]> {
|
||||
if (q.trim().length < SUGGEST_MIN_QUERY) return [];
|
||||
try {
|
||||
const res = await fetch(`${API}/suggest?q=${encodeURIComponent(q.trim())}`,
|
||||
{ signal });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return body.suggestions ?? [];
|
||||
} catch {
|
||||
// Includes AbortError, which is the normal path on every keystroke.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -361,9 +361,20 @@ export interface SchoolDetailsResponse {
|
||||
* held back as a paid feature and are not part of this public payload — see
|
||||
* data_loader._admission_distance.
|
||||
*/
|
||||
admission_distance: SchoolAdmissionDistance | null;
|
||||
/**
|
||||
* Absent — not null — when the admission_distance flag is off. Null means
|
||||
* "this school has no published cut-off"; absent means "cut-offs are not
|
||||
* being published at all". They are different claims and the type says so.
|
||||
*/
|
||||
admission_distance?: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
/**
|
||||
* Optional so an older backend, which does not send the key at all, still
|
||||
* typechecks. Null means the school has no published destination data;
|
||||
* either way the sections simply do not render.
|
||||
*/
|
||||
destinations?: SchoolDestinations | null;
|
||||
}
|
||||
|
||||
export interface ComparisonData {
|
||||
@@ -593,3 +604,28 @@ export interface SortConfig {
|
||||
key: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
// ── Destination measures ────────────────────────────────────────────────────
|
||||
// Shaped by backend/data_loader.py::_destinations_block. `status` is the field
|
||||
// that matters: 'suppressed' is DfE withholding a figure it judged disclosive
|
||||
// and must render as "withheld"; 'not_applicable' must render as nothing.
|
||||
// `pupils` is null for both, so a null check alone loses the difference.
|
||||
|
||||
import type { DestinationCell, PupilGroup } from './destinations';
|
||||
|
||||
export interface DestinationGroupPayload {
|
||||
cohort: number | null;
|
||||
categories: DestinationCell[];
|
||||
}
|
||||
|
||||
export interface DestinationPhase {
|
||||
/** e.g. "2022/23" — the section dates its own cohort, which runs about two
|
||||
* GCSE years behind the results shown above it. */
|
||||
cohort_year: string | null;
|
||||
groups: Partial<Record<PupilGroup, DestinationGroupPayload>>;
|
||||
}
|
||||
|
||||
export interface SchoolDestinations {
|
||||
ks4: DestinationPhase | null;
|
||||
ks5: DestinationPhase | null;
|
||||
}
|
||||
+12
-2
@@ -82,11 +82,21 @@ export function shortName(name: string, maxLength = 32): string {
|
||||
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
||||
* detection) untouched. Falls back to the raw value if it's not a plain range.
|
||||
*/
|
||||
export function formatAgeRange(ageRange: string | null | undefined): string {
|
||||
export function formatAgeSpan(ageRange: string | null | undefined): string {
|
||||
if (!ageRange) return '';
|
||||
const match = ageRange.match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||||
if (!match) return ageRange;
|
||||
return `Ages ${match[1]}–${match[2]}`;
|
||||
return `${match[1]}–${match[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same span, labelled — for the places that show it with no column
|
||||
* heading to carry the word "Ages". Delegates so the en-dash normalisation
|
||||
* lives in one place.
|
||||
*/
|
||||
export function formatAgeRange(ageRange: string | null | undefined): string {
|
||||
const span = formatAgeSpan(ageRange);
|
||||
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -18,6 +18,7 @@ COPY plugins/ plugins/
|
||||
RUN pip install --no-cache-dir \
|
||||
./plugins/extractors/tap-uk-gias \
|
||||
./plugins/extractors/tap-uk-ees \
|
||||
./plugins/extractors/tap-uk-ees-destinations \
|
||||
./plugins/extractors/tap-uk-ofsted \
|
||||
./plugins/extractors/tap-uk-fbit \
|
||||
./plugins/extractors/tap-uk-idaci
|
||||
|
||||
@@ -178,9 +178,19 @@ with DAG(
|
||||
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees target-postgres",
|
||||
)
|
||||
|
||||
# Destinations come from the EES query API rather than a release ZIP,
|
||||
# so they are a separate tap. Run after extract_ees rather than beside
|
||||
# it: both write to the raw schema and the loader is happier serial.
|
||||
extract_ees_destinations = BashOperator(
|
||||
task_id="extract_ees_destinations",
|
||||
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees-destinations target-postgres",
|
||||
)
|
||||
|
||||
extract_ees >> extract_ees_destinations
|
||||
|
||||
dbt_build_ees = BashOperator(
|
||||
task_id="dbt_build",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+ stg_ees_ks4_destinations+ stg_ees_ks5_destinations+",
|
||||
)
|
||||
|
||||
sync_typesense_ees = BashOperator(
|
||||
|
||||
@@ -41,6 +41,12 @@ plugins:
|
||||
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true"
|
||||
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true"
|
||||
|
||||
- name: tap-uk-ees-destinations
|
||||
namespace: uk_ees_destinations
|
||||
pip_url: ./plugins/extractors/tap-uk-ees-destinations
|
||||
executable: tap-uk-ees-destinations
|
||||
settings: []
|
||||
|
||||
- name: tap-uk-ofsted
|
||||
namespace: uk_ofsted
|
||||
pip_url: ./plugins/extractors/tap-uk-ofsted
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tap-uk-ees-destinations"
|
||||
version = "0.1.0"
|
||||
description = "Singer tap for UK EES destination measures (KS4 and 16-18), school level"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"singer-sdk~=0.53",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
tap-uk-ees-destinations = "tap_uk_ees_destinations.tap:TapUKEESDestinations.cli"
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: tap-uk-ees-destinations
|
||||
Version: 0.1.0
|
||||
Summary: Singer tap for UK EES destination measures (KS4 and 16-18), school level
|
||||
Requires-Python: >=3.10
|
||||
Requires-Dist: singer-sdk~=0.53
|
||||
Requires-Dist: requests>=2.31
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
pyproject.toml
|
||||
tap_uk_ees_destinations/__init__.py
|
||||
tap_uk_ees_destinations/tap.py
|
||||
tap_uk_ees_destinations.egg-info/PKG-INFO
|
||||
tap_uk_ees_destinations.egg-info/SOURCES.txt
|
||||
tap_uk_ees_destinations.egg-info/dependency_links.txt
|
||||
tap_uk_ees_destinations.egg-info/entry_points.txt
|
||||
tap_uk_ees_destinations.egg-info/requires.txt
|
||||
tap_uk_ees_destinations.egg-info/top_level.txt
|
||||
tests/test_tap.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
tap-uk-ees-destinations = tap_uk_ees_destinations.tap:TapUKEESDestinations.cli
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user