Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1ca660cb | ||
|
|
3c69b65ff0 | ||
|
|
24e114dee7 | ||
|
|
6c5db0c266 | ||
|
|
6f749ed21f | ||
|
|
d423826840 | ||
|
|
d3c63ccc6d | ||
|
|
b93eb3a691 | ||
|
|
6b871ce1e9 | ||
|
|
c981d89137 | ||
|
|
de5e790112 | ||
|
|
42138fc402 | ||
|
|
c5af476213 | ||
|
|
de853b90b3 | ||
|
|
759d9f5cea | ||
|
|
555d3f0a7d | ||
|
|
ecc847091c | ||
|
|
b187a478c9 | ||
|
|
c0547c45e5 | ||
|
|
4a3928df9f | ||
|
|
07c97a46c5 | ||
|
|
bb81337aba | ||
|
|
b34511e459 | ||
|
|
f928a15c1e | ||
|
|
1fc1e07d21 | ||
|
|
2208ad93c1 | ||
|
|
24f3cb4c65 | ||
|
|
3816b92d06 | ||
|
|
51ce2d6373 | ||
|
|
5ddc7314fd | ||
|
|
8ad2070e76 | ||
|
|
3aad5101a8 | ||
|
|
c47fe38971 | ||
|
|
8f211577c8 | ||
|
|
69f2201244 | ||
|
|
e6048c9ca6 | ||
|
|
7650b16f62 | ||
|
|
9abd020967 | ||
|
|
228eb214f5 | ||
|
|
ea5249a2ea | ||
|
|
ffe7e04951 | ||
|
|
c9a1892bfb | ||
|
|
50b599a09b | ||
|
|
94151c58ea | ||
|
|
8bf6145a73 | ||
|
|
a72323874f | ||
|
|
5a71f54d94 | ||
|
|
88c653215d | ||
|
|
5156a85bd1 | ||
|
|
fa1abff642 | ||
|
|
38acc76555 | ||
|
|
c7e0c2eac7 | ||
|
|
59ac9c10b9 | ||
|
|
eddf74745f | ||
|
|
d5f3fdf0f9 | ||
|
|
3015c37bac | ||
|
|
153b26a32f | ||
|
|
e373241d51 | ||
|
|
4043270a77 | ||
|
|
e65688d600 | ||
|
|
7f4dfa2748 | ||
|
|
bdaa05cd54 | ||
|
|
043506cb6b | ||
|
|
0466986195 | ||
|
|
3f0e05cc99 | ||
|
|
f1819e9c4d | ||
|
|
97ac5c9cef | ||
|
|
dc22fd2853 | ||
|
|
6b117dd26c | ||
|
|
9ee4d45a94 | ||
|
|
b11ee3c8de | ||
|
|
eecb84fb35 | ||
|
|
39e5c11274 | ||
|
|
accc562b90 | ||
|
|
f31f6f8404 | ||
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
91b49f1a59 | ||
|
|
2433101fa0 | ||
|
|
f43a8ecb69 | ||
|
|
8c3a5cc4e9 | ||
|
|
8ab0ac0a04 | ||
|
|
c298a29b61 | ||
|
|
e098ad4bd1 | ||
|
|
5f961bf7f4 | ||
|
|
803e68970c | ||
|
|
ec2d12478e | ||
|
|
4c229aec6e | ||
|
|
752eb07310 | ||
|
|
a7f6ff4035 | ||
|
|
2f786787b9 | ||
|
|
5044c24895 | ||
|
|
d74cc95034 | ||
|
|
7c43a1baaf | ||
|
|
bcf3498086 | ||
|
|
7199c3a90b | ||
|
|
97dd69a512 | ||
|
|
d1c92ea7d5 | ||
|
|
cab7b4fe91 | ||
|
|
96d5fcf5b0 | ||
|
|
8cf2ac47c1 | ||
|
|
a102508ef1 | ||
|
|
684da611ff | ||
|
|
0186227ced | ||
|
|
59b5f3f163 | ||
|
|
8e763e39d1 | ||
|
|
84bca53c7e | ||
|
|
15b7493b85 | ||
|
|
1d9d2eb5ae | ||
|
|
ddb42badb6 | ||
|
|
702408d6a4 | ||
|
|
09d94e513e |
No files matched your search
@@ -5,3 +5,9 @@ __pycache__/
|
||||
pipeline/transform/target/
|
||||
pipeline/transform/logs/
|
||||
pipeline/transform/.user.yml
|
||||
|
||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||
.playwright-mcp/
|
||||
|
||||
# Playwright run artefacts written when the suite is run from the repo root
|
||||
test-results/
|
||||
+300
-45
@@ -7,6 +7,7 @@ Uses real data from UK Government Compare School Performance downloads.
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -34,6 +35,7 @@ from .data_loader import (
|
||||
search_schools_typesense,
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from .places import build_place_registry
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
@@ -48,11 +50,20 @@ PHASE_GROUPS: dict[str, set[str]] = {
|
||||
"all-through": {"all-through"},
|
||||
}
|
||||
|
||||
BASE_URL = "https://schoolcompare.co.uk"
|
||||
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
|
||||
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
|
||||
BASE_URL = "https://www.schoolcompare.co.uk"
|
||||
MAX_SLUG_LENGTH = 60
|
||||
|
||||
# In-memory sitemap cache
|
||||
_sitemap_xml: str | None = None
|
||||
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
|
||||
# regenerate endpoint after a pipeline run.
|
||||
_sitemaps: dict[str, str] | None = None
|
||||
|
||||
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
||||
# never describe different corpora. Reset by the same admin endpoint.
|
||||
_place_registry: dict | None = None
|
||||
|
||||
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
@@ -70,43 +81,206 @@ def _school_url(urn: int, school_name: str) -> str:
|
||||
return f"/school/{urn}-{slug}"
|
||||
|
||||
|
||||
def build_sitemap() -> str:
|
||||
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
||||
df = load_school_data()
|
||||
# Routes worth submitting that are not a school page. /admissions was missing
|
||||
# from the sitemap entirely despite being a static, indexable guide.
|
||||
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
|
||||
|
||||
static_urls = [
|
||||
(BASE_URL + "/", "daily", "1.0"),
|
||||
(BASE_URL + "/rankings", "weekly", "0.8"),
|
||||
(BASE_URL + "/compare", "weekly", "0.8"),
|
||||
]
|
||||
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||
# A page has something a search result could state if any of these is present
|
||||
# in any year. Shared by _has_publishable_data and the per-school check in
|
||||
# _school_sitemap_rows so the two can never drift.
|
||||
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
|
||||
|
||||
for url, freq, priority in static_urls:
|
||||
lines.append(
|
||||
f" <url><loc>{url}</loc>"
|
||||
f"<changefreq>{freq}</changefreq>"
|
||||
f"<priority>{priority}</priority></url>"
|
||||
|
||||
def _has_publishable_data(row) -> bool:
|
||||
"""True when a school page has something a search result could state.
|
||||
|
||||
A school with no results in any year and no Ofsted grade renders an empty
|
||||
page. Submitting it spends crawl budget and drags the corpus-wide quality
|
||||
signal down, so it stays out of the sitemap. The page itself still resolves
|
||||
for anyone who has the URL.
|
||||
"""
|
||||
for field in _PUBLISHABLE_FIELDS:
|
||||
value = row.get(field)
|
||||
if value is not None and not pd.isna(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _url_element(loc: str, lastmod: str | None = None) -> str:
|
||||
"""One <url> entry. No priority or changefreq — Google ignores both."""
|
||||
body = f"<loc>{loc}</loc>"
|
||||
if lastmod:
|
||||
body += f"<lastmod>{lastmod}</lastmod>"
|
||||
return f" <url>{body}</url>"
|
||||
|
||||
|
||||
def _school_sitemap_rows(df) -> list[str]:
|
||||
"""A <url> element per school that has something to show.
|
||||
|
||||
lastmod comes from the school's Ofsted date where there is one and is
|
||||
omitted otherwise. An always-now lastmod is a claim Google learns to
|
||||
distrust; an absent one honestly means "unknown".
|
||||
"""
|
||||
if df.empty or "urn" not in df.columns or "school_name" not in df.columns:
|
||||
return []
|
||||
|
||||
rows: list[str] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
# Publishable is a property of the SCHOOL, not of its latest row.
|
||||
#
|
||||
# The first cut tested the latest year's row alone, which quietly dropped
|
||||
# every school that has results in its history but a null row for the most
|
||||
# recent year — a school that stopped reporting, or whose figures were
|
||||
# suppressed for small-cohort disclosure. The Mallard Academy (150367) is
|
||||
# the case that caught it: real KS2 results for 2015-16 through 2018-19,
|
||||
# then null rows for 2022-23 onward. Its page shows all four years; the
|
||||
# sitemap omitted it. Roughly 220 schools were affected.
|
||||
publishable_cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||
publishable: set[int] = (
|
||||
set(df.loc[df[publishable_cols].notna().any(axis=1), "urn"].astype(int))
|
||||
if publishable_cols else set()
|
||||
)
|
||||
|
||||
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
|
||||
seen = set()
|
||||
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
||||
# Latest row per URN first, so a school's most recent Ofsted date wins.
|
||||
ordered = df.sort_values("year", ascending=False) if "year" in df.columns else df
|
||||
|
||||
for _, row in ordered.iterrows():
|
||||
urn = int(row["urn"])
|
||||
name = str(row["school_name"])
|
||||
if urn in seen:
|
||||
continue
|
||||
seen.add(urn)
|
||||
path = _school_url(urn, name)
|
||||
lines.append(
|
||||
f" <url><loc>{BASE_URL}{path}</loc>"
|
||||
f"<changefreq>monthly</changefreq>"
|
||||
f"<priority>0.6</priority></url>"
|
||||
)
|
||||
if urn not in publishable:
|
||||
continue
|
||||
|
||||
lines.append("</urlset>")
|
||||
return "\n".join(lines)
|
||||
lastmod = None
|
||||
ofsted_date = row.get("ofsted_date")
|
||||
if ofsted_date is not None and not pd.isna(ofsted_date):
|
||||
lastmod = pd.Timestamp(ofsted_date).date().isoformat()
|
||||
|
||||
rows.append(_url_element(
|
||||
BASE_URL + _school_url(urn, str(row["school_name"])), lastmod))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# Sitemaps cap at 50,000 URLs per file. 10,000 keeps a child small enough to
|
||||
# scan by eye in Search Console, which is the point of splitting at all:
|
||||
# coverage is reported per submitted sitemap, so one file per page family is
|
||||
# what makes an indexation problem attributable to a family.
|
||||
SITEMAP_CHUNK_SIZE = 10_000
|
||||
|
||||
# Children are served under /sitemaps/ because Next.js only treats a whole
|
||||
# bracketed path segment as dynamic — a route folder named "sitemap-[...parts]"
|
||||
# is read as a literal static segment and never matches.
|
||||
SITEMAP_CHILD_PREFIX = "/sitemaps"
|
||||
|
||||
|
||||
def get_place_registry() -> dict:
|
||||
"""The place registry, built once and cached for the process."""
|
||||
global _place_registry
|
||||
if _place_registry is None:
|
||||
_place_registry = build_place_registry(load_school_data())
|
||||
return _place_registry
|
||||
|
||||
|
||||
def _urlset(rows: list[str]) -> str:
|
||||
return "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
*rows,
|
||||
"</urlset>",
|
||||
])
|
||||
|
||||
|
||||
def _place_url(place) -> str:
|
||||
"""The canonical path for a place. Two namespaces, per the spec.
|
||||
|
||||
Towns and localities share /schools/[place]; authorities take their own
|
||||
prefix because 67 town names collide with an authority name and neither
|
||||
set contains the other.
|
||||
"""
|
||||
if place.kind == "authority":
|
||||
return f"/schools/authority/{place.slug}"
|
||||
if place.kind == "outcode":
|
||||
return f"/schools/near/{place.slug}"
|
||||
return f"/schools/{place.slug}"
|
||||
|
||||
|
||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||
threshold on its own.
|
||||
|
||||
Phase is part of the query — "primary schools in beccles" — so each
|
||||
variant is its own indexable page. Submitting only the bare place URL left
|
||||
~950 of them reachable by nothing: absent from every sitemap, and not
|
||||
linked from the place page either.
|
||||
"""
|
||||
rows: list[str] = []
|
||||
for p in sorted(get_place_registry().values(), key=lambda p: (p.kind, p.slug)):
|
||||
if p.kind not in kinds:
|
||||
continue
|
||||
rows.append(_url_element(BASE_URL + _place_url(p)))
|
||||
# Outcodes carry no phase variants: nobody searches "primary schools
|
||||
# in SW11", so the routes do not exist to submit.
|
||||
if p.kind == "outcode":
|
||||
continue
|
||||
for phase in ("primary", "secondary"):
|
||||
if p.publishes_phase(phase):
|
||||
rows.append(_url_element(f"{BASE_URL}{_place_url(p)}/{phase}"))
|
||||
return rows
|
||||
|
||||
|
||||
def build_sitemaps() -> dict[str, str]:
|
||||
"""Build the sitemap index and every child, keyed by name."""
|
||||
df = load_school_data()
|
||||
|
||||
children: dict[str, str] = {
|
||||
"static.xml": _urlset(
|
||||
[_url_element(BASE_URL + path) for path in STATIC_SITEMAP_PATHS]),
|
||||
}
|
||||
|
||||
school_rows = _school_sitemap_rows(df)
|
||||
# Always emit at least one school child, so the index shape is stable even
|
||||
# on an empty database.
|
||||
chunks = [school_rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||
for i in range(0, len(school_rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"schools-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# Separate children per family: Search Console reports coverage per
|
||||
# submitted sitemap, which is how the location layer's indexation is
|
||||
# measured apart from the school pages'.
|
||||
for label, kinds in (("places", ("town", "locality", "authority")),
|
||||
("outcodes", ("outcode",))):
|
||||
rows = _place_sitemap_rows(kinds)
|
||||
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"{label}-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# On a sitemap index, lastmod means "when this sitemap file last changed",
|
||||
# so generation time is the correct value here — unlike on a <url>, where
|
||||
# it would be a claim about content we cannot support.
|
||||
generated = datetime.now(timezone.utc).date().isoformat()
|
||||
index_rows = [
|
||||
f" <sitemap><loc>{BASE_URL}{SITEMAP_CHILD_PREFIX}/{name}</loc>"
|
||||
f"<lastmod>{generated}</lastmod></sitemap>"
|
||||
for name in children
|
||||
]
|
||||
index = "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
*index_rows,
|
||||
"</sitemapindex>",
|
||||
])
|
||||
return {**children, "sitemap.xml": index}
|
||||
|
||||
|
||||
def build_sitemap() -> str:
|
||||
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
|
||||
return build_sitemaps()["sitemap.xml"]
|
||||
|
||||
|
||||
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||
@@ -284,7 +458,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - startup and shutdown events."""
|
||||
global _sitemap_xml
|
||||
global _sitemaps
|
||||
print("Loading school data from marts...")
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
@@ -294,9 +468,9 @@ async def lifespan(app: FastAPI):
|
||||
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||
await asyncio.to_thread(load_latest_school_data)
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
print(f"Sitemap built: {n} URLs.")
|
||||
_sitemaps = build_sitemaps()
|
||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
|
||||
except Exception as e:
|
||||
print(f"Warning: sitemap build failed on startup: {e}")
|
||||
|
||||
@@ -606,17 +780,21 @@ async def get_school_details(request: Request, urn: int):
|
||||
"religious_denomination": latest.get("religious_denomination", ""),
|
||||
"age_range": latest.get("age_range", ""),
|
||||
"has_sixth_form": latest.get("has_sixth_form"),
|
||||
"nursery_provision": latest.get("nursery_provision"),
|
||||
"status": latest.get("status"),
|
||||
"latitude": latest.get("latitude"),
|
||||
"longitude": latest.get("longitude"),
|
||||
"phase": latest.get("phase"),
|
||||
# GIAS fields
|
||||
"website": latest.get("website"),
|
||||
"telephone": latest.get("telephone"),
|
||||
"headteacher_name": latest.get("headteacher_name"),
|
||||
"capacity": latest.get("capacity"),
|
||||
"total_pupils": latest.get("gias_total_pupils"),
|
||||
"trust_name": latest.get("trust_name"),
|
||||
"gender": latest.get("gender"),
|
||||
"county": latest.get("county"),
|
||||
"parliamentary_constituency": latest.get("parliamentary_constituency"),
|
||||
}.items()
|
||||
}
|
||||
|
||||
@@ -628,6 +806,7 @@ async def get_school_details(request: Request, urn: int):
|
||||
"census": supplementary.get("census"),
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
"admission_distance": supplementary.get("admission_distance"),
|
||||
"sen_detail": supplementary.get("sen_detail"),
|
||||
"phonics": supplementary.get("phonics"),
|
||||
"deprivation": supplementary.get("deprivation"),
|
||||
@@ -1002,6 +1181,66 @@ async def get_rankings(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/places")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def list_places(request: Request):
|
||||
"""Every published place. The sitemap and the link modules read this."""
|
||||
registry = get_place_registry()
|
||||
return {"places": [
|
||||
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
|
||||
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
|
||||
]}
|
||||
|
||||
|
||||
@app.get("/api/places/{kind}/{slug}")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_place(request: Request, kind: str, slug: str,
|
||||
phase: Optional[str] = None):
|
||||
"""One place: its schools ranked, and its local averages."""
|
||||
if kind not in VALID_PLACE_KINDS:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
place = get_place_registry().get(f"{kind}:{slug}")
|
||||
if place is None:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
df = load_latest_school_data()
|
||||
rows = df[df["urn"].isin(place.urns)]
|
||||
|
||||
if phase:
|
||||
wanted = PHASE_GROUPS.get(phase.lower())
|
||||
if wanted and "phase" in rows.columns:
|
||||
rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)]
|
||||
|
||||
# The metric the page ranks on, which is also the one it averages.
|
||||
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
|
||||
if metric in rows.columns:
|
||||
rows = rows.sort_values(metric, ascending=False, na_position="last")
|
||||
|
||||
averages = {
|
||||
m: (None if m not in rows.columns or rows[m].dropna().empty
|
||||
else float(rows[m].dropna().mean()))
|
||||
for m in ("rwm_expected_pct", "attainment_8_score")
|
||||
}
|
||||
|
||||
cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
||||
"rwm_expected_pct", "attainment_8_score",
|
||||
"total_pupils"]
|
||||
if c in rows.columns]
|
||||
|
||||
return {
|
||||
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
|
||||
"count": len(place.urns),
|
||||
"parent_authority": place.parent_authority,
|
||||
# Only phases that clear the threshold, so the page links
|
||||
# variants that exist rather than 404s.
|
||||
"phases": [ph for ph in ("primary", "secondary")
|
||||
if place.publishes_phase(ph)]},
|
||||
"schools": clean_for_json(rows[cols]),
|
||||
"averages": averages,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/data-info")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_data_info(request: Request):
|
||||
@@ -1082,16 +1321,28 @@ async def robots_txt():
|
||||
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml():
|
||||
"""Serve sitemap.xml for search engine indexing."""
|
||||
global _sitemap_xml
|
||||
if _sitemap_xml is None:
|
||||
def _serve_sitemap(name: str) -> Response:
|
||||
global _sitemaps
|
||||
if _sitemaps is None:
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
_sitemaps = build_sitemaps()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||
return Response(content=_sitemap_xml, media_type="application/xml")
|
||||
if name not in _sitemaps:
|
||||
raise HTTPException(status_code=404, detail="No such sitemap")
|
||||
return Response(content=_sitemaps[name], media_type="application/xml")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml():
|
||||
"""Serve the sitemap index."""
|
||||
return _serve_sitemap("sitemap.xml")
|
||||
|
||||
|
||||
@app.get("/sitemaps/{name}")
|
||||
async def sitemap_child(name: str):
|
||||
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
|
||||
return _serve_sitemap(name)
|
||||
|
||||
|
||||
@app.post("/api/admin/regenerate-sitemap")
|
||||
@@ -1101,10 +1352,14 @@ async def regenerate_sitemap(
|
||||
_: bool = Depends(verify_admin_api_key),
|
||||
):
|
||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||
global _sitemap_xml
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
return {"status": "ok", "urls": n}
|
||||
global _sitemaps, _place_registry
|
||||
# Places and sitemap are rebuilt together — they read the same marts, and
|
||||
# letting them drift apart would submit URLs for places that no longer
|
||||
# exist.
|
||||
_place_registry = None
|
||||
_sitemaps = build_sitemaps()
|
||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||
return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)}
|
||||
|
||||
|
||||
# Mount static files directly (must be after all routes to avoid catching API calls)
|
||||
|
||||
+86
-2
@@ -18,7 +18,7 @@ from .config import settings
|
||||
from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactAdmissions,
|
||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
|
||||
s.total_pupils AS gias_total_pupils,
|
||||
s.headteacher_name,
|
||||
s.website,
|
||||
s.telephone,
|
||||
s.nursery_provision,
|
||||
foi.ofsted_grade,
|
||||
foi.ofsted_date,
|
||||
foi.ofsted_framework,
|
||||
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
|
||||
l.address_line1 AS address1,
|
||||
l.address_line2 AS address2,
|
||||
l.town,
|
||||
l.county,
|
||||
l.parliamentary_constituency,
|
||||
l.postcode,
|
||||
l.latitude,
|
||||
l.longitude,
|
||||
@@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
||||
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||
|
||||
# Fallback used when marts predate the optional GIAS/location detail columns
|
||||
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
|
||||
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
||||
# present as NULL so the whole data load degrades gracefully instead of failing.
|
||||
_EXTRA_OPTIONAL_COLUMNS = (
|
||||
"telephone",
|
||||
"nursery_provision",
|
||||
"county",
|
||||
"parliamentary_constituency",
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
||||
for _col, _tbl in (
|
||||
("telephone", "s"),
|
||||
("nursery_provision", "s"),
|
||||
("county", "l"),
|
||||
("parliamentary_constituency", "l"),
|
||||
):
|
||||
_src = f"{_tbl}.{_col},"
|
||||
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
||||
f"expected {_src!r} to be present in _MAIN_QUERY before replacement"
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace(
|
||||
_src, f"NULL AS {_col},"
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS)
|
||||
|
||||
_GIAS_CODE_COLUMN_NAMES = (
|
||||
"phase_code",
|
||||
"school_type_code",
|
||||
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
|
||||
"admissions_policy_code",
|
||||
)
|
||||
|
||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
||||
# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports
|
||||
# an undefined *qualified* column unquoted as "column l.county does not exist",
|
||||
# so matching only the s. alias would miss dim_location columns (county,
|
||||
# parliamentary_constituency) and defeat the graceful-degradation fallback.
|
||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist')
|
||||
|
||||
|
||||
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
elif missing in _EXTRA_OPTIONAL_COLUMNS:
|
||||
logging.getLogger(__name__).warning(
|
||||
"marts.dim_school/dim_location is missing %s (pipeline hasn't "
|
||||
"rebuilt the mart yet on this DB) — retrying without the "
|
||||
"optional detail columns: %s",
|
||||
missing, exc,
|
||||
)
|
||||
try:
|
||||
df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, engine)
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
else:
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
return pd.DataFrame()
|
||||
@@ -677,6 +723,17 @@ def _admissions_row_dict(a) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _admission_distance_dict(d) -> dict:
|
||||
"""Serialize one fact_admission_distance row for API responses."""
|
||||
return {
|
||||
"year": d.year,
|
||||
"distance_m": d.distance_m,
|
||||
"route_count": d.route_count,
|
||||
"la_name": d.la_name,
|
||||
"distance_unit_raw": d.distance_unit_raw,
|
||||
}
|
||||
|
||||
|
||||
def _census_dict(pc) -> dict:
|
||||
return {
|
||||
"year": pc.year,
|
||||
@@ -713,6 +770,7 @@ def _empty_supplementary() -> dict:
|
||||
"census": None,
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"admission_distance": None,
|
||||
"sen_detail": None,
|
||||
"phonics": None,
|
||||
"deprivation": None,
|
||||
@@ -791,6 +849,32 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||
_safe(_admissions)
|
||||
|
||||
# Last distance offered — the latest year per URN, and only that.
|
||||
#
|
||||
# The mart holds every published year and the DAG keeps loading them; what
|
||||
# changed is what leaves this process. Earlier years are being held back as
|
||||
# a paid feature, and this API is public and unauthenticated — serving the
|
||||
# history here would hand it to anyone who opened the network tab, whatever
|
||||
# the page chose to render. Withholding it in the client would have been
|
||||
# decoration, not a decision.
|
||||
#
|
||||
# Restoring it for entitled callers is a change to this function, not to
|
||||
# the pipeline: fact_admission_distance is untouched and complete.
|
||||
def _admission_distance():
|
||||
rows = (
|
||||
db.query(FactAdmissionDistance)
|
||||
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
for d in rows:
|
||||
if d.urn in seen:
|
||||
continue
|
||||
seen.add(d.urn)
|
||||
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
||||
_safe(_admission_distance)
|
||||
|
||||
# Deprivation — one row per URN.
|
||||
def _deprivation():
|
||||
rows = (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Curated London localities, defined by the postcode districts they cover.
|
||||
|
||||
The GIAS `town` field puts 1,819 London schools under the single value
|
||||
"London", so it cannot answer "schools in Battersea" — a query that appears in
|
||||
the Search Console baseline. No single field can: parliamentary constituency
|
||||
gives Battersea but not Canary Wharf; postcodes.io's admin_ward gives Canary
|
||||
Wharf but not Battersea; neither gives Clapham or Shoreditch, which are postal
|
||||
and colloquial rather than administrative.
|
||||
|
||||
So this is curated. Where a locality ends is a judgement, not a fact, and a
|
||||
reviewable file is the honest place for a judgement. No new ingestion is
|
||||
needed — the corpus already carries postcodes.
|
||||
|
||||
This is the canonical copy. `pipeline/transform/seeds/locality_outcodes.csv`
|
||||
mirrors it for anyone querying the warehouse directly; the backend image does
|
||||
not contain `pipeline/`, which is why the module rather than the seed is
|
||||
canonical. Same arrangement as `backend/gias_codes.py`.
|
||||
|
||||
A locality whose outcodes hold fewer than MIN_SCHOOLS schools is not
|
||||
published, so a typo produces no page rather than an empty one. Places that
|
||||
fail that check are logged at startup, because a locality you meant to publish
|
||||
quietly not appearing is the failure worth hearing about.
|
||||
|
||||
Two rules for anything added here.
|
||||
|
||||
**Sub-borough districts only.** A London borough is a local authority and
|
||||
already has a page at /schools/authority/[la] covering all of its schools; a
|
||||
locality defined by two or three outcodes would be a partial, near-duplicate
|
||||
subset of it. Hackney, Islington, Greenwich and Ealing were all in the first
|
||||
draft for that reason and have been removed.
|
||||
|
||||
**The slug must not match a GIAS town.** "Richmond" did — GIAS has a Richmond
|
||||
in North Yorkshire with 37 schools — so the London one could never publish.
|
||||
The registry skips any locality that collides and logs it.
|
||||
"""
|
||||
|
||||
# slug -> (display name, outcodes)
|
||||
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"battersea": ("Battersea", ("SW11",)),
|
||||
"canary-wharf": ("Canary Wharf", ("E14",)),
|
||||
"clapham": ("Clapham", ("SW4",)),
|
||||
"shoreditch": ("Shoreditch", ("EC2A", "E1")),
|
||||
"peckham": ("Peckham", ("SE15",)),
|
||||
"brixton": ("Brixton", ("SW2", "SW9")),
|
||||
"camden-town": ("Camden Town", ("NW1",)),
|
||||
"wimbledon": ("Wimbledon", ("SW19",)),
|
||||
"putney": ("Putney", ("SW15",)),
|
||||
"fulham": ("Fulham", ("SW6",)),
|
||||
"chiswick": ("Chiswick", ("W4",)),
|
||||
"stratford": ("Stratford", ("E15",)),
|
||||
"walthamstow": ("Walthamstow", ("E17",)),
|
||||
"tooting": ("Tooting", ("SW17",)),
|
||||
"dulwich": ("Dulwich", ("SE21", "SE22")),
|
||||
}
|
||||
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
|
||||
admissions_policy = Column(String(100))
|
||||
|
||||
|
||||
class FactAdmissionDistance(Base):
|
||||
"""Last distance offered — one row per URN per year.
|
||||
|
||||
Separate from FactAdmissions because the source is separate: EES publishes
|
||||
admissions for the whole country, whereas cut-off distances exist only for
|
||||
the local authorities that choose to publish them (57 at the time of
|
||||
writing), and the two refresh on unrelated timetables.
|
||||
"""
|
||||
__tablename__ = "fact_admission_distance"
|
||||
__table_args__ = (
|
||||
Index("ix_admission_distance_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
# Straight-line distance in metres from the school to the last home offered
|
||||
# a place that year.
|
||||
distance_m = Column(Float)
|
||||
# How many admission routes (ability bands, separate reception/junior
|
||||
# intakes) were collapsed into distance_m. >1 means the figure is the
|
||||
# furthest of several and the page must say so.
|
||||
route_count = Column(Integer)
|
||||
la_code = Column(Integer)
|
||||
la_name = Column(String(100))
|
||||
# The unit the council published in, so the page can lead with the unit a
|
||||
# parent was given rather than always converting.
|
||||
distance_unit_raw = Column(String(20))
|
||||
source_file = Column(Text)
|
||||
|
||||
|
||||
class FactPupilCharacteristics(Base):
|
||||
"""School pupil composition from EES census — one row per URN per year."""
|
||||
__tablename__ = "fact_pupil_characteristics"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""The place registry: what places the site publishes, and what is in each.
|
||||
|
||||
One module owns this question. The pages, the sitemap and the internal-link
|
||||
modules all read from here, so the threshold and the collision rules exist in
|
||||
exactly one place and are testable without a browser or a database.
|
||||
|
||||
Two namespaces, never one. 67 viable town names collide with a local
|
||||
authority name, and the authority is the larger set in only 43 of them —
|
||||
postal towns cross authority boundaries, so neither can absorb the other.
|
||||
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Five schools with publishable data. Below this a place has nothing to say
|
||||
# that a list of schools does not, and publishing it is index bloat.
|
||||
MIN_SCHOOLS = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Place:
|
||||
kind: str # "town" | "locality" | "authority" | "outcode"
|
||||
slug: str
|
||||
name: str
|
||||
urns: tuple[int, ...]
|
||||
parent_authority: str | None # authority NAME, for the 301 target
|
||||
# URNs per phase, so the per-phase threshold can be applied without
|
||||
# re-querying. A place with 30 primaries and 2 secondaries publishes a
|
||||
# primary variant and no secondary one.
|
||||
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
||||
|
||||
def publishes_phase(self, phase: str) -> bool:
|
||||
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.slug}"
|
||||
|
||||
|
||||
def _publishable_urns(df) -> set[int]:
|
||||
"""URNs with something a page could state, deduplicated across years."""
|
||||
from backend.app import _PUBLISHABLE_FIELDS
|
||||
|
||||
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||
if not cols:
|
||||
return set()
|
||||
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
||||
|
||||
|
||||
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
|
||||
"""URNs per phase. All-through schools count toward both, matching the
|
||||
PHASE_GROUPS mapping the search filters already use."""
|
||||
from backend.app import PHASE_GROUPS
|
||||
|
||||
if "phase" not in group.columns:
|
||||
return {}
|
||||
lowered = group["phase"].fillna("").str.lower()
|
||||
out: dict[str, tuple[int, ...]] = {}
|
||||
for phase in ("primary", "secondary"):
|
||||
wanted = PHASE_GROUPS.get(phase, set())
|
||||
subset = group[lowered.isin(wanted)]
|
||||
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
|
||||
if urns:
|
||||
out[phase] = urns
|
||||
return out
|
||||
|
||||
|
||||
def _parent_authority(group) -> str | None:
|
||||
"""The most common authority in a group — the useful 301 target.
|
||||
|
||||
A town spanning several authorities has no single parent, so the mode is
|
||||
the honest answer rather than an arbitrary first row.
|
||||
"""
|
||||
if "local_authority" not in group.columns:
|
||||
return None
|
||||
top = group["local_authority"].dropna()
|
||||
return str(top.mode().iloc[0]) if not top.empty else None
|
||||
|
||||
|
||||
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per distinct value of `column` that clears the threshold."""
|
||||
from backend.app import _slugify
|
||||
|
||||
if column not in df.columns:
|
||||
return {}
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for name, group in df.groupby(column, dropna=True):
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
continue
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
slug = _slugify(name)
|
||||
if not slug:
|
||||
continue
|
||||
place = Place(
|
||||
kind=kind, slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(group) if kind == "town" else None,
|
||||
phase_urns=_phase_urns(group, publishable),
|
||||
)
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
||||
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
||||
|
||||
|
||||
def _outcode(postcode) -> str | None:
|
||||
if not isinstance(postcode, str):
|
||||
return None
|
||||
m = _OUTCODE_RE.match(postcode.upper().strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per postcode district clearing the threshold.
|
||||
|
||||
These carry no phase variants: nobody searches "primary schools in SW11".
|
||||
"""
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
working = working[working["_oc"].notna()]
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for oc, group in working.groupby("_oc"):
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
||||
urns=urns, parent_authority=_parent_authority(group),
|
||||
phase_urns=_phase_urns(group, publishable))
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def _locality_places(df, publishable: set[int],
|
||||
town_slugs: set[str]) -> dict[str, Place]:
|
||||
"""One Place per curated locality clearing the threshold."""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
if slug in town_slugs:
|
||||
# Skip, do not raise. The guard exists so a locality never
|
||||
# silently shadows a town — skipping achieves that, and the error
|
||||
# log makes it loud.
|
||||
#
|
||||
# Raising here took down sitemap generation for all 25,000 school
|
||||
# pages when "richmond" met the GIAS town Richmond in North
|
||||
# Yorkshire. Worse, GIAS town names change without any code change,
|
||||
# so a raise means curated data can break the site spontaneously.
|
||||
# A curation mistake must cost one page, not the sitemap.
|
||||
logger.error(
|
||||
"locality %r collides with the published town of the same "
|
||||
"slug and has been skipped; rename it or remove it", slug)
|
||||
continue
|
||||
group = working[working["_oc"].isin(outcodes)]
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
# Not an error — a locality can legitimately be too small. Logged
|
||||
# because one you meant to publish quietly vanishing is the
|
||||
# failure worth hearing about.
|
||||
logger.warning(
|
||||
"locality %s (%s) has %d publishable schools, below the "
|
||||
"threshold of %d - not published",
|
||||
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
||||
continue
|
||||
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(group),
|
||||
phase_urns=_phase_urns(group, publishable))
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def build_place_registry(df) -> dict[str, Place]:
|
||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||
if df.empty or "urn" not in df.columns:
|
||||
return {}
|
||||
|
||||
publishable = _publishable_urns(df)
|
||||
registry: dict[str, Place] = {}
|
||||
registry.update(_group(df, "local_authority", "authority", publishable))
|
||||
|
||||
towns = _group(df, "town", "town", publishable)
|
||||
registry.update(towns)
|
||||
|
||||
town_slugs = {p.slug for p in towns.values()}
|
||||
registry.update(_locality_places(df, publishable, town_slugs))
|
||||
registry.update(_outcode_places(df, publishable))
|
||||
return registry
|
||||
@@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed():
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_name_location_alias_prefixed():
|
||||
# dim_location columns are selected via the `l.` alias; Postgres reports a
|
||||
# missing qualified column unquoted (e.g. "column l.county does not exist").
|
||||
# The matcher must strip any alias, not just `s.`, or the county /
|
||||
# parliamentary_constituency fallback never triggers and the whole data
|
||||
# load degrades to an empty DataFrame (zero schools) instead of NULLs.
|
||||
assert (
|
||||
_missing_column_name(_fake_exc("column l.county does not exist"))
|
||||
== "county"
|
||||
)
|
||||
assert (
|
||||
_missing_column_name(
|
||||
_fake_exc("column l.parliamentary_constituency does not exist")
|
||||
)
|
||||
== "parliamentary_constituency"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_name_no_match_returns_none():
|
||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the place registry (spec 2026-08-21).
|
||||
|
||||
The registry is built from the in-memory school DataFrame, so these build a
|
||||
small frame directly rather than touching a database.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
base = {
|
||||
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": np.nan,
|
||||
"phase": "Primary", "postcode": "AA1 1AA",
|
||||
}
|
||||
return pd.DataFrame([{**base, **r} for r in rows])
|
||||
|
||||
|
||||
def _town(n: int, town: str, la: str, start: int = 100000, **kw) -> list[dict]:
|
||||
"""`start` offsets the URNs so two calls can describe different schools —
|
||||
the Bedford case needs two authorities' worth of distinct URNs in one
|
||||
town."""
|
||||
return [
|
||||
{"urn": start + i, "school_name": f"{town} School {i}",
|
||||
"town": town, "local_authority": la, **kw}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_town_clearing_the_threshold_is_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
assert "town:brentwood" in reg
|
||||
assert reg["town:brentwood"].name == "Brentwood"
|
||||
assert len(reg["town:brentwood"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_town_below_the_threshold_is_not_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS - 1, "Crosby", "Sefton")))
|
||||
assert "town:crosby" not in reg
|
||||
|
||||
|
||||
def test_a_town_below_threshold_still_names_its_authority():
|
||||
# The route layer needs somewhere to 301 to.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS - 1, "Crosby", "Sefton") + _town(MIN_SCHOOLS, "Bootle", "Sefton")))
|
||||
assert "authority:sefton" in reg
|
||||
|
||||
|
||||
def test_town_and_authority_of_the_same_name_are_separate_places():
|
||||
# 67 real collisions. Neither set contains the other: Bedford the town has
|
||||
# 104 schools, Bedford the authority 86, because postal towns cross
|
||||
# authority boundaries.
|
||||
rows = (_town(MIN_SCHOOLS, "Bedford", "Bedford")
|
||||
+ _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire", start=200000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
town, authority = reg["town:bedford"], reg["authority:bedford"]
|
||||
assert set(town.urns) != set(authority.urns)
|
||||
assert len(town.urns) == MIN_SCHOOLS * 2 # both authorities' schools
|
||||
assert len(authority.urns) == MIN_SCHOOLS # only this authority's
|
||||
|
||||
|
||||
def test_schools_without_publishable_data_do_not_count_toward_the_threshold():
|
||||
rows = _town(MIN_SCHOOLS, "Ghosttown", "Nowhere")
|
||||
for r in rows:
|
||||
r["rwm_expected_pct"] = np.nan
|
||||
r["ofsted_grade"] = np.nan
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "town:ghosttown" not in reg
|
||||
|
||||
|
||||
def test_blank_town_is_ignored():
|
||||
rows = _town(MIN_SCHOOLS, "", "Essex")
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("town:") for k in reg)
|
||||
|
||||
|
||||
def test_a_school_is_counted_once_even_with_several_years_of_rows():
|
||||
rows = []
|
||||
for year in (202324, 202425):
|
||||
rows += [{**r, "year": year} for r in _town(MIN_SCHOOLS, "Beccles", "Suffolk")]
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert len(reg["town:beccles"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_groups_schools_by_outcode(monkeypatch):
|
||||
# The GIAS town field collapses 1,819 London schools into "London", so a
|
||||
# locality is defined by its postcode districts instead.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"battersea": ("Battersea", ("SW11",))})
|
||||
rows = _town(MIN_SCHOOLS, "London", "Wandsworth")
|
||||
for r in rows:
|
||||
r["postcode"] = "SW11 2AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["locality:battersea"].name == "Battersea"
|
||||
assert len(reg["locality:battersea"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_below_the_threshold_is_not_published(monkeypatch):
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"nowhere": ("Nowhere", ("ZZ99",))})
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "London", "Wandsworth")))
|
||||
assert "locality:nowhere" not in reg
|
||||
|
||||
|
||||
def test_a_locality_may_not_shadow_a_viable_town(monkeypatch, caplog):
|
||||
"""A colliding locality is skipped loudly, and the town survives.
|
||||
|
||||
This used to raise, which took down sitemap generation for all 25,000
|
||||
school pages the first time a curated slug met a real GIAS town. Curated
|
||||
data must not be able to break the site — and GIAS town names change with
|
||||
no code change at all, so the raise could fire spontaneously.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
assert "locality:brentwood" not in reg # skipped
|
||||
assert "town:brentwood" in reg # the town is untouched
|
||||
assert "brentwood" in caplog.text # and it was loud about it
|
||||
|
||||
|
||||
def test_a_locality_collision_does_not_break_the_rest_of_the_registry(monkeypatch):
|
||||
# The whole point of skipping rather than raising.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "authority:essex" in reg
|
||||
assert "outcode:cm13" in reg
|
||||
|
||||
|
||||
def test_outcode_places_are_built_from_postcodes():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["outcode:cm13"].name == "CM13"
|
||||
assert len(reg["outcode:cm13"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_malformed_postcodes_do_not_create_places():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "not a postcode"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("outcode:") for k in reg)
|
||||
|
||||
|
||||
def test_every_curated_locality_is_structurally_valid():
|
||||
# Guards the hand-maintained file: real slug, real name, real outcodes.
|
||||
import re
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
assert LOCALITY_OUTCODES, "the curated locality list must not be empty"
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
assert re.fullmatch(r"[a-z0-9-]+", slug), slug
|
||||
assert name.strip() == name and name, slug
|
||||
assert outcodes, f"{slug} has no outcodes"
|
||||
for oc in outcodes:
|
||||
assert re.fullmatch(r"[A-Z]{1,2}\d{1,2}[A-Z]?", oc), (slug, oc)
|
||||
|
||||
|
||||
def test_the_pipeline_seed_mirrors_the_canonical_module():
|
||||
"""Two copies with no drift guard is worse than one copy.
|
||||
|
||||
backend/localities.py is canonical because the backend image does not
|
||||
contain pipeline/. The seed exists so the warehouse can join on the same
|
||||
definitions, and this is what stops the two diverging — the same
|
||||
arrangement assert_gias_code_names_match_seed.sql gives gias_codes.
|
||||
"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
seed_path = (Path(__file__).resolve().parents[2]
|
||||
/ "pipeline/transform/seeds/locality_outcodes.csv")
|
||||
assert seed_path.exists(), f"missing seed mirror at {seed_path}"
|
||||
|
||||
seed = {
|
||||
row["locality_slug"]: (row["locality_name"],
|
||||
tuple(row["outcodes"].split("|")))
|
||||
for row in csv.DictReader(seed_path.open())
|
||||
}
|
||||
assert seed == LOCALITY_OUTCODES
|
||||
|
||||
|
||||
def test_no_curated_locality_names_a_london_borough():
|
||||
"""Boroughs are authorities and already have a page.
|
||||
|
||||
A locality defined by two or three outcodes inside a borough would be a
|
||||
partial, near-duplicate subset of that authority page — the exact
|
||||
thin-content failure the two-namespace design exists to avoid. Hackney,
|
||||
Islington, Greenwich and Ealing were all in the first draft.
|
||||
|
||||
Hardcoded rather than read from the corpus because this must fail in CI,
|
||||
where there is no database.
|
||||
"""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
boroughs = {
|
||||
"barking-and-dagenham", "barnet", "bexley", "brent", "bromley",
|
||||
"camden", "croydon", "ealing", "enfield", "greenwich", "hackney",
|
||||
"hammersmith-and-fulham", "haringey", "harrow", "havering",
|
||||
"hillingdon", "hounslow", "islington", "kensington-and-chelsea",
|
||||
"kingston-upon-thames", "lambeth", "lewisham", "merton", "newham",
|
||||
"redbridge", "richmond-upon-thames", "southwark", "sutton",
|
||||
"tower-hamlets", "waltham-forest", "wandsworth", "westminster",
|
||||
}
|
||||
named = boroughs & set(LOCALITY_OUTCODES)
|
||||
assert not named, (
|
||||
f"these are boroughs, not districts: {sorted(named)} - they already "
|
||||
"have an authority page covering every school"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for the places API (spec 2026-08-21)."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
|
||||
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 50.0 + i}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_registry_lists_each_published_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
|
||||
assert ("town", "brentwood") in slugs
|
||||
assert ("authority", "essex") in slugs
|
||||
assert ("outcode", "cm13") in slugs
|
||||
|
||||
|
||||
def test_registry_carries_a_count_per_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
town = next(p for p in body["places"] if p["slug"] == "brentwood")
|
||||
assert town["count"] == 6
|
||||
|
||||
|
||||
def test_place_detail_returns_its_schools_ranked(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
assert body["place"]["name"] == "Brentwood"
|
||||
scores = [s["rwm_expected_pct"] for s in body["schools"]]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
def test_place_detail_carries_the_local_average(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
# 50..55 inclusive
|
||||
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
|
||||
|
||||
|
||||
def test_phase_filter_narrows_the_school_list(client):
|
||||
body = client.get("/api/places/town/brentwood?phase=secondary").json()
|
||||
assert body["schools"] == []
|
||||
|
||||
|
||||
def test_unknown_place_404s(client):
|
||||
assert client.get("/api/places/town/atlantis").status_code == 404
|
||||
|
||||
|
||||
def test_unknown_kind_404s(client):
|
||||
assert client.get("/api/places/planet/mars").status_code == 404
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for sitemap generation (spec 2026-08-20, workstream W1).
|
||||
|
||||
The sitemap is built from the in-memory school DataFrame, so these inject a
|
||||
small frame via monkeypatch rather than touching a database.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
"""Two schools: one with results, one with neither results nor Ofsted."""
|
||||
base = {
|
||||
"local_authority": "Testshire",
|
||||
"school_type": "Academy",
|
||||
"phase": "Primary",
|
||||
"year": 202425,
|
||||
"ofsted_date": None,
|
||||
}
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{**base, "urn": 100001, "school_name": "Alpha Primary",
|
||||
"rwm_expected_pct": 62.0, "attainment_8_score": np.nan,
|
||||
"ofsted_grade": 2.0},
|
||||
{**base, "urn": 100002, "school_name": "Ghost Primary",
|
||||
"rwm_expected_pct": np.nan, "attainment_8_score": np.nan,
|
||||
"ofsted_grade": np.nan},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sitemap(monkeypatch) -> str:
|
||||
"""The sitemap index."""
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemap()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def schools_child(monkeypatch) -> str:
|
||||
"""The first school child sitemap, where school URLs actually live."""
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()["schools-1.xml"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def static_child(monkeypatch) -> str:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()["static.xml"]
|
||||
|
||||
|
||||
def test_every_loc_uses_the_www_host(sitemaps):
|
||||
# The apex 301s to www. A <loc> that redirects burns a crawl per URL.
|
||||
# Checked across every file, index included, not just one.
|
||||
#
|
||||
# A child can legitimately be empty — this fixture holds two schools and no
|
||||
# town clearing the threshold — so the presence check applies only to files
|
||||
# that carry URLs. The absence check applies to all of them.
|
||||
for name, xml in sitemaps.items():
|
||||
assert "https://schoolcompare.co.uk" not in xml, name
|
||||
if "<loc>" in xml:
|
||||
assert "https://www.schoolcompare.co.uk" in xml, name
|
||||
|
||||
|
||||
def test_school_with_results_is_listed(schools_child):
|
||||
assert "/school/100001-alpha-primary" in schools_child
|
||||
|
||||
|
||||
def test_school_with_no_results_and_no_ofsted_is_omitted(schools_child):
|
||||
# Nothing for a search result to say about it. Submitting it spends crawl
|
||||
# budget and drags the corpus-wide quality signal down.
|
||||
#
|
||||
# Asserted against the child, not the index: the index carries no school
|
||||
# URLs at all, so it would pass this trivially and prove nothing.
|
||||
assert "/school/100002" not in schools_child
|
||||
|
||||
|
||||
def test_no_invented_priority_or_changefreq(sitemaps):
|
||||
# Google ignores both. They were noise dressed as signal.
|
||||
for name, xml in sitemaps.items():
|
||||
assert "<priority>" not in xml, name
|
||||
assert "<changefreq>" not in xml, name
|
||||
|
||||
|
||||
def test_ofsted_date_becomes_lastmod(monkeypatch):
|
||||
from backend import app as app_module
|
||||
import datetime
|
||||
|
||||
def _df():
|
||||
base = _schools_df()
|
||||
base.loc[base["urn"] == 100001, "ofsted_date"] = datetime.date(2024, 3, 14)
|
||||
return base
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "<lastmod>2024-03-14</lastmod>" in xml
|
||||
|
||||
|
||||
def test_no_lastmod_invented_when_date_unknown(monkeypatch):
|
||||
# An always-now lastmod is a claim Google learns to distrust. Absent
|
||||
# honestly means unknown.
|
||||
from backend import app as app_module
|
||||
|
||||
def _df():
|
||||
df = _schools_df()
|
||||
df["ofsted_date"] = None
|
||||
return df
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
# The child only. The index legitimately carries a lastmod, because there
|
||||
# it means "when this sitemap file changed", which we do know.
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "<lastmod>" not in xml
|
||||
|
||||
|
||||
def test_static_routes_are_listed(static_child):
|
||||
for path in ("/", "/rankings", "/compare", "/admissions"):
|
||||
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static_child
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sitemaps(monkeypatch) -> dict:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()
|
||||
|
||||
|
||||
def test_index_lists_each_child(sitemaps):
|
||||
index = sitemaps["sitemap.xml"]
|
||||
assert "<sitemapindex" in index
|
||||
assert "https://www.schoolcompare.co.uk/sitemaps/static.xml" in index
|
||||
assert "https://www.schoolcompare.co.uk/sitemaps/schools-1.xml" in index
|
||||
|
||||
|
||||
def test_index_carries_no_url_elements(sitemaps):
|
||||
# A sitemap index holds <sitemap> entries only; mixing in <url> is invalid.
|
||||
assert "<url>" not in sitemaps["sitemap.xml"]
|
||||
|
||||
|
||||
def test_index_does_not_list_itself(sitemaps):
|
||||
assert "<loc>https://www.schoolcompare.co.uk/sitemap.xml</loc>" not in sitemaps["sitemap.xml"]
|
||||
|
||||
|
||||
def test_static_child_holds_the_static_routes(sitemaps):
|
||||
static = sitemaps["static.xml"]
|
||||
for path in ("/", "/rankings", "/compare", "/admissions"):
|
||||
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static
|
||||
|
||||
|
||||
def test_school_child_holds_the_schools(sitemaps):
|
||||
assert "/school/100001-alpha-primary" in sitemaps["schools-1.xml"]
|
||||
|
||||
|
||||
def test_children_are_chunked_under_the_limit(monkeypatch):
|
||||
# Sitemaps cap at 50,000 URLs per file. Chunk at 10,000 so a child stays
|
||||
# small enough to eyeball in Search Console.
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
rows = [
|
||||
{"urn": 200000 + i, "school_name": f"School {i}", "year": 202425,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": None,
|
||||
"ofsted_grade": 2.0, "ofsted_date": None}
|
||||
for i in range(10_001)
|
||||
]
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: _pd.DataFrame(rows))
|
||||
|
||||
maps = app_module.build_sitemaps()
|
||||
assert maps["schools-1.xml"].count("<url>") == 10_000
|
||||
assert maps["schools-2.xml"].count("<url>") == 1
|
||||
|
||||
|
||||
def test_build_sitemap_still_returns_the_index(sitemap):
|
||||
# lifespan and the admin endpoint call build_sitemap(); keep it working.
|
||||
assert "<sitemapindex" in sitemap
|
||||
|
||||
|
||||
def test_school_with_results_in_an_earlier_year_is_still_listed(monkeypatch):
|
||||
"""Regression: The Mallard Academy (150367).
|
||||
|
||||
Real KS2 results 2015-16 to 2018-19, then null rows from 2022-23 onward
|
||||
because the school stopped reporting. The first cut tested the latest
|
||||
year's row alone and dropped it, along with ~220 others, even though its
|
||||
detail page shows all four years of results.
|
||||
"""
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
base = {"local_authority": "Testshire", "school_type": "Academy",
|
||||
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
|
||||
"attainment_8_score": np.nan, "urn": 150367,
|
||||
"school_name": "Mallard Academy"}
|
||||
df = _pd.DataFrame([
|
||||
{**base, "year": 201819, "rwm_expected_pct": 67.0},
|
||||
{**base, "year": 202324, "rwm_expected_pct": np.nan},
|
||||
{**base, "year": 202425, "rwm_expected_pct": np.nan},
|
||||
])
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "/school/150367-mallard-academy" in xml
|
||||
|
||||
|
||||
def test_school_with_no_results_in_any_year_is_still_omitted(monkeypatch):
|
||||
"""The fix must not turn into "list everything"."""
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
base = {"local_authority": "Testshire", "school_type": "Academy",
|
||||
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
|
||||
"attainment_8_score": np.nan, "rwm_expected_pct": np.nan,
|
||||
"urn": 100002, "school_name": "Ghost Primary"}
|
||||
df = _pd.DataFrame([{**base, "year": y} for y in (202324, 202425)])
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
|
||||
assert "/school/100002" not in app_module.build_sitemaps()["schools-1.xml"]
|
||||
|
||||
|
||||
def _places_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA",
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 60.0}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def place_sitemaps(monkeypatch) -> dict:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _places_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return app_module.build_sitemaps()
|
||||
|
||||
|
||||
def test_place_children_are_listed_in_the_index(place_sitemaps):
|
||||
index = place_sitemaps["sitemap.xml"]
|
||||
assert "/sitemaps/places-1.xml" in index
|
||||
assert "/sitemaps/outcodes-1.xml" in index
|
||||
|
||||
|
||||
def test_town_and_authority_urls_use_their_own_namespaces(place_sitemaps):
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood</loc>" in xml
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/authority/essex</loc>" in xml
|
||||
|
||||
|
||||
def test_outcode_urls_live_in_their_own_child(place_sitemaps):
|
||||
assert "/schools/near/cm13" in place_sitemaps["outcodes-1.xml"]
|
||||
assert "/schools/near/cm13" not in place_sitemaps["places-1.xml"]
|
||||
|
||||
|
||||
def test_place_urls_carry_no_priority_or_changefreq(place_sitemaps):
|
||||
for name in ("places-1.xml", "outcodes-1.xml"):
|
||||
assert "<priority>" not in place_sitemaps[name]
|
||||
assert "<changefreq>" not in place_sitemaps[name]
|
||||
|
||||
|
||||
def test_phase_variants_are_submitted_where_the_phase_clears_the_threshold(place_sitemaps):
|
||||
# "primary schools in beccles" is the query shape the baseline showed, so
|
||||
# each variant is its own page and has to be submitted. Emitting only the
|
||||
# bare place URL left ~950 of them reachable by nothing.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood/primary</loc>" in xml
|
||||
|
||||
|
||||
def test_a_phase_below_its_own_threshold_is_not_submitted(place_sitemaps):
|
||||
# The fixture is six primaries and no secondaries.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "/schools/brentwood/secondary" not in xml
|
||||
|
||||
|
||||
def test_outcodes_get_no_phase_variants(place_sitemaps):
|
||||
# Nobody searches "primary schools in CM13"; the routes do not exist.
|
||||
xml = place_sitemaps["outcodes-1.xml"]
|
||||
assert "/primary" not in xml and "/secondary" not in xml
|
||||
@@ -8,8 +8,31 @@ from backend import data_loader
|
||||
from backend.data_loader import get_supplementary_data_batch
|
||||
|
||||
|
||||
def _sort_key(criterion):
|
||||
"""(column name, descending) for a SQLAlchemy order_by argument.
|
||||
|
||||
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
|
||||
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
|
||||
.element.
|
||||
"""
|
||||
name = getattr(criterion, "key", None)
|
||||
if name is not None:
|
||||
return name, False
|
||||
element = getattr(criterion, "element", None)
|
||||
name = getattr(element, "key", None)
|
||||
return name, "DESC" in str(criterion).upper()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list.
|
||||
|
||||
order_by is honoured rather than ignored. The batch loader picks a row per
|
||||
URN by position — first for "latest Ofsted", last for "latest cut-off
|
||||
distance" — which is only correct because the database returned them
|
||||
sorted. A double that drops the ORDER BY makes those picks depend on
|
||||
fixture insertion order instead, so the test would pass with the sort
|
||||
reversed or removed and prove nothing about the query.
|
||||
"""
|
||||
|
||||
def __init__(self, recorder, model_name, rows):
|
||||
self._rec = recorder
|
||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
def order_by(self, *criteria):
|
||||
for crit in reversed(criteria): # reversed = stable multi-key sort
|
||||
name, descending = _sort_key(crit)
|
||||
if not name:
|
||||
continue
|
||||
values = [getattr(r, name, None) for r in self._rows]
|
||||
# Only sort on plainly comparable values. Some fixtures stand dates
|
||||
# up as namespace objects, which raise on <; leaving those in their
|
||||
# given order matches what the real query would produce for them.
|
||||
if not all(isinstance(v, (int, float, str)) for v in values):
|
||||
continue
|
||||
self._rows = sorted(
|
||||
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
||||
)
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
@@ -69,6 +105,13 @@ def _adm_row(urn, year):
|
||||
)
|
||||
|
||||
|
||||
def _dist_row(urn, year, distance_m, route_count=1):
|
||||
return types.SimpleNamespace(
|
||||
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
|
||||
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
|
||||
)
|
||||
|
||||
|
||||
def test_one_query_per_table_and_latest_row_per_urn():
|
||||
rows = {
|
||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||
@@ -78,6 +121,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
_ofsted_row(2, "2021-06-01", 1),
|
||||
],
|
||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||
# URN 1 has three years of cut-offs; only the most recent is served.
|
||||
# Deliberately not in year order — the ordering is the query's job.
|
||||
"FactAdmissionDistance": [
|
||||
_dist_row(1, 2026, 529.47),
|
||||
_dist_row(1, 2024, 772.49),
|
||||
_dist_row(1, 2025, 1421.05),
|
||||
_dist_row(2, 2023, 2029.38, route_count=4),
|
||||
],
|
||||
"FactPupilCharacteristics": [],
|
||||
"FactDeprivation": [],
|
||||
"FactFinance": [],
|
||||
@@ -85,10 +136,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
session = _FakeSession(rows)
|
||||
out = get_supplementary_data_batch(session, [1, 2])
|
||||
|
||||
# Exactly one query per table — five total, regardless of two URNs.
|
||||
# Exactly one query per table — six total, regardless of two URNs.
|
||||
assert sorted(session.queries) == [
|
||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||
]
|
||||
|
||||
# Latest Ofsted kept per URN
|
||||
@@ -100,6 +151,18 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
assert out[1]["admissions"]["year"] == 202627
|
||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||
|
||||
# Cut-off distance: the latest year only. Earlier years stay in the mart
|
||||
# but are held back as a paid feature, and this API is public — serving
|
||||
# them here would hand them to anyone reading the response. The fixture
|
||||
# rows are deliberately out of order, so "latest" only comes out right if
|
||||
# the query's ORDER BY is doing the work.
|
||||
assert out[1]["admission_distance"]["year"] == 2026
|
||||
assert "admission_distance_history" not in out[1]
|
||||
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||
# route_count travels with the figure — the page needs it to say the
|
||||
# distance is the furthest of several bands rather than the only one.
|
||||
assert out[2]["admission_distance"]["route_count"] == 4
|
||||
|
||||
# Empty tables degrade to the null block, not a crash
|
||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||
|
||||
@@ -109,3 +172,5 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
||||
single = data_loader.get_supplementary_data(session, 5)
|
||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||
assert single["admissions_history"] == []
|
||||
assert single["admission_distance"] is None
|
||||
assert "admission_distance_history" not in single
|
||||
@@ -0,0 +1,85 @@
|
||||
# Remote branch cleanup, 2026-08-20
|
||||
# Restore any branch with: git push origin <sha>:refs/heads/<name>
|
||||
|
||||
## Deleted: fully merged into main (content is in main)
|
||||
75677f4252b759ef895e7d5f7c19f8f1745bdb59 add-contact-form-footer
|
||||
fa1abff642683dfd26ba88a295a0a6710d77147a chore/byline-removal-and-audit-figure
|
||||
95081d38bdf87764ef5d298676c25fae4cd3b792 chore/remove-parent-view
|
||||
6877abedebfc1c7d95f1f6ebe68945c62be528ab chore/staged-prod-promotion
|
||||
090d5f7bec824e083d3252e2c6e636686016304d ci/frontend-checks-speedup
|
||||
8c3a5cc4e9f551f0190d85357ce7741ad87f3a4c design/cohort-identity
|
||||
955659580067ca8b81bd77e31e4e2554103f094d feat/allow-analytics-iframe-embed
|
||||
6828f6cd4417284ea3eb6f088fa20945b8b40ed3 feat/compare-chips-two-per-row
|
||||
d5cd0abfee226885119665da5d2aa8288b59217f feat/compare-data-foundation
|
||||
6dd9b04b50bee146682da87efad8fc8b526251c5 feat/compare-frontend-rebuild
|
||||
96d5fcf5b07b6f175b48e9b20fcb765a320a907f feat/detail-header-details-reveal
|
||||
f1388ff5bd0af1409823a1e047b7ba84246a0f70 feat/gias-sixth-form-flag
|
||||
eddf74745f86c9c6d9eb07d867246ff9cc90dc20 feat/hero-artwork-v2
|
||||
3015c37bac6dc28db58c80fdb9235942c25b83d7 feat/hero-byline
|
||||
8e763e39d17a4964cf558e51c03f044186371f6d feat/info-popover-tooltip
|
||||
88c653215d520ab6e902c9de55bf27d86eeb90c3 feat/last-distance-offered
|
||||
a72323874f7aebdb2e64b6d64a5febd61152d09d feat/last-distance-offered-full
|
||||
c9a1892bfb0370e0672e5849cba294ddfabe3c65 feat/latest-cutoff-only
|
||||
4e8df006d75d8be2a1d8529ddf855c445854cba1 feat/near-me-by-search
|
||||
45ab479062c6a1639facad636fc0cc0cf0fd9155 feat/proposed-to-close-schools
|
||||
3bf2e8f262cbe058fda6de6f8ea3e050224a51b9 feat/school-detail-visualisations
|
||||
1f80571b1ff217dc92b660a936c02b5f49d07f0b feat/umami-heatmap-recorder
|
||||
609bb923d96aa5730131463ea35d5efdd404bf96 feature/ingest-independent-schools
|
||||
94151c58ea38a9256d66d15161293486a500c7a2 fix/admissions-section-height
|
||||
79246edc22961c2beb3520437e9d064d3b809d10 fix/annual-dag-ks4-national-selector
|
||||
59ac9c10b97e0ef1143f57fea06b324e72ac3d4a fix/chart-marker-contrast
|
||||
9f8dba227c95706ca3527bd48d381e7622cc0a5e fix/compare-chart-refetch-resilience
|
||||
e74d3882ce78a141fa1a57daa3102d7a58852dc3 fix/compare-expert-fixes
|
||||
80176cac4db4820e76ea2a156c7a2974bee2f203 fix/compare-final-review-mustfix
|
||||
f579630fab6c456e26a6984a3e8eebdbe3184202 fix/compare-mockup-drift
|
||||
dc85254ad2ddf134b4434065d4762cef374d2220 fix/compare-null-year-blanks-chart
|
||||
43a2c4a6bc539b621f31655aec05ef319a25f343 fix/compare-refresh-and-fetch
|
||||
d677b5453365b72c81d6df2de62b1fa0d05d684d fix/daily-dag-cache-invalidation
|
||||
f6bb037c471553e8195b5a8b147467ce0d07a688 fix/detail-all-through
|
||||
17bd4d5a5eb0b12ca79b97db587f14f7d671e85b fix/detail-chart-truthfulness
|
||||
4e6be0ce65647410b4ff74f8b763207c28920c26 fix/detail-inclusion-admissions
|
||||
fdda52ff0af3fae03a4b059a655973cdbb269f91 fix/detail-ofsted-correctness
|
||||
e36125b24aba254a8d15c5c33b4d2a296e691995 fix/detail-provenance-anchoring
|
||||
b31e71ac884df6507f567adc046c9fc52d9d310a fix/detail-report-card-render-date
|
||||
32f8a02862be6a1d49f4c3928b17fcf15d4c94cc fix/detail-trend-chart-taller
|
||||
e65688d600a86818fe21ae4c61ba27e5b6ec8d7c fix/e2e-brand-assertions
|
||||
3adea73ee04cdedfab54b0351878b297f72756ad fix/e2e-compare-chips-phase
|
||||
06e4898c30feaedc471f97aba28ddb0d61379f4d fix/e2e-compare-samephase
|
||||
9abd020967670a855e80fe5a908c8048a3aa9f14 fix/e2e-distance-locator
|
||||
acec8135e1ec7c9c3c255e5b23733a0ef862b590 fix/e2e-rankings-year-pick
|
||||
5944d88f0b1517ef1ef56af1b62270d2cf28e717 fix/expert-signoff-mustfixes
|
||||
2433101fa08be5df6f170d41790512ffe823d33e fix/font-cascade-and-map-palette
|
||||
74ca76d150deec6725259d9637ea86d7bb90c683 fix/gias-legacy-fallback
|
||||
bdaa05cd542f563ef74c45307cd8f7fc465193c9 fix/hero-fallback-and-sharp
|
||||
d52d384cf23d282b44e9251176f8f3402d808600 fix/hero-map-ios-fullscreen
|
||||
4043270a77bbe4edb18207fa5f1d94d4747fe12f fix/hero-mobile-and-wording
|
||||
22e9eb2d48b0d6623e88fd67cb6ca8e5e4074583 fix/homepage-education-accuracy
|
||||
8d50afef1e8a2b621b7344eadf475b0d609ad7a2 fix/leaflet-specificity-and-font-assertion
|
||||
b2b2cad5acf534ae7a667d3fb2be15efff37c4a7 fix/list-map-report-card-signal
|
||||
dc21e80a5e9eebd13aaab84735642eb77cef35e6 fix/mobile-cell-name-size
|
||||
e5f7f4c959f024c073472122d858333a0f24866c fix/mobile-compare-polish
|
||||
a00cbe916182d1e04661c750a1ce2e7ad27a68ae fix/mobile-sort-select-overflow
|
||||
2fd997bfe640c419a6713e85df008463de7f56e8 fix/modal-keyboard-viewport
|
||||
3e7705756776a0c44d27966dfc972023f1f38b69 fix/ofsted-link-text
|
||||
ce422e64363e2b03c186ef316832b6de15502d67 fix/promote-status-token
|
||||
4522cbf64560db1e1cd519e119aa466b42cd16a1 fix/proposed-to-close-copy
|
||||
15da060e4af37fbae919e0edf25f108266de2585 fix/rankings-admissions-accuracy
|
||||
6c872ce726f210433354ca38dc5314bc6a467534 fix/rankings-year-validation
|
||||
1c1df7796194af3d47f8e5ac0a0fbe6f323700e7 fix/report-card-chip-alignment
|
||||
b2dc4d0779ced02709429afaa5985acf4abda794 fix/results-map-ios-fullscreen
|
||||
95a5783da1fc994df76cb97238b55596dce4cd8f fix/runtime-api-proxy
|
||||
8a9ba30cc24e29653a72c03c0e817684b7db7c07 fix/sats-per-level-national
|
||||
536832a524fc4f9ed858f047e00b9a4d9d429c46 fix/school-detail-nan-500
|
||||
fef83b3bf244a9bf3cb4afa75dbc433d8725014f fix/schoolbar-sticky-offset
|
||||
b0c5b6bb57c879477da12c37b15cff950c29ebd3 fix/secondary-anchors-button-affordance
|
||||
261403bcd2b01aa4f26ee212e26302fc0f769bf9 fix/special-note-full-width
|
||||
ea5249a2ea6faf6bfa5a1522644387ba59770ec8 fix/standardise-distance-units
|
||||
e4565e9f158721d4df6b918f2b065de82851f8d9 fix/trends-chart-height
|
||||
3aad5101a842539105022f9059e85c224fc5973a fix/welsh-establishment-leak
|
||||
315f1feede70bdf3101d2fdd305b3d06e037fdac perf/batch-supplementary
|
||||
d2dc78aeb599e16b7ef5019be2b360b08df463bc perf/compare-loading
|
||||
e098ad4bd1130152705788d4773837b7d1e7112e perf/server-client-split
|
||||
|
||||
## Deleted: superseded by PR #110 (content preserved on feat/seo-crawl-hygiene-main)
|
||||
786ec80dd4de4a3cb674a89e35b3b5e461639289 feat/seo-crawl-hygiene
|
||||
a5ac0bcd1b37bc10dcbce88f8601d01bf7b3eaaf feat/england-only-corpus
|
||||
@@ -0,0 +1,739 @@
|
||||
# Info Popover Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the two broken metric-help affordances (the off-viewport `MetricTooltip` bubble and the native-`title` compare `?` help) with one `@floating-ui/react`-backed `InfoPopover` that stays on-screen in any viewport and shows a custom (non-native) tooltip on desktop.
|
||||
|
||||
**Architecture:** One shared engine (`InfoPopover`) owns all Floating UI positioning and interactions. Two existing components become thin adapters over it: `MetricTooltip` keeps its public API (so ~24 detail-page call sites are untouched), and `RowLabel` in `sectionShared.tsx` swaps its native-`title` span for `InfoPopover` (so ~14 compare `tip=` call sites are untouched).
|
||||
|
||||
**Tech Stack:** Next.js 16, React 19, TypeScript, `@floating-ui/react`, CSS Modules, Jest + jsdom + Testing Library, Playwright (e2e).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All work happens in `nextjs-app/` unless a path says otherwise. Commands below assume the working directory is `nextjs-app/`.
|
||||
- Path alias: `@/` → `nextjs-app/` root (jest `moduleNameMapper` and tsconfig).
|
||||
- Jest test locations: files under `__tests__/**` or named `*.test.tsx`. Env is `jest-environment-jsdom`.
|
||||
- Trigger glyph is the circled **?** everywhere (replaces ⓘ on detail pages).
|
||||
- Trigger is a real `<button>` with a min 24px tap target (WCAG 2.5.8) and an accessible name.
|
||||
- `InfoPopover` renders `null` when it has no `plain` content (preserves current `MetricTooltip` behaviour).
|
||||
- Positioning/interaction is delegated to Floating UI — do not hand-roll positioning, outside-click, or Escape handling.
|
||||
- Run `npx tsc --noEmit` and `npx jest` green before every commit that changes code.
|
||||
- Never push to `main`; this plan's branch is `feat/info-popover-tooltip`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the `@floating-ui/react` dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/package.json` (dependencies)
|
||||
- Modify: `nextjs-app/package-lock.json` (generated)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `@floating-ui/react` importable — the hooks `useFloating`, `autoUpdate`, `offset`, `flip`, `shift`, `arrow`, `useHover`, `useFocus`, `useClick`, `useDismiss`, `useRole`, `useInteractions`, and the component `FloatingPortal`, `FloatingArrow`.
|
||||
|
||||
- [ ] **Step 1: Install the package**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm install @floating-ui/react@^0.27.0
|
||||
```
|
||||
Expected: `package.json` gains `"@floating-ui/react": "^0.27.0"` under `dependencies`; `package-lock.json` updates; exit 0.
|
||||
|
||||
- [ ] **Step 2: Verify it resolves**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "const f=require('@floating-ui/react'); console.log(typeof f.useFloating, typeof f.FloatingPortal, typeof f.useInteractions)"
|
||||
```
|
||||
Expected: `function function function`
|
||||
|
||||
- [ ] **Step 3: Typecheck still passes**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no output (exit 0).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json
|
||||
git commit -m "build(deps): add @floating-ui/react for the info popover"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Build `InfoPopover` (the shared engine) with unit tests
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/InfoPopover.tsx`
|
||||
- Create: `nextjs-app/components/InfoPopover.module.css`
|
||||
- Test: `nextjs-app/__tests__/components/InfoPopover.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `@floating-ui/react` (Task 1).
|
||||
- Produces:
|
||||
```ts
|
||||
export interface InfoPopoverProps {
|
||||
label?: string; // bold heading
|
||||
plain?: string; // body text (primary explanation)
|
||||
detail?: string; // muted supplementary line
|
||||
ariaLabel?: string; // accessible name for the trigger button
|
||||
}
|
||||
export function InfoPopover(props: InfoPopoverProps): JSX.Element | null;
|
||||
```
|
||||
Behaviour: renders `null` if `plain` is falsy. Otherwise renders a `<button>`
|
||||
(the circled `?`) with `aria-label={ariaLabel ?? 'More information'}` and
|
||||
`aria-expanded`. When open, a portalled `role="tooltip"` container shows
|
||||
`label` (if any), `plain`, and `detail` (if any). Opens on hover (100ms
|
||||
open / 0ms close) and focus on desktop, on click for touch; closes on
|
||||
outside-press and Escape.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `nextjs-app/__tests__/components/InfoPopover.test.tsx`:
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
|
||||
describe('InfoPopover', () => {
|
||||
it('renders nothing when there is no plain content', () => {
|
||||
const { container } = render(<InfoPopover label="X" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders a labelled, collapsed trigger button', () => {
|
||||
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
|
||||
const btn = screen.getByRole('button', { name: 'Reading score' });
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on click and shows label, plain and detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InfoPopover
|
||||
label="Reading, Writing & Maths"
|
||||
plain="% reaching the expected standard"
|
||||
detail="National average ~60%"
|
||||
ariaLabel="RWM"
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'RWM' }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent('Reading, Writing & Maths');
|
||||
expect(tip).toHaveTextContent('% reaching the expected standard');
|
||||
expect(tip).toHaveTextContent('National average ~60%');
|
||||
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('closes again on a second click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
const btn = screen.getByRole('button', { name: 'Info' });
|
||||
await user.click(btn);
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
await user.click(screen.getByRole('button', { name: 'Info' }));
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults the accessible name when no ariaLabel is given', () => {
|
||||
render(<InfoPopover plain="body" />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'More information' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/InfoPopover.test.tsx
|
||||
```
|
||||
Expected: FAIL — `Cannot find module '@/components/InfoPopover'`.
|
||||
|
||||
- [ ] **Step 3: Write the component**
|
||||
|
||||
Create `nextjs-app/components/InfoPopover.tsx`:
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
arrow,
|
||||
useHover,
|
||||
useFocus,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
FloatingPortal,
|
||||
FloatingArrow,
|
||||
} from '@floating-ui/react';
|
||||
import styles from './InfoPopover.module.css';
|
||||
|
||||
export interface InfoPopoverProps {
|
||||
label?: string;
|
||||
plain?: string;
|
||||
detail?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: 'top',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
arrow({ element: arrowRef, padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
|
||||
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
|
||||
// so tap and hover never double-fire.
|
||||
const hover = useHover(context, { delay: { open: 100, close: 0 } });
|
||||
const focus = useFocus(context);
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: 'tooltip' });
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
focus,
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
]);
|
||||
|
||||
if (!plain) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={refs.setReference}
|
||||
className={styles.icon}
|
||||
aria-label={ariaLabel ?? 'More information'}
|
||||
aria-expanded={open}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={styles.tooltip}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
<span className={styles.plain}>{plain}</span>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the styles**
|
||||
|
||||
Create `nextjs-app/components/InfoPopover.module.css`:
|
||||
```css
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||
below carries the visible circled "?" at its own explicit size. */
|
||||
font-size: 0;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
/* The visible affordance: a small circled "?" centred in the 24px target. */
|
||||
.icon::before {
|
||||
content: '?';
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.icon:hover,
|
||||
.icon[aria-expanded='true'],
|
||||
.icon:focus-visible {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary, #faf7f2);
|
||||
stroke: var(--border-color, #e8ddd4);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/InfoPopover.test.tsx
|
||||
```
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 6: Typecheck**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no output (exit 0).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add components/InfoPopover.tsx components/InfoPopover.module.css __tests__/components/InfoPopover.test.tsx
|
||||
git commit -m "feat(ui): add InfoPopover — viewport-aware metric help via Floating UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Reduce `MetricTooltip` to a thin adapter over `InfoPopover`
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/components/MetricTooltip.tsx` (full rewrite of body)
|
||||
- Delete: `nextjs-app/components/MetricTooltip.module.css`
|
||||
- Test: `nextjs-app/__tests__/components/MetricTooltip.test.tsx` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `InfoPopover` (Task 2), `METRIC_EXPLANATIONS` from `@/lib/metrics`.
|
||||
- Produces: `MetricTooltip` with unchanged public props
|
||||
`{ metricKey?: string; label?: string; plain?: string; detail?: string }`.
|
||||
Resolves `metricKey` → `METRIC_EXPLANATIONS[metricKey]`, with explicit
|
||||
`label`/`plain`/`detail` props overriding the looked-up values. Passes the
|
||||
resolved label as `InfoPopover`'s `ariaLabel`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `nextjs-app/__tests__/components/MetricTooltip.test.tsx`:
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MetricTooltip } from '@/components/MetricTooltip';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
|
||||
describe('MetricTooltip', () => {
|
||||
it('resolves content from a metricKey', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const exp = METRIC_EXPLANATIONS[key];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} />);
|
||||
await user.click(screen.getByRole('button', { name: exp.label }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent(exp.plain);
|
||||
});
|
||||
|
||||
it('renders nothing for an unknown metricKey with no explicit content', () => {
|
||||
const { container } = render(<MetricTooltip metricKey="__nope__" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('lets explicit props override the looked-up explanation', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} plain="custom text" />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/MetricTooltip.test.tsx
|
||||
```
|
||||
Expected: FAIL — the old `MetricTooltip` renders a `.tooltip` span even when
|
||||
collapsed (no `role="tooltip"` gating on open) / different structure, so
|
||||
`findByRole('tooltip')` after click and the empty-render assertion diverge from
|
||||
the new contract. (If any assertion happens to pass against the old component,
|
||||
it still must fail overall before Step 3.)
|
||||
|
||||
- [ ] **Step 3: Rewrite the component**
|
||||
|
||||
Replace the entire contents of `nextjs-app/components/MetricTooltip.tsx` with:
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
import { InfoPopover } from './InfoPopover';
|
||||
|
||||
interface MetricTooltipProps {
|
||||
metricKey?: string;
|
||||
label?: string;
|
||||
plain?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||
const resolvedLabel = label ?? explanation?.label;
|
||||
return (
|
||||
<InfoPopover
|
||||
label={resolvedLabel}
|
||||
plain={plain ?? explanation?.plain}
|
||||
detail={detail ?? explanation?.detail}
|
||||
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete the now-unused stylesheet**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rm components/MetricTooltip.module.css
|
||||
```
|
||||
Expected: file staged for deletion. (The old component was its only importer;
|
||||
`InfoPopover.module.css` supersedes it.)
|
||||
|
||||
- [ ] **Step 5: Verify nothing else imports the deleted CSS**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "MetricTooltip.module.css" components app lib || echo "no importers"
|
||||
```
|
||||
Expected: `no importers`.
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/MetricTooltip.test.tsx
|
||||
```
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 7: Typecheck**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no output (exit 0).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add components/MetricTooltip.tsx __tests__/components/MetricTooltip.test.tsx
|
||||
git commit -m "refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Swap the compare `?` help (`RowLabel`) to `InfoPopover`
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/components/compare/sectionShared.tsx` (`RowLabel`)
|
||||
- Modify: `nextjs-app/components/compare/compareSections.module.css` (remove `.help`)
|
||||
- Test: `nextjs-app/__tests__/components/sectionShared.test.tsx` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `InfoPopover` (Task 2).
|
||||
- Produces: `RowLabel({ children, tip })` renders the label text plus, when
|
||||
`tip` is set, an `InfoPopover` with `plain={tip}`. `ariaLabel` is omitted, so
|
||||
the trigger uses `InfoPopover`'s default accessible name `"More information"`
|
||||
(the row label is arbitrary `ReactNode`, so there is no clean string to derive
|
||||
a per-row name from). `Measure` and all compare `tip=` call sites are
|
||||
unchanged.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `nextjs-app/__tests__/components/sectionShared.test.tsx`:
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RowLabel } from '@/components/compare/sectionShared';
|
||||
|
||||
describe('RowLabel', () => {
|
||||
it('renders its label text', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no help affordance when no tip is given', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the tip in a popover on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'Average GCSE score across 8 subjects',
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/sectionShared.test.tsx
|
||||
```
|
||||
Expected: FAIL — the current `RowLabel` renders a `<span title=...>` (not a
|
||||
`button`/`role="tooltip"`), so the click test fails.
|
||||
|
||||
- [ ] **Step 3: Update `RowLabel`**
|
||||
|
||||
In `nextjs-app/components/compare/sectionShared.tsx`, add the import near the
|
||||
other imports:
|
||||
```tsx
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
```
|
||||
Then replace the `RowLabel` function:
|
||||
```tsx
|
||||
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
|
||||
return (
|
||||
<div className={styles.rowLabel}>
|
||||
{children}
|
||||
{tip && <InfoPopover plain={tip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the dead `.help` style**
|
||||
|
||||
In `nextjs-app/components/compare/compareSections.module.css`, delete the entire
|
||||
`.help { … }` rule (the `display: inline-flex; width: 15px; … flex: none;`
|
||||
block — the circled-`?` styling now lives in `InfoPopover.module.css`).
|
||||
|
||||
- [ ] **Step 5: Confirm `.help` is unreferenced**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "styles.help\|\.help\b" components/compare || echo "no references"
|
||||
```
|
||||
Expected: `no references`.
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx jest __tests__/components/sectionShared.test.tsx
|
||||
```
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 7: Full unit suite + typecheck**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx tsc --noEmit && npx jest
|
||||
```
|
||||
Expected: typecheck clean; all suites pass (existing + the 3 new suites).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add components/compare/sectionShared.tsx components/compare/compareSections.module.css __tests__/components/sectionShared.test.tsx
|
||||
git commit -m "refactor(compare): row-label help uses InfoPopover, not native title"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: E2E regression guard — popover stays within the mobile viewport
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/../e2e/tests/journeys.spec.ts` (add one test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running app (staging/local baseURL), the `twoPrimaryUrns`
|
||||
helper already defined in `journeys.spec.ts`.
|
||||
- Produces: a Playwright test asserting an opened compare help popover's
|
||||
bounding box is fully within the viewport on a narrow screen.
|
||||
|
||||
- [ ] **Step 1: Add the failing-guard test**
|
||||
|
||||
Append to `nextjs-app/../e2e/tests/journeys.spec.ts` (i.e. `e2e/tests/journeys.spec.ts`):
|
||||
```ts
|
||||
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The metric-help triggers are the circled-"?" buttons in the row labels.
|
||||
// "More information" is InfoPopover's default accessible name.
|
||||
const help = page.getByRole('button', { name: 'More information' }).first();
|
||||
await expect(help).toBeVisible({ timeout: 15_000 });
|
||||
await help.click();
|
||||
|
||||
const tip = page.getByRole('tooltip');
|
||||
await expect(tip).toBeVisible();
|
||||
|
||||
// The whole bubble must sit inside the viewport — the original bug pushed it
|
||||
// off the right edge with no way to scroll to it.
|
||||
const box = await tip.boundingBox();
|
||||
const width = page.viewportSize()!.width;
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
|
||||
// And the page must not have gained a horizontal scrollbar from the bubble.
|
||||
const bodyOverflowsX = await page
|
||||
.locator('body')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
|
||||
expect(bodyOverflowsX).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lint/typecheck the e2e file**
|
||||
|
||||
Run (from `e2e/`):
|
||||
```bash
|
||||
cd ../e2e && npx tsc --noEmit -p . 2>/dev/null || npx tsc --noEmit journeys 2>/dev/null; cd ../nextjs-app
|
||||
```
|
||||
Expected: no type errors reported for `journeys.spec.ts`. (If the e2e package
|
||||
has no standalone tsconfig, this is a no-op; the CI Playwright run type-checks
|
||||
on execution.)
|
||||
|
||||
- [ ] **Step 3: Note on running e2e**
|
||||
|
||||
The e2e journeys run against a deployed environment (staging) in CI, per
|
||||
`CLAUDE.md`; they are not run locally here (no local server). This test will
|
||||
execute in the staging gate after merge. Do not attempt to start a local server.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ../e2e/tests/journeys.spec.ts
|
||||
git commit -m "test(e2e): compare help popover stays within the mobile viewport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final verification and PR
|
||||
|
||||
**Files:** none (verification + PR).
|
||||
|
||||
- [ ] **Step 1: Full typecheck + unit suite**
|
||||
|
||||
Run (from `nextjs-app/`):
|
||||
```bash
|
||||
npx tsc --noEmit && npx jest 2>&1 | tail -8
|
||||
```
|
||||
Expected: typecheck clean; all suites pass.
|
||||
|
||||
- [ ] **Step 2: Production build sanity (catches client/server boundary issues)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx next build 2>&1 | tail -20
|
||||
```
|
||||
Expected: build completes without errors. (`InfoPopover` is a client
|
||||
component — `'use client'` — so this confirms the portal usage compiles.)
|
||||
|
||||
- [ ] **Step 3: Confirm no stragglers reference removed APIs**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "MetricTooltip.module.css\|styles.help\|title={tip}" components app || echo "clean"
|
||||
```
|
||||
Expected: `clean`.
|
||||
|
||||
- [ ] **Step 4: Push and open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/info-popover-tooltip
|
||||
```
|
||||
Then open a Gitea PR (base `main`, head `feat/info-popover-tooltip`) via the
|
||||
credential-helper + API pattern used in this repo, summarising: the two bugs
|
||||
fixed (off-viewport mobile bubble; slow native-`title` desktop hover), the
|
||||
unified `InfoPopover` approach, the circled-`?` standardisation, the new
|
||||
`@floating-ui/react` dependency, and the mobile-viewport e2e guard.
|
||||
```
|
||||
@@ -0,0 +1,950 @@
|
||||
# Detail-page Server/Client Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop ~750 lines of static school-detail markup from shipping to the browser as JavaScript, by turning the page's sections into React Server Components behind a small client shell.
|
||||
|
||||
**Architecture:** A server component imported by a client component becomes a client component. So the sections cannot be children of a client `SchoolDetailView`. Instead `app/school/[slug]/page.tsx` (server) composes the sections and passes them *through* a small client shell as `children` — the pattern already used at `app/page.tsx:98-99`. The shell keeps the genuinely interactive chrome (back link, header reveal, hero map, compare CTA, sticky nav, scroll-spy); everything below it becomes server-rendered.
|
||||
|
||||
**Tech Stack:** Next.js 16.1.6 (App Router, Turbopack), React 19, TypeScript, CSS Modules, Jest + React Testing Library, Playwright (e2e).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-30-server-client-split-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch is `perf/server-client-split`, already created off `origin/main` at `cab7b4f`. Never push to `main`; this lands as a PR (CLAUDE.md SDLC).
|
||||
- All paths below are relative to the repo root. The Next.js app lives in `nextjs-app/`; run all `npm` commands from there.
|
||||
- **Do not start a local server to test** — it does not work in this environment (CLAUDE.md). Verification is `npm run typecheck`, `npm test`, `npm run build`.
|
||||
- **No visual or behavioural change** on any of the four school shapes, except the two documented CSS merges. No redesign, no copy changes.
|
||||
- Commits 6 and 7 are **pure moves**: JSX is relocated verbatim, changing only the `styles` import and prop plumbing. If you find a bug mid-move, record it in the PR description — do not fix it inline.
|
||||
- Characterization tests written in Task 2 must pass **unmodified** through Tasks 3-8. The only file permitted to change is `__tests__/support/renderSchoolDetail.tsx`.
|
||||
- End commit messages with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## What is shared and what is not
|
||||
|
||||
Measured similarity between the two views' sections (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary lines | Secondary lines | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Ofsted | 88 | 107 | 80% | **Shared** — `OfstedSection` |
|
||||
| Finances | 29 | 35 | 91% | **Shared** — `FinancesSection` |
|
||||
| History | 121 | 47 | 40% | Separate components |
|
||||
| Admissions | 89 | 55 | 14% | Separate components |
|
||||
|
||||
The spec's section-sharing table listed Admissions and History as shared; measurement shows they are not. **This costs no bytes** — the client-JS win comes from a section being a *server* component, not from being shared. Sharing is a maintainability bonus, taken only where it is cheap.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create — `nextjs-app/lib/`**
|
||||
- `schoolSections.ts` — pure derived flags (`hasInclusionData`, `hasKS2Results`, `suppressKs2Comparison`, …) and `buildNavItems()`. No JSX, no React. Unit-testable in isolation; consumed by `page.tsx` and the sections.
|
||||
|
||||
**Create — `nextjs-app/components/school/`** (mirrors the existing `components/compare/` layout)
|
||||
- `schoolSections.module.css` — merged stylesheet (Task 5)
|
||||
- `sectionShared.tsx` — `Section`, `MetricGrid`, `MetricCard`, `StatRow` primitives, all server
|
||||
- `OfstedSection.tsx`, `FinancesSection.tsx` — shared, server
|
||||
- `ResultsSection.tsx`, `AdmissionsSection.tsx`, `InclusionSection.tsx`, `HistorySection.tsx`, `SchoolLifeSection.tsx`, `LocalAreaSection.tsx` — primary, server
|
||||
- `GcseSection.tsx`, `SecondaryAdmissionsSection.tsx`, `SecondaryHistorySection.tsx`, `WellbeingSection.tsx` — secondary, server
|
||||
- `AdmissionsViewToggle.tsx` — **the only new client component**
|
||||
- `PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` — server composers
|
||||
- `SchoolDetailShell.tsx` + `SchoolDetailShell.module.css` — the client shell
|
||||
|
||||
**Create — `nextjs-app/__tests__/`**
|
||||
- `support/schoolFixtures.ts` — four fixtures: primary, secondary, all-through, special
|
||||
- `support/renderSchoolDetail.tsx` — the swappable render helper
|
||||
- `components/schoolDetail.characterization.test.tsx`
|
||||
- `lib/schoolSections.test.ts`
|
||||
|
||||
**Modify**
|
||||
- `nextjs-app/jest.setup.js` — `IntersectionObserver`, `window.scrollTo`
|
||||
- `nextjs-app/app/school/[slug]/page.tsx` — fetch national averages, compose sections
|
||||
- `nextjs-app/package.json` — drop `swr`
|
||||
- `e2e/tests/journeys.spec.ts` — detail-page assertions
|
||||
|
||||
**Deliberately untouched**
|
||||
- `components/Navigation.tsx` — genuinely needs client (`usePathname` plus the comparison count). No task.
|
||||
- `components/Footer.tsx` — already a server component; no `'use client'` directive. No task.
|
||||
- `app/layout.tsx` — unchanged. The shared ~172 KB baseline is out of scope; see Task 9 Step 4.
|
||||
|
||||
**Delete**
|
||||
- `nextjs-app/components/SchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/components/SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Test environment and fixtures
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/jest.setup.js`
|
||||
- Create: `nextjs-app/__tests__/support/schoolFixtures.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `primaryFixture`, `secondaryFixture`, `allThroughFixture`, `specialFixture` — each an object matching the prop shape of `SchoolDetailView` (`{ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance }`), plus `nationalAveragesFixture` typed `NationalAverages`.
|
||||
|
||||
- [ ] **Step 1: Add the missing jsdom globals**
|
||||
|
||||
jsdom implements neither API; both detail views use `IntersectionObserver` twice and `window.scrollTo` once. Append to `nextjs-app/jest.setup.js`:
|
||||
|
||||
```javascript
|
||||
// jsdom implements neither of these; the detail views use both.
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
};
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the fixtures**
|
||||
|
||||
Create `nextjs-app/__tests__/support/schoolFixtures.ts`. Derive the exact field names from `nextjs-app/lib/types.ts` (`School`, `SchoolResult`, `OfstedInspection`, `SchoolCensus`, `SchoolAdmissions`, `SchoolDeprivation`, `SchoolFinance`, `NationalAverages`) — do not invent fields; TypeScript will reject them.
|
||||
|
||||
The four fixtures must differ in exactly these ways, because these are the branches the characterization tests exercise:
|
||||
|
||||
| Fixture | `schoolInfo.phase` | Results present | Ofsted | Admissions history | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `primaryFixture` | `'Primary'` | `rwm_expected_pct: 58` | legacy OEIF, `overall_effectiveness: 2` | 3 years with `first_preference_offer_pct` | drives the trend toggle |
|
||||
| `secondaryFixture` | `'Secondary'` | `attainment_8_score: 46.2`, `progress_8_score: 0.31` | Report Card (`rc_*` fields set) | 1 year only | no trend toggle |
|
||||
| `allThroughFixture` | `'All-through'` | **both** `rwm_expected_pct` and `attainment_8_score` | legacy OEIF | 2 years | KS2 content gated back on |
|
||||
| `specialFixture` | `'Primary'`, `school_type: 'Community special school'` | `rwm_expected_pct: 0`, reading/writing/maths all `0` | none (`null`) | none (`null`) | England comparison suppressed |
|
||||
|
||||
Every fixture needs `urn` in the valid 6-digit range, a `school_name`, and a `local_authority`. Give `primaryFixture` at least 3 entries in `yearlyData` so the history table and chart have data.
|
||||
|
||||
- [ ] **Step 3: Verify the fixtures typecheck**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. Any error here means a fixture field does not exist on the real type — fix the fixture, never the type.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/jest.setup.js nextjs-app/__tests__/support/schoolFixtures.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: add jsdom globals and school detail fixtures
|
||||
|
||||
jsdom provides neither IntersectionObserver nor scrollTo; both detail views
|
||||
use them. Adds four fixtures (primary, secondary, all-through, special)
|
||||
covering the branches the characterization tests will pin.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Characterization tests
|
||||
|
||||
This is the safety net. **It must be green before any production code moves.**
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Create: `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: fixtures from Task 1.
|
||||
- Produces: `renderSchoolDetail(fixture, opts?)` and `renderSecondarySchoolDetail(fixture, opts?)`. These are the **only** functions the tests call to render. Task 7 rewrites their bodies; their signatures and every assertion stay frozen.
|
||||
|
||||
- [ ] **Step 1: Write the render helper against the CURRENT components**
|
||||
|
||||
Create `nextjs-app/__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* Task 7 rewrites the bodies of these two functions to render the new
|
||||
* shell + server-sections composition. Nothing else in the test suite may
|
||||
* change — assertions passing unmodified across that rewrite is the proof
|
||||
* that behaviour was preserved.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} />);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
return render(<SecondarySchoolDetailView {...fixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the characterization tests**
|
||||
|
||||
Create `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`. Mock the charts and analytics at the top — Chart.js needs a canvas jsdom does not provide, and `track()` fires on mount:
|
||||
|
||||
```tsx
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
}));
|
||||
```
|
||||
|
||||
Then cover, using `renderSchoolDetail` / `renderSecondarySchoolDetail` only:
|
||||
|
||||
1. **Ofsted, legacy OEIF** (`primaryFixture`) — the grade label `Good` renders; the inspected date renders; the `reports.ofsted.gov.uk` link carries the fixture's URN in its `href`.
|
||||
2. **Ofsted, Report Card** (`secondaryFixture`) — heading reads `Ofsted Report Card`; the November 2025 disclaimer renders; each populated `rc_*` category renders its label and band.
|
||||
3. **KS2 results + England delta** (`primaryFixture`) — the RWM percentage renders; a delta versus the England average renders.
|
||||
4. **KS4 results** (`secondaryFixture`) — Attainment 8 and Progress 8 values render; EBacc rows render.
|
||||
5. **All-through** (`allThroughFixture`) — **both** a KS2 figure and an Attainment 8 figure render in the same document. This is the highest-value assertion in the file; it is the case most likely to break.
|
||||
6. **Special school** (`specialFixture`) — no England-comparison delta renders, and the special-school note renders. Guards the PR #70 regression.
|
||||
7. **Admissions toggle** (`primaryFixture`) — both buttons render; the trend view starts `hidden`; after `userEvent.click` on the trend button the trend view is visible and the year view is `hidden`. Assert on the `hidden` attribute, matching how the current code toggles.
|
||||
8. **Admissions without trend** (`secondaryFixture`, 1 year) — no toggle buttons render.
|
||||
9. **Nav items** (`primaryFixture`) — the expected section ids are present in the document; and for `specialFixture` (no ofsted, no admissions) those two are absent.
|
||||
|
||||
Use `getByText` / `queryByText` / `getByRole` — never snapshots, and never assert on CSS-module class names, which are hashed and will change.
|
||||
|
||||
- [ ] **Step 3: Run the tests and confirm they PASS**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS.** This is the inverse of normal TDD — these tests describe behaviour that already exists, so green means they correctly pin the current behaviour. If one fails, the test is wrong, not the component. Fix the test.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/__tests__/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: pin school detail behaviour before refactor
|
||||
|
||||
Characterization tests covering Ofsted (both layouts), KS2/KS4 results,
|
||||
all-through dual rendering, special-school comparison suppression, and the
|
||||
admissions toggle. All rendering goes through renderSchoolDetail(), the one
|
||||
seam that the server/client split is allowed to change.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move national averages to the server
|
||||
|
||||
The prerequisite for everything after it: `primaryAvg` / `secondaryAvg` feed the England deltas across most sections, and while they arrive via `useEffect` those sections cannot be server components.
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/components/SchoolDetailView.tsx:173-179`
|
||||
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:91-93`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `fetchNationalAverages()` from `@/lib/api` (already exists, `revalidate: 3600`).
|
||||
- Produces: both view components gain a required prop `nationalAvg: NationalAverages | null`.
|
||||
|
||||
- [ ] **Step 1: Fetch it in parallel in the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the lone `fetchSchoolDetails` call in the default export with a parallel pair. Import `fetchNationalAverages` alongside the existing imports. National averages must never break the page, so it degrades to `null`:
|
||||
|
||||
```tsx
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
```
|
||||
|
||||
Leave `generateMetadata`'s own `fetchSchoolDetails(urn)` call alone — Next.js memoises identical `fetch` calls within a render pass, so it does not cost a second request.
|
||||
|
||||
- [ ] **Step 2: Pass it to both views**
|
||||
|
||||
Add `nationalAvg={nationalAvg}` to both the `<SecondarySchoolDetailView>` and `<SchoolDetailView>` elements at the bottom of the page.
|
||||
|
||||
- [ ] **Step 3: Accept the prop and delete the useEffect**
|
||||
|
||||
In `components/SchoolDetailView.tsx`: add `nationalAvg: NationalAverages | null;` to `SchoolDetailViewProps`, add `nationalAvg` to the destructured parameters, and delete lines 172-179 — the `useState` and the `useEffect` that fetches `/api/national-averages`. The two lines that follow it stay exactly as they are:
|
||||
|
||||
```tsx
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
```
|
||||
|
||||
Apply the identical change to `components/SecondarySchoolDetailView.tsx` at lines 91-93.
|
||||
|
||||
- [ ] **Step 4: Update the render helper to supply the prop**
|
||||
|
||||
In `__tests__/support/renderSchoolDetail.tsx`, spread `nationalAveragesFixture` in:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
This is a permitted change — the helper is the designated seam. The test file itself must not change.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test -- schoolDetail.characterization`
|
||||
Expected: both PASS, with the characterization test file unmodified.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/app/school nextjs-app/components/SchoolDetailView.tsx \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/__tests__/support/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): fetch national averages on the server
|
||||
|
||||
Both detail views fetched /api/national-averages in a useEffect, so the
|
||||
England-comparison deltas popped in after hydration and the sections that
|
||||
use them could not become server components. The page now fetches it in
|
||||
parallel with the school details (backend-cached 1h) and passes it down.
|
||||
|
||||
Removes one client round-trip per detail page.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract the derived-flags module
|
||||
|
||||
Pulls the pure data-shape logic out of the components so `page.tsx` can compute `navItems` on the server without importing a client component.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/lib/schoolSections.ts`
|
||||
- Create: `nextjs-app/__tests__/lib/schoolSections.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: types from `@/lib/types`; `isSpecialSchool` from `@/lib/utils`.
|
||||
- Produces:
|
||||
- `type SchoolFlags = { isAllThrough: boolean; isSecondary: boolean; isPrimary: boolean; latestResults: SchoolResult | null; hasGenderSplit: boolean; hasInclusionData: boolean; hasSchoolLife: boolean; hasDeprivation: boolean; hasFinance: boolean; hasLocation: boolean; hasKS2Results: boolean; hasKS4Results: boolean; hasAnyResults: boolean; isSpecial: boolean; ks2Placeholder: boolean; suppressKs2Comparison: boolean; suppressKs4Comparison: boolean }`
|
||||
- `computeSchoolFlags(input): SchoolFlags` where `input` is `{ schoolInfo, yearlyData, absenceData, census, deprivation, finance }`
|
||||
- `type NavItem = { id: string; label: string }`
|
||||
- `buildNavItems(flags: SchoolFlags, opts: { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; yearlyDataLength: number }): NavItem[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `nextjs-app/__tests__/lib/schoolSections.test.ts` covering the branches that matter:
|
||||
|
||||
```ts
|
||||
import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections';
|
||||
import { primaryFixture, allThroughFixture, specialFixture } from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const primary = computeSchoolFlags(primaryFixture);
|
||||
const items = buildNavItems(primary, {
|
||||
ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
});
|
||||
expect(items.find((n) => n.id === 'results')?.label).toBe('SATs');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: FAIL — `Cannot find module '@/lib/schoolSections'`.
|
||||
|
||||
- [ ] **Step 3: Implement the module**
|
||||
|
||||
Create `nextjs-app/lib/schoolSections.ts` by moving the logic verbatim from `components/SchoolDetailView.tsx` lines **211-267**. Lines 205-209 are the `deprivationDesc` helper — leave it where it is; Task 6 moves it into `LocalAreaSection`. Also bring `latestResults` (line 160). Preserve the comments — they explain the special-school and all-through reasoning and are load-bearing for future readers. The `navItems` push order must stay exactly as it is at lines 258-267; it is engagement-ordered from analytics.
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Consume it from both views**
|
||||
|
||||
Replace the inline flag computations in `SchoolDetailView.tsx` and `SecondarySchoolDetailView.tsx` with a call to `computeSchoolFlags` / `buildNavItems`, destructuring the flags so the JSX below is untouched.
|
||||
|
||||
- [ ] **Step 6: Verify nothing regressed**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test`
|
||||
Expected: all PASS, characterization tests unmodified.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.test.ts \
|
||||
nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract derived flags and nav items to lib
|
||||
|
||||
Pure data-shape logic moves out of the client components so page.tsx can
|
||||
compute the section list on the server. Adds direct unit coverage for the
|
||||
all-through and special-school branches, which were previously only
|
||||
reachable through a full component render.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Merge the stylesheets
|
||||
|
||||
Isolated so that a visual regression bisects to the CSS merge rather than to a JSX move. Highest-risk task in the plan; no test catches a mistake here.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/schoolSections.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: a stylesheet exporting every class name used by section markup in either view, plus `genderBarSecondary` and `heroStatValueSecondary` variant classes.
|
||||
|
||||
- [ ] **Step 1: Copy the primary stylesheet as the base**
|
||||
|
||||
```bash
|
||||
mkdir -p nextjs-app/components/school
|
||||
cp nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/school/schoolSections.module.css
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the secondary-only classes**
|
||||
|
||||
33 class names exist only in `SecondarySchoolDetailView.module.css`. Append their rules unchanged. Confirm the list:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
c=lambda p: set(re.findall(r'\.([a-zA-Z][\w-]*)', open(p).read()))
|
||||
a=c('components/SchoolDetailView.module.css'); b=c('components/SecondarySchoolDetailView.module.css')
|
||||
print('\n'.join(sorted(b-a)))
|
||||
PY
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Union the 14 incidental-drift differences**
|
||||
|
||||
For these classes, add any property present in the secondary rule but missing from the primary rule. All are defensive overflow properties — `min-width: 0`, `overflow-wrap: break-word`, `word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. Never remove a property the primary rule already has.
|
||||
|
||||
Affected: `.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, `.metricsGrid`, `.tableWrapper`, `.heroStatCard`, `.heroStatLabel`, `.historyToggle`, `.ofstedReportLink`, `.genderSplitBoys`, `.genderSplitGirls`, `.genderSplitSep`, and the remaining drift class from the audit in Step 5.
|
||||
|
||||
- [ ] **Step 4: Add variant classes for the two real differences**
|
||||
|
||||
Keep the primary look on the base class and add the secondary look as a separate class:
|
||||
|
||||
```css
|
||||
/* Secondary detail pages render a slimmer gender bar on a translucent track.
|
||||
Kept as an explicit variant rather than reconciled, so the divergence is
|
||||
deliberate instead of accidental drift. */
|
||||
.genderBarSecondary {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* Secondary hero stats sit tighter and omit the Georgia fallback. */
|
||||
.heroStatValueSecondary {
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Audit the merge**
|
||||
|
||||
Every shared class must now be a superset of both originals, except the two variants:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
def rules(p):
|
||||
out={}
|
||||
for m in re.finditer(r'\.([a-zA-Z][\w-]*)\s*\{([^{}]*)\}', open(p).read()):
|
||||
out.setdefault(m.group(1),[]).append({x.strip() for x in
|
||||
re.sub(r'\s+',' ',m.group(2)).strip().rstrip(';').split(';') if x.strip()})
|
||||
return out
|
||||
m=rules('components/school/schoolSections.module.css')
|
||||
for src in ['components/SchoolDetailView.module.css','components/SecondarySchoolDetailView.module.css']:
|
||||
for name, bodies in rules(src).items():
|
||||
if name in ('genderBar','heroStatValue'): continue
|
||||
if name not in m: print(f"MISSING .{name} (from {src})"); continue
|
||||
missing = bodies[0] - m[name][0]
|
||||
if missing: print(f"LOST .{name}: {missing} (from {src})")
|
||||
print("audit complete")
|
||||
PY
|
||||
```
|
||||
Expected: no `MISSING` or `LOST` lines before `audit complete`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/schoolSections.module.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
style(detail): merge the two detail stylesheets
|
||||
|
||||
Unions the 14 incidental-drift differences between the primary and secondary
|
||||
modules -- defensive overflow properties (min-width, overflow-wrap,
|
||||
max-width) that were fixed on one page and never back-ported. Keeps the two
|
||||
genuine visual differences, .genderBar and .heroStatValue, as explicit
|
||||
variant classes.
|
||||
|
||||
No intended visual change. Isolated so a regression bisects here rather than
|
||||
to a JSX move.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Extract the server sections
|
||||
|
||||
The bulk of the work, and a **pure move**. Every section below becomes a server component: no `'use client'`, no hooks, no event handlers.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/sectionShared.tsx`
|
||||
- Create: the 12 section files listed in File Structure
|
||||
- Create: `nextjs-app/components/school/AdmissionsViewToggle.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SchoolFlags` and `NavItem` from `@/lib/schoolSections`; `schoolSections.module.css`.
|
||||
- Produces: each section is a named export taking exactly the props its JSX reads. `AdmissionsViewToggle` is the only `'use client'` file created here, with props `{ title: ReactNode; subtitle: ReactNode; trendLabel: string; yearView: ReactNode; trendView: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Extract the shared primitives**
|
||||
|
||||
Create `sectionShared.tsx` mirroring `components/compare/sectionShared.tsx`. Export server components `Section` (renders `<section id className={styles.card}>`), `SectionTitle`, `MetricGrid`, `MetricCard`. Re-export the stylesheet as `sectionStyles`, matching the compare module's convention.
|
||||
|
||||
- [ ] **Step 2: Move the shared sections**
|
||||
|
||||
These are the only two sections where you are merging two implementations rather than moving one, so the reconciliation rule matters. **Start from the primary version and add only what the secondary version has that the primary lacks.** Diff them first so you are working from the actual delta rather than reading both in full:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && diff <(sed -n '598,687p' components/SchoolDetailView.tsx | sed 's/^[ \t]*//') \
|
||||
<(sed -n '395,502p' components/SecondarySchoolDetailView.tsx | sed 's/^[ \t]*//')
|
||||
```
|
||||
|
||||
For every line the secondary has and the primary does not, decide one of two things and write the decision as a code comment: either it is **phase-specific** (gate it on a prop — e.g. `showSixthForm`), or it is **an improvement the primary never received** (keep it unconditionally). Never delete a branch the primary version has. If a difference is neither — a genuine behavioural conflict — stop and report it rather than guessing.
|
||||
|
||||
- `OfstedSection.tsx` — primary `SchoolDetailView.tsx:598-687`, secondary `SecondarySchoolDetailView.tsx:395-502` (80% similar). Props: `{ ofsted, urn, isReportCard, ofstedInspectedDate, oeifAllSameGrade, oeifAreas }`. Note `RC_CATEGORIES` (lines 44-53) includes `rc_sixth_form`, which only ever populates for secondary — it is already null-guarded, so it needs no prop.
|
||||
- `FinancesSection.tsx` — primary `SchoolDetailView.tsx:1314-1344`, secondary `SecondarySchoolDetailView.tsx:937-973` (91% similar). Same rule; run the equivalent diff.
|
||||
|
||||
- [ ] **Step 3: Move the primary-only sections**
|
||||
|
||||
Verbatim moves, changing only the `styles` import to `schoolSections.module.css` and replacing closed-over variables with props:
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `ResultsSection.tsx` | `SchoolDetailView.tsx:688-959` | `latestResults, flags, primaryAvg, secondaryAvg, schoolInfo` |
|
||||
| `AdmissionsSection.tsx` | `SchoolDetailView.tsx:960-1051` | `admissions, admissionsHistory, admissionsSummary, isAllThrough` |
|
||||
| `InclusionSection.tsx` | `SchoolDetailView.tsx:1052-1135` | `latestResults, census, hasGenderSplit` |
|
||||
| `HistorySection.tsx` | `SchoolDetailView.tsx:1136-1256` | `yearlyData, flags` |
|
||||
| `SchoolLifeSection.tsx` | `SchoolDetailView.tsx:1257-1289` | `absenceData` |
|
||||
| `LocalAreaSection.tsx` | `SchoolDetailView.tsx:1290-1313` | `deprivation` — bring `deprivationDesc` (lines 205-209) with it |
|
||||
|
||||
- [ ] **Step 4: Move the secondary-only sections**
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `GcseSection.tsx` | `SecondarySchoolDetailView.tsx:503-732` | `latestResults, flags, secondaryAvg, schoolInfo` |
|
||||
| `SecondaryAdmissionsSection.tsx` | `SecondarySchoolDetailView.tsx:733-792` | `admissions` |
|
||||
| `SecondaryHistorySection.tsx` | `SecondarySchoolDetailView.tsx:793-839` | `yearlyData, flags` |
|
||||
| `WellbeingSection.tsx` | `SecondarySchoolDetailView.tsx:840-936` | `absenceData, census` — pass `variant="secondary"` to select `genderBarSecondary` |
|
||||
|
||||
- [ ] **Step 5: Build the admissions toggle island**
|
||||
|
||||
The current code renders both views always and toggles the `hidden` attribute, so the island only owns `hidden` and `aria-pressed`. The buttons sit inside `.admissionsHeader` beside the `<h2>` while the viewport is a sibling below, so the component must span both to preserve the DOM exactly.
|
||||
|
||||
Create `nextjs-app/components/school/AdmissionsViewToggle.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
/**
|
||||
* The only interactive part of the admissions section. Both views are always
|
||||
* in the DOM (matching the previous behaviour) and this toggles `hidden`, so
|
||||
* the server-rendered markup passed in as yearView/trendView never ships as
|
||||
* client JavaScript.
|
||||
*/
|
||||
export function AdmissionsViewToggle({
|
||||
title, subtitle, trendLabel, yearView, trendView,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>{yearView}</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>{trendView}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `AdmissionsSection.tsx`, when `showAdmissionsTrend` is false, render the header and year view directly with **no** client component — those schools then ship zero admissions JavaScript.
|
||||
|
||||
- [ ] **Step 6: Verify it compiles**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. The old views still exist and still render; nothing is wired up yet.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract sections as server components
|
||||
|
||||
Moves the section markup out of the two client views into
|
||||
components/school/, mirroring the components/compare/ layout. Sections are
|
||||
server components; the only new client file is AdmissionsViewToggle, which
|
||||
owns the hidden/aria-pressed state and receives both views as server-rendered
|
||||
children.
|
||||
|
||||
Ofsted and Finances are shared between phases (80% and 91% similar);
|
||||
Admissions and History are not (14% and 40%) and stay separate.
|
||||
|
||||
Pure move -- no logic or markup changes. Not yet wired up.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add the shell and wire it up
|
||||
|
||||
Where the bytes actually move.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/SchoolDetailShell.tsx` + `.module.css`
|
||||
- Create: `nextjs-app/components/school/PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx`
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Delete: `SchoolDetailView.tsx` + `.module.css`, `SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 4-6.
|
||||
- Produces: `SchoolDetailShell` — `'use client'`, props `{ schoolInfo: School; navItems: NavItem[]; flags: SchoolFlags; children: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Build the shell**
|
||||
|
||||
Create `SchoolDetailShell.tsx` from `SchoolDetailView.tsx` lines 1-595 — the back link, header, details reveal, hero map, compare CTA, sticky nav, jump sheet — plus the hooks at lines 79-104 and the effects at 120-203 and 270-300. Keep `'use client'`. It renders `{children}` where the sections used to begin (after the nav closes, at what is currently line 595).
|
||||
|
||||
`navItems` becomes a prop instead of being computed inline. The scroll-spy at line 283 uses `document.getElementById(id)` and needs no change — it finds server-rendered sections exactly as before.
|
||||
|
||||
For the shell's stylesheet, do not eyeball which rules are shell-only — determine it mechanically. Every class the shell's JSX references goes into `SchoolDetailShell.module.css`; classes referenced by both the shell and a section stay duplicated in both files (CSS Modules hash them independently, so duplication is correct, not a smell). List the shell's classes with:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -o 'styles\.[a-zA-Z][A-Za-z0-9_]*' components/school/SchoolDetailShell.tsx \
|
||||
| sort -u | sed 's/styles\.//'
|
||||
```
|
||||
|
||||
Copy each of those rules — including every media-query and `:hover`/`:focus` block that mentions them — from `SchoolDetailView.module.css` before deleting it in Step 6.
|
||||
|
||||
- [ ] **Step 2: Build the two composers**
|
||||
|
||||
Server components — no `'use client'`. Each returns the section sequence in the current order, gated by the same flags:
|
||||
|
||||
```tsx
|
||||
export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) {
|
||||
const { ofsted, admissions, /* … */ } = data;
|
||||
return (
|
||||
<>
|
||||
{ofsted && <OfstedSection ofsted={ofsted} urn={data.schoolInfo.urn} /* … */ />}
|
||||
{flags.hasAnyResults && flags.latestResults && <ResultsSection /* … */ />}
|
||||
{admissions && <AdmissionsSection /* … */ />}
|
||||
{flags.hasInclusionData && <InclusionSection /* … */ />}
|
||||
{data.yearlyData.length > 0 && <HistorySection /* … */ />}
|
||||
{flags.hasSchoolLife && <SchoolLifeSection /* … */ />}
|
||||
{flags.hasDeprivation && <LocalAreaSection /* … */ />}
|
||||
{flags.hasFinance && <FinancesSection /* … */ />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The conditions must match `buildNavItems` exactly, or the nav will link to sections that do not exist.
|
||||
|
||||
`SecondarySchoolSections` follows the same shape with `GcseSection`, `SecondaryAdmissionsSection`, `SecondaryHistorySection`, `WellbeingSection`, `FinancesSection`.
|
||||
|
||||
- [ ] **Step 3: Rewire the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the `isSecondary ? <SecondarySchoolDetailView/> : <SchoolDetailView/>` block. Compute flags and nav items on the server, then wrap:
|
||||
|
||||
```tsx
|
||||
const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance });
|
||||
const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length });
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<SchoolDetailShell schoolInfo={school_info} navItems={navItems} flags={flags}>
|
||||
{isSecondary
|
||||
? <SecondarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />
|
||||
: <PrimarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />}
|
||||
</SchoolDetailShell>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
Keep the existing `isSecondary` / `isAllThrough` derivation exactly as it is — all-through schools must still route to the primary composer, which renders both KS2 and KS4 content.
|
||||
|
||||
- [ ] **Step 4: Swap the render helper — the moment of truth**
|
||||
|
||||
Rewrite **only** `__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted, admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
return render(
|
||||
<SchoolDetailShell schoolInfo={fixture.schoolInfo} navItems={navItems} flags={flags}>
|
||||
<PrimarySchoolSections data={fixture} flags={flags} nationalAvg={nationalAveragesFixture} />
|
||||
</SchoolDetailShell>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the characterization tests**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS, with `schoolDetail.characterization.test.tsx` byte-identical to Task 2.**
|
||||
|
||||
This is the proof the refactor preserved behaviour. If a test fails, the refactor is wrong — fix the components, never the test. Confirm the file is untouched:
|
||||
|
||||
```bash
|
||||
git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Delete the old views**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.module.css
|
||||
```
|
||||
|
||||
Confirm nothing still imports them:
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "SchoolDetailView\|SecondarySchoolDetailView" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 7: Full verification**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all three PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): render sections on the server behind a client shell
|
||||
|
||||
page.tsx now composes the sections and passes them through
|
||||
SchoolDetailShell as children, so ~750 lines of static markup stop shipping
|
||||
as client JavaScript. The shell keeps what is genuinely interactive: back
|
||||
link, header reveal, hero map, compare CTA, sticky nav and scroll-spy.
|
||||
|
||||
The scroll-spy already located sections via document.getElementById, so it
|
||||
works unchanged against server-rendered children.
|
||||
|
||||
Characterization tests from the earlier commit pass unmodified.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete the unused SWR hooks
|
||||
|
||||
**Files:**
|
||||
- Delete: `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
- Modify: `nextjs-app/package.json`
|
||||
|
||||
- [ ] **Step 1: Confirm nothing imports them**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "useSchools\|useFilters\|useMetrics\|useSchoolDetails" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output. If anything appears, stop and report it — do not delete.
|
||||
|
||||
- [ ] **Step 2: Delete and drop the dependency**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/hooks/useSchools.ts nextjs-app/hooks/useFilters.ts \
|
||||
nextjs-app/hooks/useMetrics.ts nextjs-app/hooks/useSchoolDetails.ts
|
||||
cd nextjs-app && npm uninstall swr
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: drop unused SWR hooks and dependency
|
||||
|
||||
useSchools, useFilters, useMetrics and useSchoolDetails were imported by
|
||||
nothing and were the only consumers of swr. All data fetching goes through
|
||||
lib/api.ts on the server.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E coverage and measurement
|
||||
|
||||
CLAUDE.md requires e2e updates in the same PR as user-facing changes; these journeys gate whether staging is fit for human testing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/tests/journeys.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Read the existing journeys**
|
||||
|
||||
Run: `cat e2e/tests/journeys.spec.ts` and match its existing helpers, selectors and naming. Note the staging-data constraint: year filters must use the **latest** year, not the oldest — staging carries only partial history.
|
||||
|
||||
- [ ] **Step 2: Add detail-page assertions**
|
||||
|
||||
Add a journey that visits a school detail page and asserts: the Ofsted section renders; the results section renders a figure; the sticky section nav renders and a jump link scrolls to its section; and — the regression this whole refactor risks — the admissions year/trend toggle switches views. Assert on user-visible text and roles, never CSS-module class names.
|
||||
|
||||
- [ ] **Step 3: Verify the spec parses**
|
||||
|
||||
Run: `cd e2e && npx playwright test --list`
|
||||
Expected: the new tests appear. Do not run them locally — they target staging, which is deployed from `main`.
|
||||
|
||||
- [ ] **Step 4: Measure the result**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && rm -rf .next && npx next build >/dev/null 2>&1 && python3 - <<'PY'
|
||||
import re,os,gzip,glob
|
||||
for html in sorted(glob.glob('.next/server/app/**/*.html', recursive=True)):
|
||||
src=set(re.findall(r'/_next/(static/[^"\']+\.js)', open(html).read()))
|
||||
tot=sum(len(gzip.compress(open(os.path.join('.next',s),'rb').read()))
|
||||
for s in src if os.path.exists(os.path.join('.next',s)))
|
||||
print(f"{tot/1024:8.1f} KB gz {len(src):3d} scripts {html}")
|
||||
PY
|
||||
```
|
||||
|
||||
Record the figures for the PR description. Compare against the `cab7b4f` baseline: 172 KB gz shared baseline, `/admissions` 177 KB gz. **Report the route-specific figure and the shared baseline separately** — this change does not target the baseline, and an unchanged baseline is not a failure.
|
||||
|
||||
- [ ] **Step 5: Commit and open the PR**
|
||||
|
||||
```bash
|
||||
git add e2e/tests/journeys.spec.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(e2e): cover school detail sections and admissions toggle
|
||||
|
||||
The server/client split moves the detail sections out of the client bundle;
|
||||
these journeys gate that the rendered page still behaves the same on staging.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push -u origin perf/server-client-split
|
||||
```
|
||||
|
||||
Open the PR against `main` per the Gitea flow in `docs/DEPLOY.md`. The description must include: the before/after byte table from Step 4, the note that the shared baseline is deliberately unchanged, the two CSS variants introduced in Task 5, and any bugs found-but-not-fixed during the pure-move tasks.
|
||||
|
||||
Do **not** trigger the production promotion workflow — that is the human's call.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before claiming the work complete, confirm each of these with actual command output:
|
||||
|
||||
- [ ] `cd nextjs-app && npm run typecheck` — passes
|
||||
- [ ] `cd nextjs-app && npm test` — passes
|
||||
- [ ] `cd nextjs-app && npm run build` — passes
|
||||
- [ ] `git diff cab7b4f -- nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx` — **empty** after Task 2's commit
|
||||
- [ ] `grep -rn "SchoolDetailView" nextjs-app/app nextjs-app/components` — no output
|
||||
- [ ] `grep -rn "swr" nextjs-app/package.json` — no output
|
||||
- [ ] CSS audit script from Task 5 Step 5 — no `MISSING` or `LOST` lines
|
||||
- [ ] Byte measurement recorded in the PR description
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,7 @@
|
||||
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||
|
||||
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||
@@ -57,7 +57,7 @@
|
||||
- Severity guess: P2
|
||||
|
||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Info Popover — unified metric-help affordance
|
||||
|
||||
**Date:** 2026-07-22
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Problem
|
||||
|
||||
Two different "info affordance" patterns explain metrics across the app, and both are broken:
|
||||
|
||||
1. **`MetricTooltip` (ⓘ)** — used on the two detail pages (`SchoolDetailView`,
|
||||
`SecondarySchoolDetailView`). Its bubble is `position: absolute` with a fixed
|
||||
`220px` width and no viewport-collision detection. Near a screen edge on
|
||||
mobile the bubble renders **partly or wholly off-viewport with no way to
|
||||
scroll to it** — effectively unusable. A `left: -12px` mobile hack only
|
||||
shifts the problem, it doesn't solve it.
|
||||
|
||||
2. **Compare-section `?` help** — every compare section funnels through
|
||||
`RowLabel` in `components/compare/sectionShared.tsx`, which uses the **native
|
||||
`title=` attribute**. On desktop the native tooltip has a long, unconfigurable
|
||||
hover delay; on touch it barely surfaces at all.
|
||||
|
||||
We want a single component that positions itself correctly in any viewport and
|
||||
shows a custom (non-native) tooltip on desktop.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Positioning: `@floating-ui/react`** (industry standard, ~10KB gzipped, React
|
||||
19 compatible). Chosen over a hand-rolled portal + JS positioning because the
|
||||
current hand-rolled approach is exactly what failed, and Floating UI already
|
||||
solves flip/shift/portal/interactions/ARIA.
|
||||
- **Mobile presentation: repositioning popover** (not a bottom-sheet). Same small
|
||||
bubble as desktop; Floating UI's `shift`/`flip` keep it fully on-screen. One
|
||||
presentation to build and maintain, consistent across platforms.
|
||||
- **Glyph: standardise on the circled `?`** everywhere (replaces ⓘ on the detail
|
||||
pages). Matches the common "help" convention.
|
||||
- **Bundle:** adding `@floating-ui/react` as a runtime dependency is accepted.
|
||||
|
||||
## Architecture
|
||||
|
||||
One shared engine, two thin adapters — **no call-site churn**.
|
||||
|
||||
### `InfoPopover` (new — `components/InfoPopover.tsx`)
|
||||
|
||||
Owns all behaviour via `@floating-ui/react`.
|
||||
|
||||
- **Trigger:** a real `<button>` (min 24px tap target — WCAG 2.5.8 —
|
||||
keyboard-focusable) showing the circled `?` glyph. Muted colour; hover / focus
|
||||
/ open → accent colour.
|
||||
- **Positioning middleware:** `offset(6)` + `flip()` + `shift({ padding: 8 })`
|
||||
so the bubble flips above/below and slides sideways to **always stay fully
|
||||
on-screen** (the direct fix for the off-viewport bug). An `arrow()` element
|
||||
tracks the trigger.
|
||||
- **Portal:** rendered inside a `FloatingPortal` so it escapes the compare
|
||||
grid's `overflow`/`transform` clipping contexts.
|
||||
- **Interactions** (merged via `useInteractions`, one code path for both
|
||||
platforms):
|
||||
- `useHover(context, { delay: { open: 100, close: 0 } })` — short open delay
|
||||
fixes the slow native-`title` hover; Floating UI disables hover on touch
|
||||
devices so it doesn't double-fire with tap.
|
||||
- `useFocus` — keyboard focus reveals it.
|
||||
- `useClick` — tap toggles on touch.
|
||||
- `useDismiss` — outside-press and `Escape` close it (opening another popover
|
||||
closes the first, since the outside-press lands on the new trigger).
|
||||
- `useRole(context, { role: 'tooltip' })` — ARIA wiring.
|
||||
- **Content:** optional `label` (bold), `plain` (body), `detail` (muted) — same
|
||||
content shape as today.
|
||||
|
||||
**Props:**
|
||||
|
||||
```ts
|
||||
interface InfoPopoverProps {
|
||||
label?: string; // bold heading
|
||||
plain?: string; // body text (the primary explanation)
|
||||
detail?: string; // muted supplementary line
|
||||
/** Accessible name for the trigger button, e.g. the metric label. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Renders `null` when there is no `plain` content (mirrors current
|
||||
`MetricTooltip` behaviour).
|
||||
|
||||
### Adapter 1 — `MetricTooltip` (public API unchanged)
|
||||
|
||||
Keeps its existing props (`metricKey`, `label`, `plain`, `detail`), still
|
||||
resolves `metricKey` → `METRIC_EXPLANATIONS`, and renders `InfoPopover` with the
|
||||
resolved content. **All ~24 detail-page call sites are untouched.** The
|
||||
hand-rolled positioning/dismiss `useEffect` and the `.tooltip` absolute-position
|
||||
CSS (including the `left: -12px` mobile hack) are deleted.
|
||||
|
||||
### Adapter 2 — `RowLabel` (in `sectionShared.tsx`)
|
||||
|
||||
Replaces the native-`title` `?` span with `InfoPopover`, passing the `tip`
|
||||
string as `plain` and the row label as `ariaLabel`. **All ~14 compare `tip=`
|
||||
call sites are untouched** (they pass through `Measure` → `RowLabel`).
|
||||
|
||||
## Removed / replaced
|
||||
|
||||
- `MetricTooltip`'s hand-rolled open/dismiss `useEffect`.
|
||||
- `MetricTooltip.module.css` `.tooltip` absolute positioning + arrow + the
|
||||
`@media (max-width: 640px)` `left: -12px` hack (superseded by Floating UI).
|
||||
- Native `title=` on the compare `?` help span.
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Unit (jest + jsdom)** — `InfoPopover`:
|
||||
- trigger button renders with the correct `aria-label` and `aria-expanded`;
|
||||
- click opens and closes it;
|
||||
- `label` / `plain` / `detail` render when open;
|
||||
- `Escape` closes it.
|
||||
Positioning (flip/shift) is Floating UI's own tested concern, not re-tested
|
||||
here.
|
||||
|
||||
2. **E2E (Playwright, mobile viewport)** — the regression guard for the reported
|
||||
bug. On a narrow viewport, open a help popover near the **right edge** of a
|
||||
compare section and assert its bounding box is fully within the viewport
|
||||
(`rect.right <= innerWidth && rect.left >= 0`). This is the check that would
|
||||
have caught the original overflow. Satisfies the CLAUDE.md rule to extend
|
||||
`e2e/` when user-facing behaviour changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Other `title=` usages (button/link hints, empty-state props) — these are not
|
||||
metric explainers and keep native `title`.
|
||||
- No bottom-sheet / alternate mobile presentation.
|
||||
@@ -0,0 +1,295 @@
|
||||
# Detail pages: server/client split
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Branch:** `perf/server-client-split`
|
||||
|
||||
## Problem
|
||||
|
||||
Every page of the site ships ~172 KB gzipped of JavaScript before any
|
||||
page-specific code loads. The 404 page costs that much; `/admissions`, a static
|
||||
content page with no data, costs 177 KB. A well-tuned Next.js app baseline is
|
||||
90-110 KB.
|
||||
|
||||
The cause is that nearly the whole site is a client component.
|
||||
`SchoolDetailView.tsx` is 65 KB of source marked `'use client'`;
|
||||
`SecondarySchoolDetailView.tsx` is 46 KB. Both are overwhelmingly presentational
|
||||
markup driven by props the server already fetched. Every stat tile, table row and
|
||||
Ofsted badge is serialised into the RSC payload *and* shipped again as JavaScript
|
||||
to hydrate.
|
||||
|
||||
Measured on a production build of `cab7b4f` (Next 16.1.6, Turbopack):
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Baseline JS, every page | 172 KB gz across 8 chunks |
|
||||
| `/admissions` (no data) | 177 KB gz |
|
||||
| Chart.js chunk | 64 KB gz (correctly lazy) |
|
||||
| Leaflet chunk | 41 KB gz (correctly lazy) |
|
||||
| CSS | ~30 KB gz, route-split |
|
||||
|
||||
**Scope of this document.** The table above characterises the whole problem; this
|
||||
design addresses one part of it — the route-level client JS on school detail
|
||||
pages, the site's highest-traffic and most SEO-critical route. The shared
|
||||
baseline and the other routes are separate work, tracked elsewhere.
|
||||
|
||||
## What makes this tractable
|
||||
|
||||
Two properties of the existing code make an aggressive split low-risk.
|
||||
|
||||
**The section bodies are almost entirely static.** Across ~750 lines of sections
|
||||
in `SchoolDetailView` (lines 596-1350), the only interactivity is five charts
|
||||
(already `dynamic({ ssr: false })` islands), fourteen `<MetricTooltip>`
|
||||
instances (already a small client leaf), and two buttons driving the admissions
|
||||
year/trend toggle. Everything else renders props.
|
||||
|
||||
**The scroll-spy is already decoupled.** `SchoolDetailView.tsx:283` locates
|
||||
sections with `document.getElementById(id)`, and `navItems` (lines 258-267) is
|
||||
computed purely from data. The navigation does not care which component renders
|
||||
the sections, so server-rendered children work unchanged.
|
||||
|
||||
There is also a precedent to follow: `components/compare/` is already decomposed
|
||||
this way — one file per section plus a `sectionShared.tsx` of primitives.
|
||||
|
||||
## Architecture
|
||||
|
||||
A server component imported by a client component becomes a client component. So
|
||||
the sections cannot be children of a client `SchoolDetailView`. They are composed
|
||||
in `page.tsx` and passed *through* the client shell as `children` — the pattern
|
||||
already used at `app/page.tsx:98-99`.
|
||||
|
||||
The header and navigation sit above the sections, and the sections are plain
|
||||
siblings, so a single `children` slot suffices. Named slots were considered and
|
||||
rejected: more ceremony, no benefit when sections render in sequence.
|
||||
|
||||
```
|
||||
app/school/[slug]/page.tsx server; fetches details + nationalAverages
|
||||
│ computes isSecondary / isAllThrough / navItems
|
||||
└── <SchoolDetailShell schoolInfo navItems> 'use client' — the only large client file
|
||||
│ back link · header + details reveal · hero map · compare CTA
|
||||
│ sticky section nav · scroll spy · jump sheet
|
||||
└── children: <PrimarySchoolSections/> | <SecondarySchoolSections/> server
|
||||
└── components/school/ all server, mirroring components/compare/
|
||||
OfstedSection · ResultsSection · AdmissionsSection
|
||||
InclusionSection · HistorySection · SchoolLifeSection
|
||||
LocalAreaSection · FinancesSection · WellbeingSection
|
||||
sectionShared.tsx Section/Card/Row primitives
|
||||
```
|
||||
|
||||
Phase branching stays out of the route file: two thin server components
|
||||
(`PrimarySchoolSections`, `SecondarySchoolSections`) return the section sequence,
|
||||
and `page.tsx` picks one.
|
||||
|
||||
### Client leaves inside server sections
|
||||
|
||||
Importing a client component into a server component is the legal direction, so
|
||||
these stay client and are used from server sections unchanged:
|
||||
|
||||
- `MetricTooltip` / `InfoPopover` — exist, unchanged
|
||||
- `PerformanceChart`, `SatsChart`, `AdmissionsTrendChart` — exist, stay `dynamic({ ssr: false })`
|
||||
- `AdmissionsViewToggle` — **new**, ~25 lines; receives server-rendered year-view
|
||||
and trend-view as two props and swaps between them
|
||||
- `DeltaChip`, `SpecialSchoolNote` — already server-compatible (no `'use client'`)
|
||||
|
||||
### What stays in the client shell, and why
|
||||
|
||||
The shell is genuinely interactive throughout: `router.back()`, the details
|
||||
reveal, `IntersectionObserver` hero-CTA tracking, the nav overflow fade, the
|
||||
Escape-to-close sheet, and the comparison context. The goal is not to shrink the
|
||||
shell but to stop the other ~750 lines being attached to it.
|
||||
|
||||
### Section sharing
|
||||
|
||||
Sharing is decided by measured similarity, not assumption. Comparing the two
|
||||
views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary | Secondary | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Finances | 29 lines | 35 | 91% | Shared |
|
||||
| Ofsted | 88 | 107 | 80% | Shared |
|
||||
| History | 121 | 47 | 40% | Separate |
|
||||
| Admissions | 89 | 55 | 14% | Separate |
|
||||
|
||||
Admissions and History were assumed shareable when this design was first drafted;
|
||||
measurement during planning showed they are not — primary's admissions carries
|
||||
the year/trend toggle and chart, secondary's is a much simpler panel.
|
||||
|
||||
**This costs nothing in bytes.** The client-JS win comes from a section being a
|
||||
*server* component, not from being shared between views. Sharing is a
|
||||
maintainability benefit, taken only where it is cheap. Forcing the two
|
||||
low-similarity sections together would inflate the diff and the regression risk
|
||||
for no performance gain.
|
||||
|
||||
| Shared | Primary only | Secondary only |
|
||||
| --- | --- | --- |
|
||||
| Ofsted, Finances, `sectionShared` primitives, and the shell | Results (KS2), Admissions, History, Inclusion, School Life, Local Area | GCSEs, Admissions, History, Wellbeing |
|
||||
|
||||
All-through schools render both the KS2 and KS4 section sets via the primary
|
||||
composer.
|
||||
|
||||
### CSS strategy
|
||||
|
||||
Shared section components need one stylesheet, but the two existing CSS modules
|
||||
disagree. Measured on `cab7b4f`: `SchoolDetailView.module.css` defines 142
|
||||
classes, `SecondarySchoolDetailView.module.css` 112, with 79 names in common — of
|
||||
which 22 have different rules, and 16 of those are used inside section markup by
|
||||
both views (`.card`, `.sectionTitle`, `.metricCard`, `.metricValue`,
|
||||
`.metricsGrid`, `.tableWrapper`, the `heroStat*` set, the `genderSplit*` set,
|
||||
`.historyToggle`, `.ofstedReportLink`).
|
||||
|
||||
The 16 conflicts split into two kinds:
|
||||
|
||||
**Incidental drift (14 classes).** One view carries defensive overflow properties
|
||||
the other lacks — `min-width: 0`, `overflow-wrap: break-word`,
|
||||
`word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`.
|
||||
These are independent bug-fixes that were never back-ported, not design
|
||||
decisions. **Resolution: take the union.** No visible change, and it fixes latent
|
||||
long-school-name overflow on whichever page currently lacks the property.
|
||||
|
||||
**Real visual differences (2 classes).**
|
||||
|
||||
| Class | Primary | Secondary |
|
||||
| --- | --- | --- |
|
||||
| `.genderBar` | `height: 6px`, `background: var(--border-color, #e5dfd5)`, `width: 100%` | `height: 4px`, `background: rgba(0, 0, 0, 0.08)`, `margin-top: 0.45rem` |
|
||||
| `.heroStatValue` | `gap: 0.5rem`, `justify-content: flex-start`, Playfair fallback includes Georgia | `gap: 0.4rem`, Playfair fallback omits Georgia |
|
||||
|
||||
**Resolution: keep both looks as distinct classes** and have the owning section
|
||||
take a `variant: 'primary' | 'secondary'` prop selecting between them. Zero
|
||||
intended visual change, one shared stylesheet, and the divergence becomes
|
||||
explicit rather than accidental.
|
||||
|
||||
The merged stylesheet is `components/school/schoolSections.module.css`. Shell-only
|
||||
rules stay behind in the per-view modules. Classes outside the 79 shared names
|
||||
move across unchanged.
|
||||
|
||||
### Prerequisite: national averages move server-side
|
||||
|
||||
`SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`,
|
||||
and `primaryAvg` / `secondaryAvg` feed the England-comparison deltas across most
|
||||
sections. Those sections cannot be server components until that data arrives as a
|
||||
prop. `SecondarySchoolDetailView.tsx:91-93` has the same fetch.
|
||||
|
||||
`page.tsx` will fetch it in parallel with the school details (the endpoint is
|
||||
backend-cached for an hour) and pass `nationalAvg` down. This is a prerequisite,
|
||||
not an optional extra. It independently removes a client round-trip per detail
|
||||
page and puts the deltas in the initial HTML instead of popping in after
|
||||
hydration.
|
||||
|
||||
### Layout
|
||||
|
||||
`Footer` is already a server component — no change needed. `Navigation` genuinely
|
||||
requires client (`usePathname` plus the comparison count) and stays as it is.
|
||||
This item is much smaller than the original review estimated.
|
||||
|
||||
### Unused dependency
|
||||
|
||||
`hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts` and `useSchoolDetails.ts`
|
||||
are imported by nothing. They are the only consumers of `swr`. All four files and
|
||||
the `swr` dependency are deleted.
|
||||
|
||||
## Testing
|
||||
|
||||
`SchoolDetailView` will not exist as a single component afterwards, so
|
||||
characterization tests must not import it directly. A single render helper
|
||||
absorbs the change:
|
||||
|
||||
```
|
||||
__tests__/support/renderSchoolDetail.tsx the only file that changes at the split
|
||||
before: render(<SchoolDetailView {...props} />)
|
||||
after: render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)
|
||||
```
|
||||
|
||||
Every assertion imports that helper and never changes. Assertions passing
|
||||
untouched across the refactor is the proof that behaviour is preserved.
|
||||
|
||||
**Coverage:** Ofsted panel (rating, date, report link); KS2 results rows and
|
||||
England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the
|
||||
year/trend toggle; inclusion and wellbeing figures; the history table; finances;
|
||||
and the conditional `navItems` set.
|
||||
|
||||
**Fixtures:** four shapes — primary, secondary, all-through, and special school.
|
||||
The special-school path drops the England comparison via `isSpecialSchool()` and
|
||||
is the site of a known past regression (PR #70), so it gets explicit coverage.
|
||||
|
||||
**Environment gaps to close in `jest.setup.js`:** jsdom provides neither
|
||||
`IntersectionObserver` (used twice in each view) nor `window.scrollTo`. Chart.js
|
||||
needs a canvas, so the chart components are mocked. `next/jest` already handles
|
||||
CSS modules, and `next/navigation` is already globally mocked. Section components
|
||||
are synchronous functions, so React Testing Library renders both the server and
|
||||
client pieces in jsdom without special handling.
|
||||
|
||||
**E2E:** `e2e/journeys.spec.ts` gains detail-page assertions. CLAUDE.md requires
|
||||
e2e updates in the same PR as user-facing changes.
|
||||
|
||||
## Commit sequence
|
||||
|
||||
One PR, but the diff is large, so it is structured to stay reviewable and
|
||||
bisectable:
|
||||
|
||||
1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures
|
||||
2. `test:` characterization tests against the current components — all green before anything moves
|
||||
3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches
|
||||
4. `refactor:` extract the shared derived-flags/`navItems` module
|
||||
5. `style:` merge the two CSS modules into `components/school/schoolSections.module.css` per the CSS strategy above
|
||||
6. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits)
|
||||
7. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views
|
||||
8. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json`
|
||||
9. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions
|
||||
|
||||
Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes
|
||||
them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge
|
||||
rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect.
|
||||
|
||||
## Verification
|
||||
|
||||
Before the work is claimed complete:
|
||||
|
||||
- `npm run typecheck`, `npm test` and `npm run build` all green
|
||||
- characterization tests passing **unmodified** from commit 2
|
||||
- before/after gzipped JS per route recorded in the PR description, measured the
|
||||
same way as the figures in the Problem section
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `/school/[slug]` route-specific JS drops to roughly 40-60 KB gz, from a client
|
||||
tree currently built out of a 65 KB and a 46 KB source file
|
||||
- No visual or behavioural change on any of the four school shapes
|
||||
- One fewer client API round-trip per detail page
|
||||
|
||||
**This change does not reduce the 172 KB baseline.** That baseline is react-dom,
|
||||
the Next.js runtime, and the layout's client components (`Navigation`,
|
||||
`ComparisonProvider`, `ComparisonToast`) — none of which this design touches, as
|
||||
`Navigation` legitimately needs `usePathname` and the comparison count. The
|
||||
baseline is a separate piece of work; it is cited above only to establish why
|
||||
route-level client JS matters. Reporting must state the two numbers separately so
|
||||
the unchanged baseline is not read as a failure of this change.
|
||||
|
||||
Budget enforcement in CI was considered and deliberately deferred: measurement is
|
||||
recorded in the PR, but no new CI gate is added in this change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No visual redesign or copy changes to any section
|
||||
- No change to `Navigation`, `Footer`, or any other route
|
||||
- No CI bundle-budget gate
|
||||
- Bugs found mid-move are recorded, not fixed inline
|
||||
|
||||
## Risks
|
||||
|
||||
**DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS
|
||||
modules depend on. Commits 4 and 5 are strict moves and the section CSS moves
|
||||
with them; any styling change beyond the CSS-merge rules above is out of scope.
|
||||
|
||||
**CSS merge.** Merging two large stylesheets is the highest-risk part of the
|
||||
work. The union rule is mechanical, but a missed conflict shows up as a visual
|
||||
regression that no unit test catches. The merge gets its own commit, and the
|
||||
16 known conflicts are enumerated above so they can be verified individually.
|
||||
|
||||
**All-through schools.** The trickiest case: `isAllThrough` gates KS2 content
|
||||
back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It
|
||||
gets a dedicated fixture.
|
||||
|
||||
**Large diff.** Accepted deliberately in favour of designing the shared section
|
||||
library against both consumers at once. Mitigated by the commit sequence above.
|
||||
|
||||
**`swr` removal** is safe — nothing imports those hooks.
|
||||
@@ -0,0 +1,309 @@
|
||||
# SEO Programme — Design
|
||||
|
||||
Date: 2026-08-20
|
||||
Status: awaiting review
|
||||
|
||||
## Problem
|
||||
|
||||
schoolcompare ranks second for "school compare" — an exact match for the
|
||||
brand and the domain. It ranks poorly for "compare schools", "school
|
||||
comparison" and "schools near me". The first is a naming artefact and
|
||||
transfers to nothing; the rest are the queries that actually carry parent
|
||||
demand.
|
||||
|
||||
The cause is structural, not editorial. The site publishes five route
|
||||
families:
|
||||
|
||||
/ /compare /rankings /admissions /school/[slug]
|
||||
|
||||
Location intent has no landing page at all. Every competitor outranking us
|
||||
on those queries wins with programmatic location pages:
|
||||
|
||||
| Competitor | URL pattern |
|
||||
|---------------|--------------------------------------------|
|
||||
| School Guide | `/best-schools-in/manchester` |
|
||||
| Locrating | `/the-best-primary-schools-in-Manchester_…`|
|
||||
| FindMySchool | `/best-primary-schools/manchester` |
|
||||
| Snobe | `/best-primary-schools/manchester` |
|
||||
| School Atlas | `/guides/best-primary-schools-manchester` |
|
||||
|
||||
"Schools near me" is a local-intent query. Google resolves it against the
|
||||
user's coordinates and serves pages that are *about a place*. A national
|
||||
homepage cannot win it. No title or description change fixes this; only
|
||||
pages Google can localise will.
|
||||
|
||||
## Baseline (measured 2026-08-20, production API)
|
||||
|
||||
| Measure | Value |
|
||||
|--------------------------------------------|---------|
|
||||
| Unique schools | 27,229 |
|
||||
| URLs in sitemap.xml | 27,232 |
|
||||
| Schools with 2024/25 performance data | 21,266 |
|
||||
| Schools with **no** current performance data| ~5,963 (22%) |
|
||||
| Welsh establishments (all metrics null) | 1,569 |
|
||||
| Overseas / offshore establishments | 467 |
|
||||
| Static URLs in sitemap | 3 |
|
||||
| Routes setting a canonical | 1 of 5 |
|
||||
|
||||
Three findings from that table drive the plan.
|
||||
|
||||
**We submit ~6,000 thin pages to Google.** `build_sitemap()`
|
||||
(`backend/app.py:73`) enumerates every URN regardless of whether the school
|
||||
has any data. Welsh establishments return `school_type: "Welsh
|
||||
establishment"` with every performance metric, Ofsted grade and phase field
|
||||
null. Overseas and offshore establishments ("BFPO Overseas Establishments",
|
||||
"Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are
|
||||
in the local-authority list too. At 22% of the submitted corpus this is a
|
||||
site-wide quality signal problem and a crawl-budget waste, not a rounding
|
||||
error.
|
||||
|
||||
W1 item 4 removes 2,036 of those — every non-England establishment — taking
|
||||
the corpus to 25,193. The 3,927 that remain are English schools with no
|
||||
current data: mostly newly opened, special, nursery or alternative provision.
|
||||
Those are a template problem, not a corpus problem, and item 5 handles them
|
||||
separately.
|
||||
|
||||
**The homepage is its own competitor.** `app/page.tsx` accepts eleven search
|
||||
params (`search`, `local_authority`, `school_type`, `phase`, `page`,
|
||||
`postcode`, `radius`, `sort`, `gender`, `admissions_policy`,
|
||||
`has_sixth_form`) and sets no canonical. Every filter combination is a
|
||||
crawlable near-duplicate of the single page we are asking to rank for
|
||||
"compare schools".
|
||||
|
||||
**School pages are near-orphans.** Reachable from the sitemap and from site
|
||||
search, but almost nothing links to them contextually, so they accrue no
|
||||
internal authority.
|
||||
|
||||
Also noted: the sitemap emits invented `priority` values and no `lastmod`.
|
||||
Google ignores `priority` and `changefreq` entirely; `lastmod` is the field
|
||||
it does read, and we omit it.
|
||||
|
||||
## Keyword clusters
|
||||
|
||||
Ranked by judgement of UK parent search behaviour and by the competitive
|
||||
SERP evidence above. Google Search Console is connected, so cluster
|
||||
priorities are to be re-derived from measured impressions before build
|
||||
starts (see Workstream 0).
|
||||
|
||||
**C1 — Head "compare" terms.** compare schools · school comparison · school
|
||||
comparison tool · compare school performance · compare primary schools ·
|
||||
compare secondary schools · compare two schools
|
||||
|
||||
**C2 — League tables and rankings.** primary school league tables ·
|
||||
secondary school league tables · school league tables 2026 · SATs results by
|
||||
school · GCSE results by school · KS2 league tables · Progress 8 rankings ·
|
||||
best primary schools in [town] · top 10 primary schools in [LA]
|
||||
|
||||
**C3 — Local / near me.** schools near me · primary schools near me ·
|
||||
secondary schools near me · best schools near me · good schools near me ·
|
||||
schools in [town] · primary schools in [LA] · schools near [postcode] ·
|
||||
[postcode] school catchment
|
||||
|
||||
**C4 — Individual school long tail.** [school] ofsted · [school] SATs
|
||||
results · [school] catchment area · [school] reviews · [school] URN
|
||||
|
||||
**C5 — Admissions.** primary school admissions 2027 · national offer day
|
||||
2027 · school application deadline · school admissions appeal ·
|
||||
oversubscription criteria · distance criteria school admissions · didn't get
|
||||
first choice school · school admissions [LA]
|
||||
|
||||
**C6 — Metric explainers.** what is a good SATs score · what is Progress 8 ·
|
||||
what is Attainment 8 · expected standard KS2 meaning · scaled score
|
||||
explained · Ofsted grades explained · Ofsted report cards · pupil premium
|
||||
explained
|
||||
|
||||
**C7 — Head to head.** [school A] vs [school B] · academy vs community
|
||||
school · grammar school vs comprehensive · faith school vs community school
|
||||
|
||||
## Workstreams
|
||||
|
||||
### W0 — Measure before touching anything
|
||||
|
||||
Export a Google Search Console baseline: impressions, clicks, average
|
||||
position and CTR by query and by page, for the trailing 16 months. Bucket
|
||||
queries into C1–C7. This sets the counterfactual — without it, no later
|
||||
claim about lift is defensible, because school-search traffic is strongly
|
||||
seasonal (results day in December, offer day in March/April).
|
||||
|
||||
Re-rank C1–C7 against measured impressions and adjust the sequence below if
|
||||
the data disagrees with the judgement calls.
|
||||
|
||||
### W1 — Crawl hygiene and index sanity
|
||||
|
||||
Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a
|
||||
corpus that is 22% thin would compound the existing problem.
|
||||
|
||||
1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions`
|
||||
currently set none.
|
||||
2. The homepage canonicalises to `/` regardless of search params.
|
||||
3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter
|
||||
space with no standalone value.
|
||||
4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the
|
||||
service/overseas schools are removed from the corpus at the mart boundary,
|
||||
not hidden at the view layer. `dim_school` and `dim_location` both exclude
|
||||
`TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service
|
||||
children's education, Welsh establishment, British schools overseas —
|
||||
listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`.
|
||||
That removes 2,036 establishments and 29 local authorities, and because
|
||||
`build_sitemap()` reads the same marts, it drops them from the sitemap in
|
||||
the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS
|
||||
refresh reintroduces them or if the two models drift apart.
|
||||
5. Prune the remaining thin pages: exclude any school with no performance data
|
||||
**and** no Ofsted record. Distinct from item 4 — these are English schools
|
||||
with nothing yet to show, so the fix may be a better template rather than
|
||||
removal.
|
||||
6. Rebuild the sitemap as a sitemap **index**: one child per page family,
|
||||
real `lastmod` from the data-load timestamp, `priority` and `changefreq`
|
||||
dropped.
|
||||
|
||||
### W2 — The location layer
|
||||
|
||||
The dominant lever. `dim_location` already carries `town`, `county`,
|
||||
`local_authority_name`, `parliamentary_constituency`, `latitude`,
|
||||
`longitude` and `postcode`, so no new ingestion is required.
|
||||
|
||||
Routes:
|
||||
|
||||
/schools/[la] e.g. /schools/manchester
|
||||
/schools/[la]/primary
|
||||
/schools/[la]/secondary
|
||||
/best-primary-schools/[town]
|
||||
/best-secondary-schools/[town]
|
||||
/schools/near/[outcode] e.g. /schools/near/m20
|
||||
/schools/near-me geolocating hub
|
||||
|
||||
**Thin-page threshold: generate a town or outcode page only where at least
|
||||
five schools have current performance data.** Below that, 301 to the parent
|
||||
LA page. This is the single most important constraint in the workstream —
|
||||
it is what separates a location layer from index bloat.
|
||||
|
||||
Each page must earn its place with content a parent would actually use, not
|
||||
a template shell:
|
||||
|
||||
- H1 matching the query intent ("Best primary schools in Manchester")
|
||||
- Counts framed usefully: "137 primary schools, 9 rated Outstanding"
|
||||
- A ranked table of the top 20 on the headline metric
|
||||
- Local average against the England average
|
||||
- Ofsted grade distribution
|
||||
- Map
|
||||
- Links to neighbouring towns and to the parent LA
|
||||
- An FAQ block (feeds `FAQPage` in W4)
|
||||
- Links to every school page in scope — this is what de-orphans W1's corpus
|
||||
|
||||
Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold
|
||||
≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**,
|
||||
comfortably inside a sitemap index and well under the per-file 50,000 limit.
|
||||
|
||||
### W3 — Make rankings indexable
|
||||
|
||||
`/rankings` is driven entirely by query params, so Google indexes
|
||||
approximately one page where there should be hundreds.
|
||||
|
||||
/rankings/[phase]/[metric]
|
||||
/rankings/[phase]/[metric]/[la]
|
||||
|
||||
The interactive filter UI stays; its state moves into real paths. Param
|
||||
forms canonicalise to the clean path. This is the direct play for C2.
|
||||
|
||||
### W4 — Structured data and internal linking
|
||||
|
||||
- Replace the bare `EducationalOrganization` on school pages with `School`,
|
||||
and populate it properly.
|
||||
- `BreadcrumbList` site-wide.
|
||||
- `ItemList` on every rankings and location page.
|
||||
- `FAQPage` on admissions and on location pages.
|
||||
- New school-page modules: "Other schools in [town]", "Nearby schools",
|
||||
"Compare with similar schools". Each links out to W2 and W3 pages, which
|
||||
is what circulates authority instead of stranding it.
|
||||
|
||||
Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus
|
||||
exists, and fabricating one would be both useless and a policy violation.
|
||||
|
||||
### W5 — Admissions expansion
|
||||
|
||||
One static page currently carries an entire cluster.
|
||||
|
||||
/admissions/[la] per-authority deadlines and offer day
|
||||
/admissions/appeals
|
||||
/admissions/national-offer-day
|
||||
|
||||
`school admissions [LA]` is high-intent and highly seasonal; per-authority
|
||||
pages are the natural unit.
|
||||
|
||||
### W6 — Explainer content
|
||||
|
||||
/guides/progress-8
|
||||
/guides/attainment-8
|
||||
/guides/sats-scaled-scores
|
||||
/guides/ofsted-grades
|
||||
/guides/expected-standard
|
||||
|
||||
Each links into the corresponding W3 rankings page. Cheap to build, and it
|
||||
is what gives the metric vocabulary enough topical weight to support C1–C4.
|
||||
|
||||
### W7 — Head-to-head pages
|
||||
|
||||
/compare/[school-a]-vs-[school-b]
|
||||
|
||||
C7 is uncontested and native to the product. It is also the easiest way to
|
||||
destroy everything W1 fixes: 27,229 schools generate 370 million pairs.
|
||||
**Curated pairs only** — same town, both with current data, both with real
|
||||
search demand — capped in the low thousands. Gated behind evidence that W2
|
||||
is indexing cleanly.
|
||||
|
||||
### W8 — Metadata rewrite for C1
|
||||
|
||||
Current homepage title is `schoolcompare | Compare every school in England`,
|
||||
which spends the most valuable position on the brand. Rewrite the homepage,
|
||||
rankings and compare titles and descriptions around C1 phrasing. Small
|
||||
change, and the cheapest item in the programme.
|
||||
|
||||
## Sequencing
|
||||
|
||||
W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly)
|
||||
|
||||
W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin
|
||||
compounds the problem rather than diluting it.
|
||||
|
||||
This spec is a programme, not a single implementation plan. Each workstream
|
||||
gets its own plan and its own PR; W2 will likely need several. Only W0 and W1
|
||||
are ready to plan against today — the rest should be re-read after W0's
|
||||
Search Console baseline lands, because that data may reorder them.
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in
|
||||
the same PR. Each workstream adds:
|
||||
|
||||
- W1: canonical present and correct on every route; `/compare?urns=` carries
|
||||
`noindex`; sitemap excludes a known dataless URN.
|
||||
- W2: a known LA, town and outcode page renders with the expected school
|
||||
count; a below-threshold town redirects to its LA.
|
||||
- W3: a clean rankings path renders; the param form canonicalises to it.
|
||||
- W4: JSON-LD parses and validates against the declared types.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat.** The failure mode of every programmatic SEO programme. The
|
||||
five-school threshold, the W1 prune and the W7 gate are the three controls.
|
||||
|
||||
**Helpful-content exposure.** Google's stance on templated location pages
|
||||
has hardened. The mitigation is that each page carries genuinely local
|
||||
computed data — real counts, real distributions, real local-vs-national
|
||||
comparison — rather than a name substituted into boilerplate.
|
||||
|
||||
**Build cost.** School pages already use ISR with a 7-day revalidate and
|
||||
`PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same
|
||||
treatment; full static generation of 32,000 pages is likely impractical in
|
||||
CI.
|
||||
|
||||
**Seasonality.** Results day and offer day dominate the traffic curve.
|
||||
Judging the programme on a mid-summer window would misread it in either
|
||||
direction. W0's baseline must be year-on-year, not month-on-month.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Catchment areas are Locrating's moat and a strong C3 driver
|
||||
(`[postcode] school catchment`). `fact_admissions` carries admission
|
||||
distances. Is deriving approximate catchment a later workstream, or out
|
||||
of scope?
|
||||
@@ -0,0 +1,278 @@
|
||||
# W2: The Location Layer — Design
|
||||
|
||||
Date: 2026-08-21
|
||||
Status: awaiting review
|
||||
Supersedes: workstream W2 in `2026-08-20-seo-programme-design.md`
|
||||
|
||||
Scope note: this covers four page families in one spec. Splitting them — towns
|
||||
and authorities first, outcodes and localities after — was proposed and
|
||||
declined in favour of building the layer in one pass. The decomposition
|
||||
argument was that the curated locality seed needs human review and would hold
|
||||
up 783 pages of measured demand behind it; that risk is accepted here, and the
|
||||
implementation plan should sequence the seed early enough that review time
|
||||
does not become the critical path.
|
||||
|
||||
## Problem
|
||||
|
||||
Location intent is the largest unserved demand the site has. In the 16-month
|
||||
Search Console baseline it draws **874 impressions, one click, average
|
||||
position 49.5**. The site does not compete.
|
||||
|
||||
Unlike named-school queries — which the same baseline showed to be
|
||||
navigational and unwinnable, since a parent typing "audley junior school"
|
||||
wants that school's own website — location queries have no incumbent owner.
|
||||
Nobody owns "primary schools in Brentwood" the way a school owns its name.
|
||||
|
||||
The cause is structural: the site has no page about a place. Every competitor
|
||||
ranking above it does.
|
||||
|
||||
## What the demand actually looks like
|
||||
|
||||
Every location query in the baseline is **town or district level**. Not one is
|
||||
an administrative area:
|
||||
|
||||
| Query | Impressions | Position |
|
||||
|-------|-------------|----------|
|
||||
| colleges in solihull | 112 | 51.2 |
|
||||
| schools in ramsey | 64 | 42.5 |
|
||||
| schools in crosby | 57 | 47.7 |
|
||||
| primary schools in beccles | 44 | 40.9 |
|
||||
| private schools in battersea | 41 | 71.9 |
|
||||
| secondary schools in brentwood | 37 | 56.1 |
|
||||
| secondary schools in canary wharf | 30 | 35.9 |
|
||||
|
||||
Three patterns follow directly, and they drive the whole design.
|
||||
|
||||
**Towns, not authorities.** The superseded W2 put `/schools/[la]` first and
|
||||
towns second. The data inverts that. Brentwood appears four times in different
|
||||
phrasings; Beccles twice. Both are towns, not authorities.
|
||||
|
||||
**Phase is part of the query**, not a filter applied afterwards: "primary
|
||||
schools in beccles", "secondary schools in brentwood", "colleges in solihull".
|
||||
|
||||
**London is searched by district** — Battersea, Canary Wharf — and the GIAS
|
||||
`town` field cannot serve it at all.
|
||||
|
||||
## Measured sizing
|
||||
|
||||
Counted against the live corpus of 25,185 schools, not estimated.
|
||||
|
||||
| Family | Viable (≥5 schools) | Below threshold |
|
||||
|--------|--------------------|-----------------|
|
||||
| Towns | **783** | 907 → redirect to authority |
|
||||
| Outcodes | **1,760** | 305 |
|
||||
| Local authorities | 154 | — |
|
||||
| London localities | ~100–150 (curated) | — |
|
||||
|
||||
With phase variants — 783 town pages plus roughly 700 primary and 250
|
||||
secondary variants, 154 authorities across three variants, 1,760 outcodes and
|
||||
the curated localities — the total lands near **4,000 pages**. Phase variants
|
||||
need their own threshold: there are 17,426 primaries but only 4,456 secondaries nationally,
|
||||
so most towns will support a primary page and not a secondary one.
|
||||
|
||||
## Two design problems this spec exists to solve
|
||||
|
||||
### 1. Town and authority names collide, and neither contains the other
|
||||
|
||||
67 viable towns share a name with a local authority. The obvious fix — let the
|
||||
authority absorb the town, since it sounds like a superset — **does not work**:
|
||||
|
||||
| Place | Schools in the town | Schools in the authority |
|
||||
|-------|--------------------|-----------------------|
|
||||
| Bedford | 104 | 86 |
|
||||
| Birmingham | 520 | 518 |
|
||||
| Derby | 157 | 119 |
|
||||
| Doncaster | 152 | 145 |
|
||||
|
||||
The authority is the larger set in only 43 of the 67. Postal towns cross
|
||||
authority boundaries, so these are overlapping sets that happen to share a
|
||||
name. Publishing both into one namespace produces near-duplicate pages, which
|
||||
is the specific failure that sinks programmatic SEO.
|
||||
|
||||
**Resolution: two namespaces.**
|
||||
|
||||
```
|
||||
/schools/[place] towns and London localities
|
||||
/schools/[place]/primary
|
||||
/schools/[place]/secondary
|
||||
/schools/authority/[la] local authorities
|
||||
/schools/authority/[la]/primary
|
||||
/schools/authority/[la]/secondary
|
||||
/schools/near/[outcode]
|
||||
```
|
||||
|
||||
Outcodes carry no phase variants: nobody searches "primary schools in SW11",
|
||||
so the variants would be pages without demand.
|
||||
|
||||
Every collision disappears by construction. `/schools/[place]` keeps the clean
|
||||
URL for the pattern that carries the demand; authorities get a namespace whose
|
||||
purpose is genuinely different — admissions are authority-run, and the
|
||||
authority page is the one that can speak to catchment policy and LA averages.
|
||||
|
||||
A place page and an authority page of the same name must each say plainly
|
||||
which set of schools they cover, or they read as duplicates to a reader even
|
||||
when they differ in fact.
|
||||
|
||||
### 2. London has no locality field
|
||||
|
||||
`town` collapses **1,819 London schools into the single value "London"**. A
|
||||
page listing all of them is useless, and borough pages do not help because
|
||||
people search "Battersea", not "Wandsworth".
|
||||
|
||||
No single field solves it:
|
||||
|
||||
| Search term | `parliamentary_constituency` | postcodes.io `admin_ward` |
|
||||
|-------------|------------------------------|---------------------------|
|
||||
| Battersea | **Battersea** ✓ | Northcote / Wandsworth Town ✗ |
|
||||
| Canary Wharf | Poplar and Limehouse ✗ | **Canary Wharf** ✓ |
|
||||
| Vauxhall | Vauxhall and Camberwell Green ✗ | **Vauxhall** ✓ |
|
||||
|
||||
And neither covers Clapham, Shoreditch or Peckham, which are postal and
|
||||
colloquial rather than administrative.
|
||||
|
||||
**Resolution: a curated seed mapping locality to outcodes.**
|
||||
|
||||
```
|
||||
pipeline/transform/seeds/locality_outcodes.csv
|
||||
locality_slug,locality_name,outcodes,region
|
||||
battersea,Battersea,"SW11|SW8",London
|
||||
canary-wharf,Canary Wharf,"E14",London
|
||||
clapham,Clapham,"SW4|SW9",London
|
||||
```
|
||||
|
||||
This needs **no new ingestion** — the corpus already has postcodes. It puts
|
||||
the fuzzy, contested part of the problem in a reviewable file rather than in
|
||||
derived logic, which suits it: locality boundaries are a judgement, not a
|
||||
fact. The repo already uses dbt seeds for curated reference data
|
||||
(`la_code_names.csv`, `gias_code_names.csv`), so this follows an established
|
||||
pattern.
|
||||
|
||||
The seed generalises past London. Any colloquial place — Jesmond, Chorlton,
|
||||
Clifton — can be defined by its outcodes without a schema change.
|
||||
|
||||
**Constraint:** a locality slug may not collide with a viable town slug. The
|
||||
place registry enforces this and fails the build rather than silently
|
||||
shadowing a town.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The place registry
|
||||
|
||||
One module owns the question "what places do we publish, and what is in each".
|
||||
Everything else reads from it: the pages, the sitemap, the internal links.
|
||||
|
||||
```
|
||||
backend/places.py
|
||||
|
||||
Place = { kind: "town"|"locality"|"authority"|"outcode",
|
||||
slug, name, urn_list, parent_authority | None }
|
||||
|
||||
build_place_registry(df) -> dict[str, Place]
|
||||
place_schools(slug, phase=None) -> list[School]
|
||||
```
|
||||
|
||||
Built once at startup from the same DataFrame the sitemap uses, and rebuilt by
|
||||
the existing `/api/admin/regenerate-sitemap` path after a pipeline run.
|
||||
Registry construction is where the threshold, the collision rules and the
|
||||
seed's uniqueness constraint are enforced — in one place, testable without a
|
||||
browser or a database.
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
GET /api/places the registry: slug, kind, name, count
|
||||
GET /api/places/{slug}?phase= aggregate + ranked schools for one place
|
||||
```
|
||||
|
||||
`/api/places` is what the sitemap and the internal-link modules enumerate.
|
||||
|
||||
### Routes
|
||||
|
||||
Next App Router, ISR with the same 7-day revalidate the school pages use.
|
||||
`generateStaticParams` gated behind an env flag, matching
|
||||
`PRERENDER_SCHOOLS`, because 3,900 more routes cannot be statically built in
|
||||
CI on every deploy.
|
||||
|
||||
## What each page must contain
|
||||
|
||||
A place page that is a name substituted into a template is the thing Google's
|
||||
helpful-content stance exists to demote. Each page carries computed local
|
||||
facts that exist nowhere else on the site:
|
||||
|
||||
- **H1** matching the query: "Primary schools in Brentwood"
|
||||
- **Counts framed usefully**: "29 schools, 4 rated Outstanding"
|
||||
- **A ranked table** of the top 20 on the phase's headline metric —
|
||||
`rwm_expected_pct` for primary, `attainment_8_score` for secondary, and for
|
||||
an unphased place page the metric matching whichever phase it holds more of
|
||||
- **The local average against the England average** — the one number a parent
|
||||
cannot get from a list
|
||||
- **Ofsted grade distribution** for the place
|
||||
- **A map**
|
||||
- **Links to neighbouring places** and to the parent authority
|
||||
- **An FAQ block**, feeding `FAQPage` structured data
|
||||
- **A link to every school page in scope** — this is what finally de-orphans
|
||||
the 23,000 school pages the original spec identified as near-orphans
|
||||
|
||||
## Thin-page controls
|
||||
|
||||
Three, and they are the difference between a location layer and index bloat:
|
||||
|
||||
1. **Five schools with current data minimum.** Below it, 301 to the parent
|
||||
authority. This drops 907 towns and 305 outcodes.
|
||||
2. **Per-phase thresholds.** A town with 30 primaries and 2 secondaries
|
||||
publishes a primary page and no secondary page.
|
||||
3. **No page without a local average.** If a place has too few schools with
|
||||
results to compute one, it has nothing to say that a list does not, and it
|
||||
falls back to the authority.
|
||||
|
||||
## Sitemap
|
||||
|
||||
Two new children in the existing index: `/sitemaps/places-{n}.xml` and
|
||||
`/sitemaps/outcodes-{n}.xml`. Per-family children are why the index was built
|
||||
in W1 — Search Console reports coverage per submitted sitemap, so indexation
|
||||
of the location layer is measurable separately from the school pages.
|
||||
|
||||
## Testing
|
||||
|
||||
Per `CLAUDE.md`, user-facing behaviour extends `e2e/tests/journeys.spec.ts` in
|
||||
the same PR.
|
||||
|
||||
**Unit (registry, no DB):** threshold enforcement; a sub-threshold town
|
||||
resolves to its authority; a locality slug colliding with a town fails the
|
||||
build; Bedford's town and authority pages hold different URN sets; per-phase
|
||||
thresholds.
|
||||
|
||||
**Backend:** `/api/places` shape; `/api/places/{slug}` aggregate correctness
|
||||
against a fixture; unknown slug 404s.
|
||||
|
||||
**e2e:** a known town, authority, locality and outcode page each render with
|
||||
the expected count; a below-threshold town 301s; every place page declares a
|
||||
canonical and appears in the sitemap; `/schools/bedford` and
|
||||
`/schools/authority/bedford` both resolve and state which set they cover.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat** is the failure mode of every programmatic SEO programme. The
|
||||
three controls above are the answer, and the per-family sitemap is how we find
|
||||
out early if they were not enough.
|
||||
|
||||
**Helpful-content exposure.** Templated location pages are exactly what
|
||||
Google's stance targets. The mitigation is that every page carries real
|
||||
computed local data — counts, distributions, local-versus-national comparison
|
||||
— rather than a name dropped into boilerplate. If indexation of the places
|
||||
sitemap stalls below roughly half, that is the signal to stop and rethink
|
||||
rather than to add more pages.
|
||||
|
||||
**Build cost.** ~4,000 additional ISR routes on top of 23,000 school pages.
|
||||
The env-flag gate on `generateStaticParams` keeps CI viable.
|
||||
|
||||
**Curation drift.** The locality seed is hand-maintained and will go stale as
|
||||
places change. It is small and reviewable, and a dbt test asserts every seed
|
||||
outcode matches at least one school so a typo fails the pipeline rather than
|
||||
publishing an empty page.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Catchment-area estimation. It is a strong driver for this cluster and
|
||||
`fact_admissions` carries the distances, but it is a modelling problem with
|
||||
real accuracy risk and deserves its own design.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,591 @@
|
||||
<title>Last distance offered — detail page mockup</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
||||
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
||||
--accent-coral:#e07256; --accent-coral-dark:#b04a2e;
|
||||
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
|
||||
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
|
||||
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
|
||||
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
|
||||
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
|
||||
--serif:'Playfair Display',Georgia,'Iowan Old Style',serif;
|
||||
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
}
|
||||
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;
|
||||
padding:44px 20px 110px;font-variant-numeric:tabular-nums}
|
||||
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
|
||||
|
||||
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
|
||||
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
|
||||
.pagehead p + p{margin-top:8px}
|
||||
|
||||
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
||||
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
|
||||
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
|
||||
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
|
||||
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
|
||||
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
|
||||
|
||||
/* ---- card shell mirrors SchoolDetailView .card ---- */
|
||||
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow);padding:26px 26px 24px}
|
||||
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
|
||||
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
|
||||
|
||||
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
|
||||
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;
|
||||
color:var(--text-muted);padding:6px 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
|
||||
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
|
||||
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||
|
||||
/* ---- tiles ---- */
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
|
||||
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
|
||||
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
|
||||
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
|
||||
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
|
||||
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
|
||||
.tile.accent{background:var(--coral-bg)}
|
||||
.tile.accent .num{color:var(--accent-coral-dark)}
|
||||
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
|
||||
.tile.newtile .num{color:var(--accent-coral-dark)}
|
||||
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||||
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
|
||||
|
||||
/* ---- verdict banner ---- */
|
||||
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
|
||||
.verdict.hard{background:var(--coral-bg)}
|
||||
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
|
||||
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
|
||||
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
|
||||
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chartwrap{overflow-x:auto}
|
||||
.chart{display:block;width:100%;min-width:460px;height:auto}
|
||||
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
|
||||
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
|
||||
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||
.keyrow span{display:inline-flex;align-items:center;gap:7px}
|
||||
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
|
||||
.kdot.line{background:var(--accent-coral)}
|
||||
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
|
||||
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
|
||||
|
||||
/* ---- table ---- */
|
||||
.tblwrap{overflow-x:auto;margin-top:4px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
|
||||
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
||||
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
|
||||
thead th:first-child{text-align:left}
|
||||
tbody td{text-align:right;padding:11px 10px;border-bottom:1px solid var(--border)}
|
||||
tbody td:first-child{text-align:left;font-weight:600}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tbody tr.now{background:var(--bg-secondary)}
|
||||
td.miss{color:var(--text-muted);font-weight:400}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
||||
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
|
||||
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
|
||||
|
||||
/* ---- map ---- */
|
||||
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
|
||||
.mapsvg{display:block;width:100%;height:auto}
|
||||
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||
.maplegend span{display:inline-flex;align-items:center;gap:7px}
|
||||
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
|
||||
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
|
||||
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
|
||||
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
|
||||
|
||||
/* ---- postcode check ---- */
|
||||
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
|
||||
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
|
||||
.checkform{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
|
||||
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
|
||||
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
|
||||
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
|
||||
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
|
||||
.checkform button:hover{background:#9c3f26}
|
||||
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
|
||||
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
|
||||
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
|
||||
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
|
||||
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
|
||||
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
|
||||
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
|
||||
|
||||
/* ---- notes / disclosure ---- */
|
||||
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
|
||||
.note .i{flex:none;width:17px;height:17px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);
|
||||
font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
|
||||
.disclosure{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
|
||||
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;
|
||||
font-weight:600;font-size:14px;color:var(--accent-teal)}
|
||||
.disclosure>summary::-webkit-details-marker{display:none}
|
||||
.disclosure>summary:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
|
||||
.disclosure .chev{transition:transform .2s ease}
|
||||
.disclosure[open]>summary .chev{transform:rotate(90deg)}
|
||||
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
|
||||
.disclosure .body b{color:var(--text-primary)}
|
||||
|
||||
/* ---- viewport stack (keeps card height stable across views) ---- */
|
||||
.viewport{display:grid}
|
||||
.viewport>.view{grid-area:1/1}
|
||||
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
|
||||
|
||||
/* ---- edge cases ---- */
|
||||
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
|
||||
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
|
||||
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
|
||||
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
|
||||
.case .body strong{color:var(--text-primary)}
|
||||
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
|
||||
|
||||
/* ---- phone ---- */
|
||||
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
|
||||
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
|
||||
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
|
||||
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
|
||||
.phone .sectionTitle{font-size:18px}
|
||||
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
|
||||
.phone .tile{padding:11px 12px}
|
||||
.phone .tile .num{font-size:22px}
|
||||
.phone .chart{min-width:0}
|
||||
.phone .chartwrap{overflow:visible}
|
||||
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
|
||||
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
|
||||
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
|
||||
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>Last distance offered — school detail page</h1>
|
||||
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
|
||||
view on the map. Data covers one to ten years depending on the school and local authority, so every
|
||||
screen here is built around partial coverage rather than assuming a full run.</p>
|
||||
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
|
||||
</div>
|
||||
|
||||
<!-- ============ 1. ADMISSIONS CARD ============ -->
|
||||
<div class="step">
|
||||
<h2>1. A distance tile joins the admissions tiles</h2>
|
||||
<span class="where">SchoolDetailView · #admissions</span>
|
||||
</div>
|
||||
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
|
||||
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
|
||||
third view, <code>Distance</code>.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardhead">
|
||||
<h2 class="sectionTitle">Admissions</h2>
|
||||
<div class="seg" role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed="true" data-view="year">This year</button>
|
||||
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
|
||||
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sectionSub">Reception entry, September 2025.</p>
|
||||
|
||||
<div class="viewport">
|
||||
<!-- view: this year -->
|
||||
<div class="view" id="v-year">
|
||||
<dl class="tiles">
|
||||
<div class="tile">
|
||||
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
|
||||
</div>
|
||||
<div class="tile accent">
|
||||
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
|
||||
<dt class="lbl">Got their first choice</dt>
|
||||
</div>
|
||||
<div class="tile newtile">
|
||||
<span class="newflag">New</span>
|
||||
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||
<dt class="lbl">Last distance offered</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="note">
|
||||
<span class="i" aria-hidden="true">i</span>
|
||||
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
|
||||
It is not a fixed catchment — it moves every year with the number of applications.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- view: distance -->
|
||||
<div class="view" id="v-dist" hidden>
|
||||
<div class="verdict hard">
|
||||
<span class="vic" aria-hidden="true">↓</span>
|
||||
<div>
|
||||
<div class="vhead">The catchment has halved in nine years</div>
|
||||
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chartwrap">
|
||||
<svg class="chart" viewBox="0 0 700 250" role="img"
|
||||
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
|
||||
<!-- grid -->
|
||||
<g stroke="#e5dfd5" stroke-width="1">
|
||||
<line x1="46" y1="30" x2="686" y2="30"/>
|
||||
<line x1="46" y1="80" x2="686" y2="80"/>
|
||||
<line x1="46" y1="130" x2="686" y2="130"/>
|
||||
<line x1="46" y1="180" x2="686" y2="180"/>
|
||||
</g>
|
||||
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
|
||||
<g class="axtxt" text-anchor="end">
|
||||
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
|
||||
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
|
||||
</g>
|
||||
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
|
||||
|
||||
<!-- gap segments (dashed = no figure published) -->
|
||||
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
|
||||
<path d="M114 92 L182 110"/>
|
||||
<path d="M318 122 L386 132.5"/>
|
||||
</g>
|
||||
<!-- solid series -->
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="46,75 114,92"/>
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="182,110 250,102 318,122"/>
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="386,132.5 454,142.5 522,152.5"/>
|
||||
|
||||
<!-- points -->
|
||||
<g fill="#e07256" stroke="#fff" stroke-width="2">
|
||||
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
|
||||
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
|
||||
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
|
||||
<circle cx="454" cy="142.5" r="5"/>
|
||||
</g>
|
||||
<!-- latest, emphasised -->
|
||||
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
|
||||
|
||||
<!-- "no cut-off needed" marker, 2022 -->
|
||||
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
|
||||
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
|
||||
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
|
||||
|
||||
<!-- endpoint labels -->
|
||||
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
|
||||
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
|
||||
|
||||
<!-- year axis -->
|
||||
<g class="axtxt" text-anchor="middle">
|
||||
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
|
||||
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
|
||||
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
|
||||
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
|
||||
</g>
|
||||
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
|
||||
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="keyrow">
|
||||
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
|
||||
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
|
||||
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
|
||||
</div>
|
||||
|
||||
<div class="tblwrap" style="margin-top:22px">
|
||||
<table>
|
||||
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
|
||||
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
|
||||
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="disclosure">
|
||||
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
|
||||
<div class="body">
|
||||
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
|
||||
in a given year. Move one large sibling cohort and the figure shifts.</span>
|
||||
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
|
||||
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
|
||||
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
|
||||
authorities use walking routes, which are always longer for the same home.</span>
|
||||
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
|
||||
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 2. MAP ============ -->
|
||||
<div class="step">
|
||||
<h2>2. The cut-off drawn on the map</h2>
|
||||
<span class="where">SchoolHeroMap · catchment layer</span>
|
||||
</div>
|
||||
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
|
||||
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
|
||||
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardhead" style="margin-bottom:14px">
|
||||
<h2 class="sectionTitle">Where the last place went</h2>
|
||||
<div class="seg" role="group" aria-label="Map years">
|
||||
<button type="button" aria-pressed="true">2025 only</button>
|
||||
<button type="button" aria-pressed="false">Last 5 years</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="mapfig">
|
||||
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
|
||||
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
|
||||
<rect width="700" height="400" fill="#eef2ec"/>
|
||||
<!-- park -->
|
||||
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
|
||||
<!-- water -->
|
||||
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
|
||||
<!-- street grid -->
|
||||
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
|
||||
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
|
||||
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
|
||||
</g>
|
||||
<g stroke="#fff" stroke-width="3.5" opacity=".8">
|
||||
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
|
||||
</g>
|
||||
<!-- building blocks -->
|
||||
<g fill="#e4e2dc" opacity=".85">
|
||||
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
|
||||
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
|
||||
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- historic rings (hairline) -->
|
||||
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
|
||||
<circle cx="300" cy="200" r="150"/>
|
||||
<circle cx="300" cy="200" r="126"/>
|
||||
<circle cx="300" cy="200" r="106"/>
|
||||
<circle cx="300" cy="200" r="90"/>
|
||||
</g>
|
||||
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
|
||||
<!-- current ring -->
|
||||
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
|
||||
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
|
||||
|
||||
<!-- school pin -->
|
||||
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
|
||||
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
|
||||
|
||||
<!-- your home -->
|
||||
<g transform="translate(246,158)">
|
||||
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
|
||||
</g>
|
||||
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
<div class="maplegend">
|
||||
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
|
||||
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
|
||||
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<div class="checkhead">Would you have got in?</div>
|
||||
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
|
||||
<form class="checkform" onsubmit="return false">
|
||||
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
|
||||
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
|
||||
<button type="submit">Check</button>
|
||||
</form>
|
||||
|
||||
<div class="result">
|
||||
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
|
||||
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
|
||||
figure, and in 2022 every applicant was offered a place.</div>
|
||||
<div class="yearstrip">
|
||||
<span class="yr in">2016 ✓</span>
|
||||
<span class="yr in">2017 ✓</span>
|
||||
<span class="yr none">2018 –</span>
|
||||
<span class="yr in">2019 ✓</span>
|
||||
<span class="yr in">2020 ✓</span>
|
||||
<span class="yr in">2021 ✓</span>
|
||||
<span class="yr in">2022 ✓</span>
|
||||
<span class="yr in">2023 ✓</span>
|
||||
<span class="yr in">2024 ✓</span>
|
||||
<span class="yr in">2025 ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<span class="i" aria-hidden="true">!</span>
|
||||
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
|
||||
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 3. COVERAGE STATES ============ -->
|
||||
<div class="step">
|
||||
<h2>3. Coverage states</h2>
|
||||
<span class="where">Partial data is the normal case</span>
|
||||
</div>
|
||||
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
|
||||
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
|
||||
|
||||
<div class="cases">
|
||||
<div class="case">
|
||||
<h3>4+ years</h3>
|
||||
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
|
||||
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>2–3 years</h3>
|
||||
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
|
||||
<strong>"Only 3 years available"</strong> sits under the table.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>1 year</h3>
|
||||
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
|
||||
the whole story.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Never oversubscribed</h3>
|
||||
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
|
||||
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
|
||||
in each of the last 6 years, so no distance cut-off was needed.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>No data at all</h3>
|
||||
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
|
||||
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
|
||||
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
|
||||
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Not distance-ranked</h3>
|
||||
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
|
||||
practice, so a distance figure would mislead.</div>
|
||||
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 4. MOBILE ============ -->
|
||||
<div class="step">
|
||||
<h2>4. Mobile</h2>
|
||||
<span class="where">≤ 640 px</span>
|
||||
</div>
|
||||
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
|
||||
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
|
||||
|
||||
<div class="phonerow">
|
||||
<div class="phone">
|
||||
<div class="phonebody">
|
||||
<div class="card">
|
||||
<div class="cardhead" style="margin-bottom:6px">
|
||||
<h2 class="sectionTitle">Admissions</h2>
|
||||
</div>
|
||||
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
|
||||
<div class="seg" style="margin-bottom:14px">
|
||||
<button type="button" aria-pressed="true">Year</button>
|
||||
<button type="button" aria-pressed="false">Trend</button>
|
||||
<button type="button" aria-pressed="false">Distance</button>
|
||||
</div>
|
||||
<dl class="tiles">
|
||||
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
|
||||
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
|
||||
</dl>
|
||||
<div class="tile newtile" style="margin-top:8px">
|
||||
<span class="newflag">New</span>
|
||||
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
|
||||
<span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||
<dt class="lbl">Last distance offered, 2025</dt>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
|
||||
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
|
||||
<span class="vic" aria-hidden="true">↓</span>
|
||||
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
|
||||
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
|
||||
</div>
|
||||
<div class="chartwrap">
|
||||
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
|
||||
<g stroke="#e5dfd5" stroke-width="1">
|
||||
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
|
||||
</g>
|
||||
<g class="axtxt" text-anchor="end" font-size="9">
|
||||
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
|
||||
</g>
|
||||
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
|
||||
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
|
||||
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
|
||||
</g>
|
||||
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
|
||||
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
|
||||
<g class="axtxt" font-size="9" text-anchor="middle">
|
||||
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
|
||||
</g>
|
||||
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
|
||||
</svg>
|
||||
</div>
|
||||
<details class="disclosure" style="margin-top:10px">
|
||||
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phonenote">
|
||||
<h3>Mobile decisions</h3>
|
||||
<ul>
|
||||
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
|
||||
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
|
||||
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
|
||||
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Segmented control on the admissions card swaps the stacked views.
|
||||
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var group = btn.closest('.seg');
|
||||
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
|
||||
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
|
||||
['v-year', 'v-dist'].forEach(function (id) {
|
||||
document.getElementById(id).hidden = id !== target;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -47,6 +47,12 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Fonts for the generated share card. File tracing already places these in
|
||||
# .next/standalone/assets, so the COPY above carries them — this makes the
|
||||
# dependency explicit rather than implicit in the tracer's behaviour, because
|
||||
# a miss here is a silent 500 on /opengraph-image, not a build failure.
|
||||
COPY --from=builder /app/assets ./assets
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { metadata as homeMetadata } from '@/app/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/compare/page';
|
||||
|
||||
describe('canonical URLs', () => {
|
||||
it('the homepage canonicalises to the bare root', () => {
|
||||
// page.tsx reads eleven search params. Without this, every filter
|
||||
// combination is a crawlable near-duplicate of the one page we want to
|
||||
// rank for "compare schools".
|
||||
expect(homeMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
|
||||
it('rankings canonicalises to the bare path', () => {
|
||||
expect(rankingsMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('admissions canonicalises to the bare path', () => {
|
||||
expect(admissionsMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/admissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/compare indexability', () => {
|
||||
it('the bare compare page is indexable and canonical to itself', async () => {
|
||||
// This is the landing page for the "compare schools" head term.
|
||||
const meta = await compareMetadata({ searchParams: Promise.resolve({}) });
|
||||
expect(meta.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||
expect(meta.robots).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a comparison of specific schools is noindex, follow', async () => {
|
||||
// ~317 million pairs before triples. Indexing the parameter space would
|
||||
// swamp everything else in the corpus.
|
||||
const meta = await compareMetadata({
|
||||
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
||||
});
|
||||
expect(meta.robots).toEqual({ index: false, follow: true });
|
||||
});
|
||||
|
||||
it('a parameterised comparison still canonicalises to the bare path', async () => {
|
||||
// follow:true plus a canonical means the outbound links to each school
|
||||
// page still pass value even though this URL is not indexed.
|
||||
const meta = await compareMetadata({
|
||||
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
||||
});
|
||||
expect(meta.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* W8 — snippet copy for the C1 cluster.
|
||||
*
|
||||
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
||||
* page one and converting at a tenth of the normal rate: "compare school
|
||||
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
||||
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
||||
* "Compare school performance" service, so the job of this copy is to say
|
||||
* what that service does not offer, without losing intent match on the title.
|
||||
*
|
||||
* These tests guard the mechanics that make a snippet work — length, intent
|
||||
* keyword, differentiator, no brand-first — not the exact wording, which
|
||||
* should stay free to iterate.
|
||||
*/
|
||||
|
||||
// Google truncates titles near 60 characters and descriptions near 155.
|
||||
const TITLE_MAX = 60;
|
||||
const DESC_MIN = 110;
|
||||
const DESC_MAX = 155;
|
||||
|
||||
type Meta = { title?: unknown; description?: unknown };
|
||||
const titleOf = (m: Meta): string => {
|
||||
const t = m.title as string | { absolute?: string } | undefined;
|
||||
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
||||
};
|
||||
|
||||
describe('C1 snippet copy', () => {
|
||||
const pages: Array<[string, Meta, RegExp]> = [
|
||||
['home', homeMetadata as Meta, /compare schools/i],
|
||||
['rankings', rankingsMetadata as Meta, /league table/i],
|
||||
['admissions', admissionsMetadata as Meta, /admission/i],
|
||||
];
|
||||
|
||||
for (const [name, meta, intent] of pages) {
|
||||
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
||||
const t = titleOf(meta);
|
||||
expect(t).toMatch(intent);
|
||||
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
||||
});
|
||||
|
||||
it(`${name}: title does not open with the brand`, () => {
|
||||
// The measured 0.43% CTR came from a brand-first title. The most
|
||||
// valuable pixels go to the thing the searcher typed.
|
||||
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
||||
});
|
||||
|
||||
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
||||
const d = meta.description as string;
|
||||
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
||||
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
||||
});
|
||||
}
|
||||
|
||||
it('the homepage description names what gov.uk does not publish', () => {
|
||||
// Admissions distance is the one fact the DfE service has no equivalent
|
||||
// for. If it ever leaves this description, the snippet is competing with
|
||||
// gov.uk on gov.uk's own ground.
|
||||
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
||||
});
|
||||
|
||||
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
||||
// Two pages chasing one phrase is how a site competes with itself.
|
||||
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
||||
expect(m.title).toMatch(/comparison tool/i);
|
||||
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
||||
});
|
||||
});
|
||||
|
||||
it('no C1 page claims a school count that will drift', () => {
|
||||
// The corpus moves with every data refresh; this repo has already shipped
|
||||
// one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
|
||||
for (const [, meta] of pages) {
|
||||
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||
|
||||
jest.mock('@/lib/places', () => ({
|
||||
...jest.requireActual('@/lib/places'),
|
||||
fetchPlace: jest.fn(async (kind: string, slug: string) =>
|
||||
slug === 'atlantis' ? null : ({
|
||||
place: { kind, slug, name: 'Brentwood', count: 29,
|
||||
parent_authority: 'Essex' },
|
||||
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
})),
|
||||
fetchPlaces: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe('place page metadata', () => {
|
||||
it('titles the page the way the place is searched', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
|
||||
});
|
||||
|
||||
it('canonicalises to its own path on the www host', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect(m.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
||||
});
|
||||
|
||||
it('opts out of the layout template, which would double the brand', () => {
|
||||
// The root layout appends '| schoolcompare' to a plain string title, and
|
||||
// these titles already carry it — every place page shipped reading
|
||||
// '... | schoolcompare | schoolcompare' until this was made absolute.
|
||||
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
|
||||
.then((m) => {
|
||||
expect(typeof m.title).toBe('object');
|
||||
expect((m.title as { absolute: string }).absolute)
|
||||
.not.toMatch(/schoolcompare.*schoolcompare/);
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
||||
expect(m.title).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The postcode check.
|
||||
*
|
||||
* This is the one place on the site that answers a question about a specific
|
||||
* family rather than about a school, so the tests here are mostly about what it
|
||||
* refuses to say — and that matters more now than it did, because there is only
|
||||
* one year to answer with. A run of years used to soften a single close call;
|
||||
* nothing does now, so the "too close to call" band is the whole safety margin.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
||||
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
||||
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
|
||||
jest.mock('@/components/LeafletCutoffMapInner', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="cutoff-map" />,
|
||||
}));
|
||||
|
||||
const mockGeocode = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
...jest.requireActual('@/lib/api'),
|
||||
geocodePostcode: (pc: string) => mockGeocode(pc),
|
||||
}));
|
||||
|
||||
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
||||
|
||||
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
||||
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
});
|
||||
|
||||
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
|
||||
function northOf(metres: number) {
|
||||
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
|
||||
}
|
||||
|
||||
const renderPanel = (c = cutoff(800)) =>
|
||||
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
||||
|
||||
async function check(postcode: string) {
|
||||
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||
}
|
||||
|
||||
beforeEach(() => mockGeocode.mockReset());
|
||||
|
||||
describe('CutoffMapPanel', () => {
|
||||
it('renders nothing without a figure to compare against', () => {
|
||||
const { container } = renderPanel(cutoff(null));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when the school has no coordinates', () => {
|
||||
const { container } = render(
|
||||
<CutoffMapPanel
|
||||
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
||||
cutoff={cutoff(800)}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('rejects a malformed postcode without calling the geocoder', async () => {
|
||||
renderPanel();
|
||||
await check('not a postcode');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel(cutoff(800, 2026));
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/inside the/);
|
||||
expect(result).toHaveTextContent(/September 2026/);
|
||||
});
|
||||
|
||||
it('reports a home clearly beyond the cut-off', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(5000));
|
||||
renderPanel(cutoff(800));
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
||||
});
|
||||
|
||||
it('declines to call a result that sits inside the measurement error', async () => {
|
||||
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
|
||||
// which a postcode centroid cannot resolve. With only one year published
|
||||
// there is nothing else to fall back on, so this must not read as a pass.
|
||||
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
||||
renderPanel(cutoff(800));
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/too close/);
|
||||
expect(result).toHaveTextContent(/measurement error/);
|
||||
// Explanation is supporting text, not part of the bold verdict line.
|
||||
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
||||
.not.toMatch(/measurement error/);
|
||||
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
|
||||
});
|
||||
|
||||
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||
mockGeocode.mockResolvedValue(null);
|
||||
renderPanel();
|
||||
await check('ZZ99 9ZZ');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
||||
});
|
||||
|
||||
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel();
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
|
||||
mockGeocode.mockRejectedValue(new Error('network'));
|
||||
await check('SE23 3NB');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
||||
});
|
||||
|
||||
it('keeps the map behind a request until there is a reason to show it', async () => {
|
||||
renderPanel();
|
||||
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
|
||||
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can also show the map without a postcode, on request', () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
||||
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('states its limits before it is used, not with the answer', () => {
|
||||
renderPanel();
|
||||
const caveat = screen.getByText(/Distance is the last criterion applied/);
|
||||
expect(caveat).toBeInTheDocument();
|
||||
expect(caveat).toHaveTextContent(/not a catchment boundary/);
|
||||
expect(caveat).toHaveTextContent(/walking route/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
|
||||
describe('InfoPopover', () => {
|
||||
it('renders nothing when there is no plain content', () => {
|
||||
const { container } = render(<InfoPopover label="X" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders a labelled, collapsed trigger button', () => {
|
||||
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
|
||||
const btn = screen.getByRole('button', { name: 'Reading score' });
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on click and shows label, plain and detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InfoPopover
|
||||
label="Reading, Writing & Maths"
|
||||
plain="% reaching the expected standard"
|
||||
detail="National average ~60%"
|
||||
ariaLabel="RWM"
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'RWM' }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent('Reading, Writing & Maths');
|
||||
expect(tip).toHaveTextContent('% reaching the expected standard');
|
||||
expect(tip).toHaveTextContent('National average ~60%');
|
||||
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('closes again on a second click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
const btn = screen.getByRole('button', { name: 'Info' });
|
||||
await user.click(btn);
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
await user.click(screen.getByRole('button', { name: 'Info' }));
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults the accessible name when no ariaLabel is given', () => {
|
||||
render(<InfoPopover plain="body" />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'More information' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MetricTooltip } from '@/components/MetricTooltip';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
|
||||
describe('MetricTooltip', () => {
|
||||
it('resolves content from a metricKey', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const exp = METRIC_EXPLANATIONS[key];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} />);
|
||||
// Collapsed by default — the popover content is not in the DOM until opened
|
||||
// (the old component left an always-present role="tooltip" span behind).
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: `What does ${exp.label} mean?` }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent(exp.plain);
|
||||
});
|
||||
|
||||
it('renders nothing for an unknown metricKey with no explicit content', () => {
|
||||
const { container } = render(<MetricTooltip metricKey="__nope__" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('lets explicit props override the looked-up explanation', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} plain="custom text" />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Last distance offered, rendered on both detail templates.
|
||||
*
|
||||
* The figure is the one number on these pages that a parent may act on — it is
|
||||
* easy to read as "we live inside the catchment, we will get a place". These
|
||||
* tests pin the things that stop it being read that way: the year is always
|
||||
* present, the caveat is always present, and the blanket "not available"
|
||||
* sentence appears only when it is actually true.
|
||||
*/
|
||||
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||
year: 2025,
|
||||
distance_m: 500,
|
||||
route_count: 1,
|
||||
la_name: 'Camden',
|
||||
distance_unit_raw: 'miles',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('primary detail page', () => {
|
||||
it('shows the figure with the year it belongs to', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
|
||||
|
||||
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
||||
});
|
||||
|
||||
it('keeps the figure and its metric support readable as two numbers', () => {
|
||||
// They are flex children with a CSS gap and nothing between them in the
|
||||
// text layer, which read as "0.48 miles770 m" to a screen reader and to any
|
||||
// text matcher. Cheap to lose again, so pinned.
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49 }) });
|
||||
|
||||
const tile = document.querySelector('[class*="admissionsTileDistance"]')!;
|
||||
expect(tile.textContent).toMatch(/0\.48 miles\s+770 m/);
|
||||
expect(tile.textContent).not.toMatch(/miles\d/);
|
||||
});
|
||||
|
||||
it('never shows the figure without saying it is not a catchment', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
||||
|
||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags that a banded school\'s figure is the widest of several routes', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
|
||||
|
||||
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing distance-related when the LA publishes none', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
|
||||
|
||||
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('carries the figure even with no EES admissions row', () => {
|
||||
// The two sources are independent; this school has a cut-off and no
|
||||
// admissions figures. Before this feature the section did not render at all.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
admissionDistance: cutoff({ distance_m: 1421.05 }),
|
||||
});
|
||||
|
||||
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary detail page', () => {
|
||||
it('shows the figure with the year it belongs to', () => {
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
|
||||
|
||||
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
|
||||
});
|
||||
|
||||
it('drops the blanket "not available" line once a distance exists', () => {
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
|
||||
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the authority that would hold the data when there is none', () => {
|
||||
// The old copy asserted "Historical distance cut-off data is not available
|
||||
// for this school" on every secondary page, including the ones whose
|
||||
// council does publish it.
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
|
||||
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
describe('Distance section', () => {
|
||||
it('appears for a school with a figure and coordinates', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
expect(screen.getByText('How far away are you?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays away when the school has no coordinates to measure from', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: null, longitude: null },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays away when no figure has been published', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: null,
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no year-by-year record — that is held back as a paid feature', () => {
|
||||
// The page must not leak the history through a table, a chart or a strip of
|
||||
// per-year verdicts. The API no longer sends it either; this guards the
|
||||
// render side so a future component cannot quietly put it back.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
// Scoped to the section: the page has other tables (the history section's).
|
||||
const section = document.querySelector('#distance')!;
|
||||
expect(section.querySelector('table')).toBeNull();
|
||||
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not published/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/too few to read as a trend/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary Distance section', () => {
|
||||
it('renders on the secondary template too', () => {
|
||||
const { container } = renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 3472.96, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains a selective school by how it admits rather than as missing data', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
|
||||
admissionDistance: null,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: null,
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Characterization tests for the school detail pages.
|
||||
*
|
||||
* These describe behaviour that ALREADY EXISTS. They are written before the
|
||||
* server/client split and must pass UNMODIFIED after it — that is the whole
|
||||
* point. If one fails during the refactor, the refactor is wrong; fix the
|
||||
* components, never these assertions.
|
||||
*
|
||||
* All rendering goes through renderSchoolDetail / renderSecondarySchoolDetail,
|
||||
* the one file the refactor is permitted to change.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
|
||||
// Chart.js needs a canvas jsdom does not provide, and Leaflet needs a real
|
||||
// window. Both are already lazy client islands; their content is out of scope.
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
describe('Ofsted section', () => {
|
||||
it('renders the legacy OEIF grade, date and report link', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Rating')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inspected 17 May 2023/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Good').length).toBeGreaterThan(0);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Ofsted reports/ });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining(String(primaryFixture.schoolInfo.urn)),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the previous grade when it differs from the current one', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText(/Previously:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Report Card layout with its category bands', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Report Card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/From November 2025, Ofsted replaced single overall grades/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Safeguarding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Met')).toBeInTheDocument();
|
||||
expect(screen.getByText('Achievement')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS2 results', () => {
|
||||
it('renders the combined RWM figure with its England comparison', async () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('58%')).toBeInTheDocument();
|
||||
// 58 vs an England average of 61 → a -3 pts chip and the hint line.
|
||||
expect(await screen.findByText('England avg: 61%')).toBeInTheDocument();
|
||||
expect(await screen.findByText('-3 pts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS4 results', () => {
|
||||
// These figures appear in both the hero stats and the history table, so
|
||||
// assert on presence rather than uniqueness.
|
||||
it('renders Attainment 8, Progress 8 and EBacc figures', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getAllByText('46.2').length).toBeGreaterThan(0);
|
||||
// formatProgress rounds to 1 decimal and signs positives: 0.31 → "+0.3"
|
||||
expect(screen.getAllByText(/\+0\.3/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/EBacc/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-through schools', () => {
|
||||
// The highest-value assertion in this file. isAllThrough gates the KS2
|
||||
// content back on for a school that also has KS4 data, so an all-through
|
||||
// page must show BOTH key stages. This is the case most likely to break.
|
||||
it('renders both KS2 and KS4 figures on the same page', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
|
||||
expect(screen.getAllByText('63%').length).toBeGreaterThan(0); // KS2 RWM
|
||||
expect(screen.getAllByText('48.7').length).toBeGreaterThan(0); // KS4 Attainment 8
|
||||
});
|
||||
|
||||
it('labels the KS2 block explicitly', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('special schools', () => {
|
||||
// Guards the PR #70 regression, where a special school displayed
|
||||
// "0% -62 below England" against a mainstream benchmark that does not fit.
|
||||
it('suppresses the England comparison', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.queryByText(/England avg:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why the comparison is missing', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('This is a special school.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admissions', () => {
|
||||
it('toggles between the year view and the multi-year trend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
const yearBtn = screen.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = screen.getByRole('button', { name: /3-year trend/ });
|
||||
expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const yearView = container.querySelector('[class*="admissionsViewYear"]')!;
|
||||
const trendView = container.querySelector('[class*="admissionsViewTrend"]')!;
|
||||
expect(yearView).not.toHaveAttribute('hidden');
|
||||
expect(trendView).toHaveAttribute('hidden');
|
||||
|
||||
await user.click(trendBtn);
|
||||
|
||||
expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(yearView).toHaveAttribute('hidden');
|
||||
expect(trendView).not.toHaveAttribute('hidden');
|
||||
});
|
||||
|
||||
it('renders the headline admissions figures', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText('Places offered')).toBeInTheDocument();
|
||||
expect(screen.getByText('Got their first choice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the toggle when there is only one year of offer data', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
expect(screen.queryByRole('button', { name: 'This year' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section navigation', () => {
|
||||
it('lists the sections that have data', () => {
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
for (const id of ['ofsted', 'results', 'admissions', 'history', 'finances']) {
|
||||
expect(container.querySelector(`#${id}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits sections with no data', () => {
|
||||
const { container } = renderSchoolDetail(specialFixture);
|
||||
|
||||
expect(container.querySelector('#ofsted')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('#admissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Guards against "stranded" CSS: when markup moves between components, its
|
||||
* rules must move with it. CSS Modules hash class names per file, so a rule
|
||||
* left behind in another stylesheet silently stops applying — exactly how the
|
||||
* header's mobile `.headerDetails { display: none }` (and the section-nav
|
||||
* mobile swap) stopped working when the detail view was split into a client
|
||||
* shell plus server sections.
|
||||
*/
|
||||
|
||||
const SCHOOL_DIR = path.join(__dirname, '..', '..', 'components', 'school');
|
||||
|
||||
function classesDefinedIn(css: string): Set<string> {
|
||||
return new Set(Array.from(css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
function classesUsedIn(tsx: string): Set<string> {
|
||||
return new Set(Array.from(tsx.matchAll(/\bstyles\.([_a-zA-Z][\w]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
const components = fs
|
||||
.readdirSync(SCHOOL_DIR)
|
||||
.filter((f) => f.endsWith('.tsx'))
|
||||
.map((file) => {
|
||||
const source = fs.readFileSync(path.join(SCHOOL_DIR, file), 'utf8');
|
||||
const importMatch = source.match(/import\s+styles\s+from\s+['"](.+?\.module\.css)['"]/);
|
||||
return { file, source, stylesheet: importMatch?.[1] };
|
||||
})
|
||||
.filter((c) => c.stylesheet);
|
||||
|
||||
describe('school detail stylesheets', () => {
|
||||
it.each(components.map((c) => [c.file, c.source, c.stylesheet as string]))(
|
||||
'%s only uses classes its own stylesheet defines',
|
||||
(_file, source, stylesheet) => {
|
||||
const css = fs.readFileSync(path.join(SCHOOL_DIR, stylesheet), 'utf8');
|
||||
const defined = classesDefinedIn(css);
|
||||
const missing = Array.from(classesUsedIn(source)).filter((c) => !defined.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RowLabel } from '@/components/compare/sectionShared';
|
||||
|
||||
describe('RowLabel', () => {
|
||||
it('renders its label text', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no help affordance when no tip is given', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the tip in a popover on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'Average GCSE score across 8 subjects',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Last distance offered — formatting and the caveats attached to the figure.
|
||||
*
|
||||
* The assertions about the route note and the year are not cosmetic. A cut-off
|
||||
* shown without its year, or a banded school's widest cut-off shown as if it
|
||||
* were the only one, tells a parent something false about their chances of a
|
||||
* place — so both are pinned here rather than left to the component.
|
||||
*/
|
||||
|
||||
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||
import {
|
||||
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
||||
} from '@/components/school/lastDistanceOffered';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||
year: 2025,
|
||||
distance_m: 500,
|
||||
route_count: 1,
|
||||
la_name: 'Camden',
|
||||
distance_unit_raw: 'miles',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('formatCutoffDistance', () => {
|
||||
it('leads with miles, the unit councils publish in', () => {
|
||||
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
||||
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
||||
});
|
||||
|
||||
it('switches to kilometres for the support figure above a kilometre', () => {
|
||||
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
||||
});
|
||||
|
||||
it('stays in miles at short range, where it used to swap to metres', () => {
|
||||
// The swap made a single number easier to read and a comparison harder:
|
||||
// "69 m away — inside the cut-off of 0.17 miles" asked the reader to
|
||||
// convert between units to check a claim we had already made for them.
|
||||
expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' });
|
||||
expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/);
|
||||
});
|
||||
|
||||
it('describes a distance too short for two decimal places', () => {
|
||||
// Rather than a flat "0.00 miles", which reads as no distance at all.
|
||||
expect(formatMiles(5)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(0)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(20)).toBe('0.01 miles');
|
||||
});
|
||||
|
||||
it('returns null rather than a zero cut-off', () => {
|
||||
// 0.0 miles appears in the source where a school filled on a higher
|
||||
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
||||
expect(formatCutoffDistance(0)).toBeNull();
|
||||
expect(formatCutoffDistance(null)).toBeNull();
|
||||
expect(formatCutoffDistance(undefined)).toBeNull();
|
||||
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEntryYear', () => {
|
||||
it('names the intake, not the academic year', () => {
|
||||
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
||||
// school year rather than the September a child started.
|
||||
expect(formatEntryYear(2025)).toBe('September 2025');
|
||||
expect(formatEntryYear(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoff', () => {
|
||||
it('always carries the entry year alongside the figure', () => {
|
||||
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
||||
expect(d).not.toBeNull();
|
||||
expect(d!.primary).toBe('0.48 miles');
|
||||
expect(d!.entryYear).toBe('September 2024');
|
||||
});
|
||||
|
||||
it('says nothing about routes for a school with one', () => {
|
||||
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
||||
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
||||
});
|
||||
|
||||
it('warns that a banded school\'s figure is the widest of several', () => {
|
||||
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
||||
expect(note).toContain('4 admission routes');
|
||||
expect(note).toContain('shorter cut-off');
|
||||
});
|
||||
|
||||
it('is null when there is nothing publishable', () => {
|
||||
expect(describeCutoff(null)).toBeNull();
|
||||
expect(describeCutoff(undefined)).toBeNull();
|
||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── "Would we have got in?" ────────────────────────────────────────────
|
||||
|
||||
describe('compareToCutoff', () => {
|
||||
it('never states the two figures in different units', () => {
|
||||
/*
|
||||
* The reported defect: "69 m away — inside the September 2026 cut-off of
|
||||
* 0.17 miles". Both numbers are correct and the sentence is still useless,
|
||||
* because checking it means converting one of them.
|
||||
*
|
||||
* Swept across the range where the old formatter switched units, so a
|
||||
* future readability tweak to one figure cannot reintroduce the mismatch
|
||||
* in the other.
|
||||
*/
|
||||
const mixed: string[] = [];
|
||||
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
|
||||
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
|
||||
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
|
||||
const hasMetres = /\d\s?m\b/.test(headline);
|
||||
const milesCount = (headline.match(/miles/g) ?? []).length;
|
||||
// Two figures, both in miles, and no metric reading anywhere near them.
|
||||
if (hasMetres || milesCount !== 2) {
|
||||
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads back the reported case in one unit', () => {
|
||||
expect(compareToCutoff(69, 270, 2026).headline)
|
||||
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
|
||||
});
|
||||
|
||||
it('calls a clearly nearer home inside, and names the year', () => {
|
||||
const r = compareToCutoff(300, 800, 2026);
|
||||
expect(r.verdict).toBe('inside');
|
||||
expect(r.headline).toContain('September 2026');
|
||||
});
|
||||
|
||||
it('calls a clearly further home beyond', () => {
|
||||
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
||||
});
|
||||
|
||||
it('refuses to call a result inside the measurement error, either way', () => {
|
||||
// A postcode centroid covers several addresses, so a margin this fine is
|
||||
// noise. With one published year there is no other year to fall back on,
|
||||
// which makes this band the only thing standing between a parent and a
|
||||
// place they do not have.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
|
||||
// The explanation is not welded to the headline, so it does not run at
|
||||
// headline weight in the result block.
|
||||
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
|
||||
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the band as exclusive at its edge', () => {
|
||||
// Exactly on the boundary is still too close; one metre past it is not.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoffAbsence', () => {
|
||||
it('explains a selective school by how it admits, not as missing data', () => {
|
||||
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
||||
expect(s).toContain('entrance test');
|
||||
expect(s).not.toContain('has not published');
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
const s = describeCutoffAbsence({
|
||||
localAuthority: 'Camden',
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
expect(s).toContain('has not needed a distance cut-off');
|
||||
});
|
||||
|
||||
it('otherwise names the authority that would hold the figure', () => {
|
||||
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
|
||||
.toContain('Camden has not published');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isPrimary).toBe(true);
|
||||
expect(f.isSecondary).toBe(false);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies an ordinary secondary', () => {
|
||||
const f = computeSchoolFlags(secondaryFixture);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.isAllThrough).toBe(false);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
expect(f.suppressKs4Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isSpecial).toBe(false);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a gender split only when census counts are present', () => {
|
||||
expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true);
|
||||
expect(
|
||||
computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
expect(ids).toContain('history');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const label = (fixture: any) => {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
return buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
}).find((n) => n.id === 'results')?.label;
|
||||
};
|
||||
|
||||
expect(label(primaryFixture)).toBe('SATs');
|
||||
expect(label(secondaryFixture)).toBe('GCSEs');
|
||||
expect(label(allThroughFixture)).toBe('Results');
|
||||
});
|
||||
|
||||
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
|
||||
// 3% of the schools that render have one source and not the other. The nav
|
||||
// condition and the section's render condition have to agree, or the sticky
|
||||
// nav links to an anchor that was never rendered.
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null,
|
||||
admissions: null,
|
||||
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
|
||||
yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toContain('admissions');
|
||||
});
|
||||
|
||||
it('keeps the engagement-led ordering', () => {
|
||||
const flags = computeSchoolFlags(primaryFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: primaryFixture.ofsted,
|
||||
admissions: primaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual([
|
||||
'ofsted', 'results', 'admissions', 'inclusion',
|
||||
'history', 'school-life', 'local-area', 'finances',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSecondaryFlags', () => {
|
||||
it('reads results and sixth form from the secondary fixture', () => {
|
||||
const f = computeSecondaryFlags(secondaryFixture);
|
||||
expect(f.hasResults).toBe(true);
|
||||
expect(f.hasSixthForm).toBe(false);
|
||||
expect(f.hasWellbeing).toBe(true);
|
||||
});
|
||||
|
||||
it('reports sixth form when GIAS flags it', () => {
|
||||
expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the comparison for a special school', () => {
|
||||
const f = computeSecondaryFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressComparison).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => {
|
||||
expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSecondaryNavItems', () => {
|
||||
it('uses the secondary section ids', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: secondaryFixture.ofsted,
|
||||
admissions: secondaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||
});
|
||||
|
||||
it('opens the admissions entry for a cut-off distance alone', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null,
|
||||
admissions: null,
|
||||
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
|
||||
yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toContain('admissions');
|
||||
});
|
||||
|
||||
it('gates History on more than one year, unlike the primary page', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('history');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SITE_URL, absoluteUrl } from '@/lib/site';
|
||||
|
||||
describe('SITE_URL', () => {
|
||||
it('is the www host, which is the one that serves a 200', () => {
|
||||
// The apex 301s to www at Cloudflare. A canonical pointing at a redirect
|
||||
// is a wasted signal, so every absolute URL we emit must already be www.
|
||||
expect(SITE_URL).toBe('https://www.schoolcompare.co.uk');
|
||||
});
|
||||
|
||||
it('has no trailing slash, so joins never double up', () => {
|
||||
expect(SITE_URL.endsWith('/')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('joins a rooted path', () => {
|
||||
expect(absoluteUrl('/rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('joins a path missing its leading slash', () => {
|
||||
expect(absoluteUrl('rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('maps the site root to a bare trailing slash', () => {
|
||||
expect(absoluteUrl('/')).toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* This file is the ONLY thing the server/client split was allowed to change.
|
||||
* It now renders the shell + server-sections composition that
|
||||
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
|
||||
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
|
||||
* that is the proof the refactor preserved behaviour.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { nationalAveragesFixture } from './schoolFixtures';
|
||||
|
||||
// The shell calls useComparison(), which throws outside the provider. In the
|
||||
// app this wrapper comes from app/layout.tsx.
|
||||
function withProviders(ui: ReactNode) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
{...fixture}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
const flags = computeSecondaryFlags(fixture);
|
||||
const navItems = buildSecondaryNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
{...fixture}
|
||||
admissionsHistory={fixture.admissionsHistory ?? []}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Fixtures for the school detail characterization tests.
|
||||
*
|
||||
* Four shapes, each pinning a branch the detail views take:
|
||||
* primary — KS2 results, legacy OEIF Ofsted, 3 years of admissions
|
||||
* (enough for the year/trend toggle)
|
||||
* secondary — KS4 results, Report Card Ofsted, 1 admissions year
|
||||
* (no toggle)
|
||||
* allThrough — BOTH KS2 and KS4, so the primary content is gated back on
|
||||
* special — a special school with an all-zero KS2 row, where the
|
||||
* England comparison must be suppressed
|
||||
*
|
||||
* Field names come from lib/types.ts. If TypeScript rejects something here,
|
||||
* the fixture is wrong — never widen the type to accommodate a fixture.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders — SchoolResult has 57 required nullable fields, so every fixture
|
||||
// starts from an all-null row and overrides only what it is testing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptyResult: SchoolResult = {
|
||||
school_id: 1,
|
||||
year: 2024,
|
||||
total_pupils: null, eligible_pupils: null,
|
||||
rwm_expected_pct: null, reading_expected_pct: null, writing_expected_pct: null,
|
||||
maths_expected_pct: null, gps_expected_pct: null, science_expected_pct: null,
|
||||
rwm_high_pct: null, reading_high_pct: null, writing_high_pct: null,
|
||||
maths_high_pct: null, gps_high_pct: null,
|
||||
reading_progress: null, writing_progress: null, maths_progress: null,
|
||||
reading_avg_score: null, maths_avg_score: null, gps_avg_score: null,
|
||||
disadvantaged_pct: null, eal_pct: null, sen_support_pct: null, sen_ehcp_pct: null,
|
||||
stability_pct: null,
|
||||
reading_absence_pct: null, gps_absence_pct: null, maths_absence_pct: null,
|
||||
writing_absence_pct: null, science_absence_pct: null,
|
||||
rwm_expected_boys_pct: null, rwm_expected_girls_pct: null,
|
||||
rwm_high_boys_pct: null, rwm_high_girls_pct: null,
|
||||
rwm_expected_disadvantaged_pct: null, rwm_expected_non_disadvantaged_pct: null,
|
||||
disadvantaged_gap: null,
|
||||
rwm_expected_3yr_pct: null, reading_avg_3yr: null, maths_avg_3yr: null,
|
||||
attainment_8_score: null, progress_8_score: null,
|
||||
progress_8_lower_ci: null, progress_8_upper_ci: null,
|
||||
progress_8_english: null, progress_8_maths: null,
|
||||
progress_8_ebacc: null, progress_8_open: null,
|
||||
english_maths_strong_pass_pct: null, english_maths_standard_pass_pct: null,
|
||||
ebacc_entry_pct: null, ebacc_strong_pass_pct: null, ebacc_standard_pass_pct: null,
|
||||
ebacc_avg_score: null, gcse_grade_91_pct: null, prior_attainment_avg: null,
|
||||
};
|
||||
|
||||
export function makeResult(overrides: Partial<SchoolResult> = {}): SchoolResult {
|
||||
return { ...emptyResult, ...overrides };
|
||||
}
|
||||
|
||||
function makeSchool(overrides: Partial<School> = {}): School {
|
||||
return {
|
||||
urn: 123456,
|
||||
school_name: 'Test Primary School',
|
||||
local_authority: 'Westshire',
|
||||
local_authority_code: 900,
|
||||
school_type: 'Community school',
|
||||
school_type_code: 'CY',
|
||||
religious_denomination: 'None',
|
||||
age_range: '4-11',
|
||||
address1: '1 Test Lane',
|
||||
address2: null,
|
||||
town: 'Testville',
|
||||
postcode: 'TE1 1ST',
|
||||
address: '1 Test Lane, Testville, TE1 1ST',
|
||||
latitude: 51.5,
|
||||
longitude: -0.12,
|
||||
phase: 'Primary',
|
||||
gender: 'Mixed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyOfsted: OfstedInspection = {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
report_url: null,
|
||||
};
|
||||
|
||||
function makeOfsted(overrides: Partial<OfstedInspection> = {}): OfstedInspection {
|
||||
return { ...emptyOfsted, ...overrides };
|
||||
}
|
||||
|
||||
function makeAdmissions(overrides: Partial<SchoolAdmissions> = {}): SchoolAdmissions {
|
||||
return {
|
||||
year: 2024,
|
||||
places_offered: 60,
|
||||
total_applications: 210,
|
||||
first_preference_applications: 90,
|
||||
first_preference_offers: 54,
|
||||
first_preference_offer_pct: 60,
|
||||
oversubscribed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const census: SchoolCensus = {
|
||||
year: 2024,
|
||||
total_pupils: 420,
|
||||
female_pupils: 205,
|
||||
male_pupils: 215,
|
||||
fsm_pct: 18.4,
|
||||
eal_pct: 22.1,
|
||||
};
|
||||
|
||||
const absence: AbsenceData = {
|
||||
overall_absence_rate: 5.2,
|
||||
persistent_absence_rate: 14.8,
|
||||
};
|
||||
|
||||
const deprivation: SchoolDeprivation = {
|
||||
lsoa_code: 'E01000001',
|
||||
idaci_score: 0.21,
|
||||
idaci_decile: 4,
|
||||
};
|
||||
|
||||
const finance: SchoolFinance = {
|
||||
year: 2024,
|
||||
per_pupil_spend: 5400,
|
||||
staff_cost_pct: 72.5,
|
||||
teacher_cost_pct: 51.2,
|
||||
support_staff_cost_pct: 21.3,
|
||||
premises_cost_pct: 6.1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// National averages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const nationalAveragesFixture: NationalAverages = {
|
||||
year: 2024,
|
||||
primary: { rwm_expected_pct: 61, rwm_high_pct: 8, reading_expected_pct: 74 },
|
||||
secondary: { attainment_8_score: 45.9, progress_8_score: 0, ebacc_entry_pct: 39 },
|
||||
by_year: [
|
||||
{ year: 2023, primary: { rwm_expected_pct: 60 }, secondary: { attainment_8_score: 44.4 } },
|
||||
{ year: 2024, primary: { rwm_expected_pct: 61 }, secondary: { attainment_8_score: 45.9 } },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 1: ordinary primary — KS2 results, legacy OEIF, 3 admissions years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const primaryFixture = {
|
||||
schoolInfo: makeSchool(),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2022, rwm_expected_pct: 54, reading_expected_pct: 68, writing_expected_pct: 62, maths_expected_pct: 65, total_pupils: 58 }),
|
||||
makeResult({ year: 2023, rwm_expected_pct: 56, reading_expected_pct: 70, writing_expected_pct: 64, maths_expected_pct: 67, total_pupils: 60 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 58, rwm_high_pct: 9,
|
||||
reading_expected_pct: 72, writing_expected_pct: 66, maths_expected_pct: 69,
|
||||
reading_progress: 0.4, writing_progress: -0.2, maths_progress: 1.1,
|
||||
disadvantaged_pct: 24.5, eal_pct: 22.1, sen_support_pct: 13.2,
|
||||
total_pupils: 60,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2023-05-17',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 2,
|
||||
quality_of_education: 2,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 2,
|
||||
leadership_management: 2,
|
||||
previous_overall: 3,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024 }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2022, first_preference_offer_pct: 71 }),
|
||||
makeAdmissions({ year: 2023, first_preference_offer_pct: 65 }),
|
||||
makeAdmissions({ year: 2024, first_preference_offer_pct: 60 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 2: secondary — KS4 results, Report Card Ofsted, single admissions year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const secondaryFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 234567,
|
||||
school_name: 'Test Secondary School',
|
||||
phase: 'Secondary',
|
||||
age_range: '11-16',
|
||||
school_type: 'Academy converter',
|
||||
school_type_code: 'AC',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, attainment_8_score: 44.8, progress_8_score: 0.18 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
attainment_8_score: 46.2, progress_8_score: 0.31,
|
||||
progress_8_banding: 'Above average',
|
||||
english_maths_standard_pass_pct: 68.4,
|
||||
english_maths_strong_pass_pct: 47.1,
|
||||
ebacc_entry_pct: 41.2, ebacc_standard_pass_pct: 28.6,
|
||||
ebacc_strong_pass_pct: 19.4, ebacc_avg_score: 4.31,
|
||||
disadvantaged_pct: 27.8, eal_pct: 19.3, sen_support_pct: 11.7,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'ReportCard',
|
||||
inspection_date: '2025-11-20',
|
||||
rc_inspection_date: '2025-11-20',
|
||||
// isReportCard keys off a non-empty report_card record, NOT the rc_*
|
||||
// fields — the rc_* numbers supply the values once the layout is chosen.
|
||||
report_card: {
|
||||
inclusion: { code: 2, label: 'Strong' },
|
||||
curriculum_teaching: { code: 2, label: 'Strong' },
|
||||
achievement: { code: 3, label: 'Expected standard' },
|
||||
attendance_behaviour: { code: 2, label: 'Strong' },
|
||||
personal_development: { code: 1, label: 'Exceptional' },
|
||||
leadership_governance: { code: 2, label: 'Strong' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
rc_inclusion: 2,
|
||||
rc_curriculum_teaching: 2,
|
||||
rc_achievement: 3,
|
||||
rc_attendance_behaviour: 2,
|
||||
rc_personal_development: 1,
|
||||
rc_leadership_governance: 2,
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [makeAdmissions({ year: 2024, school_phase: 'Secondary' })],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 3: all-through — BOTH key stages on the same row.
|
||||
// The highest-value fixture: isAllThrough gates the KS2 content back on for a
|
||||
// school that also has KS4 data, and this is the case most likely to break.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const allThroughFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 345678,
|
||||
school_name: 'Test All-Through School',
|
||||
phase: 'All-through',
|
||||
age_range: '4-18',
|
||||
has_sixth_form: true,
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, rwm_expected_pct: 55, attainment_8_score: 45.0 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 63, reading_expected_pct: 76,
|
||||
writing_expected_pct: 70, maths_expected_pct: 71,
|
||||
attainment_8_score: 48.7, progress_8_score: 0.42,
|
||||
english_maths_standard_pass_pct: 71.2,
|
||||
disadvantaged_pct: 20.1, eal_pct: 17.5, sen_support_pct: 9.8,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2022-10-04',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 1,
|
||||
quality_of_education: 1,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 1,
|
||||
leadership_management: 1,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2023, school_phase: 'Secondary', first_preference_offer_pct: 58 }),
|
||||
makeAdmissions({ year: 2024, school_phase: 'Secondary', first_preference_offer_pct: 52 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 4: special school — all-zero KS2 row.
|
||||
// Guards the PR #70 regression: special/PRU/AP schools must not be shown an
|
||||
// England comparison, because their pupils sit the same tests but very few
|
||||
// reach the mainstream "expected standard".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const specialFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 456789,
|
||||
school_name: 'Test Special School',
|
||||
school_type: 'Community special school',
|
||||
school_type_code: 'CYS',
|
||||
age_range: '2-19',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0,
|
||||
writing_expected_pct: 0,
|
||||
maths_expected_pct: 0,
|
||||
total_pupils: 12,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: null,
|
||||
census,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Admissions Guide',
|
||||
// Deadlines and offer days are what gets searched, and what this page is
|
||||
// genuinely best at — the countdowns are live.
|
||||
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
||||
description:
|
||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
||||
alternates: { canonical: absoluteUrl('/admissions') },
|
||||
};
|
||||
|
||||
export default function AdmissionsPage() {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -5,6 +5,7 @@
|
||||
|
||||
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface ComparePageProps {
|
||||
@@ -14,13 +15,36 @@ interface ComparePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Compare Schools',
|
||||
/**
|
||||
* Indexability depends on the query string, so this cannot be a static export.
|
||||
*
|
||||
* Bare /compare is the landing page for the "compare schools" head term and
|
||||
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
||||
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
||||
* keeps a canonical to the bare path, so the links out to each school page
|
||||
* still count.
|
||||
*/
|
||||
export async function generateMetadata(
|
||||
{ searchParams }: ComparePageProps,
|
||||
): Promise<Metadata> {
|
||||
const { urns } = await searchParams;
|
||||
|
||||
const base: Metadata = {
|
||||
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||
// schools" is how a site competes with itself; this one takes the tool
|
||||
// phrasing instead.
|
||||
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
||||
description:
|
||||
'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') },
|
||||
};
|
||||
|
||||
if (!urns) return base;
|
||||
|
||||
return { ...base, robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
+464
-1912
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+47
-29
@@ -1,23 +1,29 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import { Manrope, Inter } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
// modern personality". It never sets body copy or a control.
|
||||
const manrope = Manrope({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-dm-sans',
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
// Inter carries body copy, every control and every figure — chosen in the
|
||||
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['600', '700'],
|
||||
variable: '--font-playfair',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -27,42 +33,47 @@ export const viewport: Viewport = {
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'SchoolCompare',
|
||||
title: 'schoolcompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
default: 'Compare Schools Side by Side | schoolcompare',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/favicon.svg',
|
||||
},
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
metadataBase: new URL(SITE_URL),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
url: 'https://schoolcompare.co.uk',
|
||||
siteName: 'SchoolCompare',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
url: SITE_URL,
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,7 +83,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
// var(--font-inter), and a custom property's var() references resolve on
|
||||
// the element that declares it. With the classes on <body>, those two are
|
||||
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
||||
// value and every font-family referencing it silently falls back — which
|
||||
// once shipped the whole site in Times. Nothing throws and no unit test
|
||||
// fails, so the e2e typeface assertion is the only guard.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
@@ -94,8 +113,7 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
<body>
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
/**
|
||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||
* intent channel this product has.
|
||||
*
|
||||
* The card is the brand's own proposition on the Sand ground, with the mark
|
||||
* and a suggestion of the illustration's rolling landscape along the bottom.
|
||||
* It states the promise rather than decorating it.
|
||||
*/
|
||||
export const alt = 'schoolcompare — find the school where your child will flourish';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const SAND = '#F5EFE6';
|
||||
const INK = '#1C2731';
|
||||
const MUTED = '#5F6A75';
|
||||
const TEAL = '#0F766E';
|
||||
const SAGE = '#A7D7C5';
|
||||
const SAGE_DEEP = '#7FC3AC';
|
||||
const SAGE_PALE = '#D6EDE2';
|
||||
|
||||
async function font(file: string) {
|
||||
return readFile(join(process.cwd(), 'assets', file));
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark, as a data URI. Satori has no access to the public/ URL space, so
|
||||
* the artwork is read off disk and inlined. This is the same file the header
|
||||
* serves — public/brand/mark.png — so the card can never drift from the site.
|
||||
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
|
||||
* this has to move with it, or Satori will stretch it.
|
||||
*/
|
||||
async function markDataUri() {
|
||||
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold, mark] = await Promise.all([
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
markDataUri(),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: SAND,
|
||||
padding: '58px 72px 0',
|
||||
fontFamily: 'Manrope',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Rolling hills along the bottom edge — the illustration style,
|
||||
reduced to the one gesture that survives at thumbnail size. */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={mark} width={47} height={56} alt="" />
|
||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||
<span style={{ color: INK }}>school</span>
|
||||
<span style={{ color: TEAL }}>compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.035em',
|
||||
lineHeight: 1.05,
|
||||
color: INK,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||
<span style={{ marginRight: 18 }}>your child will</span>
|
||||
<span style={{ color: TEAL }}>flourish.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
+49
-6
@@ -3,7 +3,10 @@
|
||||
* Main landing page with school search and browsing
|
||||
*/
|
||||
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
@@ -24,9 +27,32 @@ interface HomePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Home',
|
||||
description: 'Search and compare school performance across England',
|
||||
/*
|
||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||
* bookmark and the search result all read. `absolute` opts out of the root
|
||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
/*
|
||||
* Intent in the title, differentiator in the description.
|
||||
*
|
||||
* These queries are owned by the DfE's own "Compare school performance"
|
||||
* service, and the old title — brand first, then a near-paraphrase of that
|
||||
* service's name — gave a searcher no reason to pick us over it. It drew
|
||||
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
||||
* neighbourhood, so the ranking was never the problem.
|
||||
*
|
||||
* The title now matches what people type. The description carries the one
|
||||
* fact gov.uk does not publish: how close you had to live to get a place.
|
||||
*/
|
||||
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
// This page reads eleven search params. They filter a result set; they do
|
||||
// not make a new document. Collapsing every combination onto "/" stops the
|
||||
// homepage competing with itself for its own head terms.
|
||||
alternates: { canonical: absoluteUrl('/') },
|
||||
};
|
||||
|
||||
// The page reads searchParams, which makes rendering dynamic by default.
|
||||
@@ -79,14 +105,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
const total = dataInfo?.total_schools ?? null;
|
||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||
// endpoint returns, and reading it silently yielded null on every request.
|
||||
const total = dataInfo?.unique_schools ?? null;
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={total}
|
||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -99,7 +135,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={null}
|
||||
localAuthorityCount={0}
|
||||
earliestYearLabel={null}
|
||||
latestYearLabel={null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
||||
import { RankingsView } from '@/components/RankingsView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface RankingsPageProps {
|
||||
@@ -17,9 +18,15 @@ interface RankingsPageProps {
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Rankings',
|
||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||
// phrase parents actually search, and it spikes each results day.
|
||||
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
||||
description:
|
||||
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||
// now. W3 replaces them with real indexable paths.
|
||||
alternates: { canonical: absoluteUrl('/rankings') },
|
||||
};
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { MetadataRoute } from 'next';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -14,6 +15,6 @@ export default function robots(): MetadataRoute.Robots {
|
||||
disallow: ['/api/', '/_next/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
|
||||
sitemap: absoluteUrl('/sitemap.xml'),
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,18 @@
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails, fetchSchools } from '@/lib/api';
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { NationalAverages } from '@/lib/types';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -91,8 +98,8 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
url: absoluteUrl(canonicalPath),
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
@@ -100,7 +107,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
canonical: absoluteUrl(canonicalPath),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -124,16 +131,24 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data
|
||||
// Fetch school data. National averages feed the England-comparison deltas
|
||||
// across most sections; fetching them here rather than in a client effect
|
||||
// keeps those sections server-renderable and puts the deltas in the initial
|
||||
// HTML. They are supplementary, so they degrade to null rather than 404ing
|
||||
// the page.
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -143,13 +158,32 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
||||
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
|
||||
// Section list is computed on the server so the client shell never needs to
|
||||
// derive it -- and so it can never disagree with what the sections render.
|
||||
const sectionInput = {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
const navInput = {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
admissionDistance: admission_distance ?? null,
|
||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -184,20 +218,13 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{isSecondary ? (
|
||||
<SecondarySchoolDetailView
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
) : (
|
||||
<SchoolDetailView
|
||||
navItems={secondaryNavItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
@@ -205,11 +232,35 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
) : (
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
census={census ?? null}
|
||||
navItems={primaryNavItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={primaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Phase variants of a place page.
|
||||
*
|
||||
* Phase is part of the query — "primary schools in beccles", "secondary
|
||||
* schools in brentwood" — not a filter applied afterwards, so each gets its
|
||||
* own indexable path. A place with no schools of the phase has no page: the
|
||||
* per-phase threshold, not an error.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string; phase: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
const PHASES = ['primary', 'secondary'] as const;
|
||||
type Phase = (typeof PHASES)[number];
|
||||
|
||||
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
||||
|
||||
async function resolve(slug: string, phase: Phase) {
|
||||
return (await fetchPlace('town', slug, phase))
|
||||
?? (await fetchPlace('locality', slug, phase));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||
|
||||
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||
const { name } = detail.place;
|
||||
return {
|
||||
title: { absolute: `${word} Schools in ${name} — Ranked | schoolcompare` },
|
||||
description:
|
||||
`Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
|
||||
+ `the local average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePhasePage({ params }: Props) {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) notFound();
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
const englandAverage = phase === 'secondary'
|
||||
? national?.secondary?.attainment_8_score ?? null
|
||||
: national?.primary?.rwm_expected_pct ?? null;
|
||||
|
||||
return <PlaceView detail={detail} phase={phase}
|
||||
englandAverage={englandAverage} neighbours={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Town and locality pages.
|
||||
*
|
||||
* A place below the five-school threshold is not in the registry, so
|
||||
* fetchPlace returns null and the request 404s rather than rendering a page
|
||||
* with nothing to say.
|
||||
*/
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string }> }
|
||||
|
||||
// ISR: place aggregates change only when the pipeline runs.
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
||||
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||
.map((p) => ({ place: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(slug: string) {
|
||||
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
||||
}
|
||||
|
||||
/** Other towns in the same authority — the cheapest honest definition of
|
||||
* "nearby", and enough to stop each place page being a dead end. */
|
||||
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
||||
if (!detail.place.parent_authority) return [];
|
||||
const all = await fetchPlaces();
|
||||
return all
|
||||
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
// absolute: the root layout's template appends '| schoolcompare' to a
|
||||
// plain string, and this title already carries it. Without this every
|
||||
// place title read '... | schoolcompare | schoolcompare'.
|
||||
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
|
||||
description:
|
||||
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
|
||||
+ `the local average against England, and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePage({ params }: Props) {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) notFound();
|
||||
|
||||
// Global constraint: no page without a local average. A place with too few
|
||||
// schools carrying results has nothing to say that a list does not, so it
|
||||
// defers to its authority rather than publishing a thin page.
|
||||
if (detail.averages.rwm_expected_pct == null
|
||||
&& detail.averages.attainment_8_score == null) {
|
||||
if (detail.place.parent_authority) {
|
||||
redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
||||
// — not flat. Reading it flat silently yields undefined and the page renders
|
||||
// with no comparison, which is the one thing that makes it not a list.
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={await neighboursOf(detail)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Local authority pages.
|
||||
*
|
||||
* A separate namespace from /schools/[place] because 67 town names collide
|
||||
* with an authority name and neither set contains the other — Bedford the
|
||||
* town holds 104 schools, Bedford the authority 86, because postal towns
|
||||
* cross authority boundaries. The title says "Local Authority" so a reader
|
||||
* landing on both knows which set each covers.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ la: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||
// Gated like every other prerender in this app. There are only ~154
|
||||
// authorities, but "few enough to always build" still means the API must be
|
||||
// reachable at build time, and in CI it is not — the build fails with
|
||||
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||
// school route uses.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'authority')
|
||||
.map((p) => ({ la: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
|
||||
+ `results, with Ofsted grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPage({ params }: Props) {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Postcode district pages.
|
||||
*
|
||||
* No phase variants: nobody searches "primary schools in SW11", so the
|
||||
* variants would be pages without demand. These exist to catch
|
||||
* "schools near <postcode>" and to give London districts a geographic page
|
||||
* where the GIAS town field cannot.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ outcode: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||
// 1,760 of these; same CI budget argument as the town routes.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'outcode')
|
||||
.map((p) => ({ outcode: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools near ${name} | schoolcompare` },
|
||||
description:
|
||||
`${count} schools in the ${name} postcode district, ranked by results, with `
|
||||
+ `Ofsted grades and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OutcodePage({ params }: Props) {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,8 @@
|
||||
/**
|
||||
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
||||
*
|
||||
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
||||
* baking the backend host into the build, so one image works in every
|
||||
* environment. robots.ts points crawlers here.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function backendOrigin(): string {
|
||||
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||
return base.replace(/\/api$/, '');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
||||
} catch {
|
||||
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
||||
}
|
||||
|
||||
const body = await upstream.text();
|
||||
return new NextResponse(body, {
|
||||
status: upstream.status,
|
||||
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
||||
});
|
||||
return proxySitemap('/sitemap.xml');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
|
||||
* validated here rather than passed through, so this route cannot be used to
|
||||
* reach arbitrary backend paths.
|
||||
*/
|
||||
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ parts: string[] }> },
|
||||
) {
|
||||
const { parts } = await params;
|
||||
const name = parts.join('/');
|
||||
if (!CHILD.test(name)) {
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
return proxySitemap(`/sitemaps/${name}`);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
@@ -9,6 +9,7 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
@@ -38,6 +39,11 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||
|
||||
// Canvas can't read var(), so pull the resolved tokens through the bridge.
|
||||
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
|
||||
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card'
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: '#e07256',
|
||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||
borderColor: cBrand,
|
||||
backgroundColor: alpha('--brand', 0.10),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: '#e07256',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: cBrand,
|
||||
pointBorderColor: cCard,
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
@@ -51,33 +51,33 @@
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
border-left-color: var(--brand);
|
||||
}
|
||||
|
||||
.navDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────── */
|
||||
@@ -95,8 +95,8 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--brand);
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -107,22 +107,22 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.55;
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
.countdownSection {
|
||||
padding: 0 0 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
@@ -155,11 +155,11 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
@@ -177,12 +177,12 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||
.chipDeadline::before { background: var(--brand); }
|
||||
.chipOffer::before { background: var(--status-above); }
|
||||
|
||||
.chipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||
background: rgba(var(--status-below-rgb), 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
@@ -196,8 +196,8 @@
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||
.chipTrackDeadline { color: var(--brand-strong); }
|
||||
.chipTrackOffer { color: var(--status-above); }
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
@@ -208,7 +208,8 @@
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -217,17 +218,17 @@
|
||||
|
||||
.chipDeadline .chipDays,
|
||||
.chipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
@@ -236,14 +237,14 @@
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
@@ -251,8 +252,8 @@
|
||||
|
||||
.track {
|
||||
margin-bottom: 3rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -263,7 +264,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -278,22 +279,22 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trackSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -317,13 +318,13 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trackDateVal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Timeline ───────────────────────────────────────── */
|
||||
@@ -356,27 +357,27 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDot {
|
||||
background: rgba(224, 114, 86, 0.15);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: rgba(var(--brand-rgb), 0.15);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.stepOffer .stepDot {
|
||||
background: rgba(45, 125, 125, 0.15);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(var(--status-above-rgb), 0.15);
|
||||
border-color: var(--status-above);
|
||||
}
|
||||
|
||||
.stepLine {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -395,27 +396,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepDate { color: var(--brand-strong); }
|
||||
.stepOffer .stepDate { color: var(--status-above); }
|
||||
|
||||
.stepTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepTitle { color: var(--brand-strong); }
|
||||
.stepOffer .stepTitle { color: var(--status-above); }
|
||||
|
||||
.stepBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -427,13 +429,13 @@
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
text-decoration: none;
|
||||
transition: gap 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.stepTool:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--brand-strong);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -455,9 +457,9 @@
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
||||
border: 1px solid rgba(201, 162, 39, 0.28);
|
||||
border-left: 3px solid var(--accent-gold, #c9a227);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid rgba(var(--status-below-rgb), 0.28);
|
||||
border-left: 3px solid var(--status-below);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -467,8 +469,8 @@
|
||||
height: 1.05rem;
|
||||
margin-top: 0.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gold, #c9a227);
|
||||
color: #fff;
|
||||
background: var(--status-below);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05rem;
|
||||
@@ -482,14 +484,15 @@
|
||||
.calloutTitle {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold-text, #7a6800);
|
||||
color: var(--status-below);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.calloutBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -501,10 +504,10 @@
|
||||
}
|
||||
|
||||
.tipsHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -515,16 +518,17 @@
|
||||
}
|
||||
|
||||
.tipCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipNumber {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
color: var(--text-muted); /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
line-height: 1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -532,14 +536,15 @@
|
||||
.tipHeading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tipBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -557,8 +562,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 0;
|
||||
background: var(--bg-primary, #faf8f3);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -1.25rem 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
z-index: 10;
|
||||
@@ -590,8 +595,8 @@
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background: var(--text-primary, #1a1612);
|
||||
color: #fff;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '31 October',
|
||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '15 January',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
.chip[aria-pressed="true"] {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
border-color: rgba(0, 0, 0, .35);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
@@ -48,6 +49,13 @@ const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'st
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||||
// stay the same colour.
|
||||
const seriesColors = useSeriesColors();
|
||||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
// A focused school that leaves the basket must not linger.
|
||||
@@ -67,7 +75,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const color = seriesColors[series.schoolIndex % seriesColors.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: isMobile ? 10 : 12,
|
||||
titleFont: {
|
||||
size: isMobile ? 12 : 14,
|
||||
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
...(isMobile && { maxTicksLimit: 5 }),
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
color: cGrid,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.18), 0 2px 8px rgba(var(--shadow-rgb), 0.08);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
@@ -62,13 +62,13 @@
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schoolList {
|
||||
@@ -95,13 +95,13 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -111,7 +111,7 @@
|
||||
.removeSchoolBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.removeSchoolBtn:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
@@ -129,13 +129,13 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btnClearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -145,13 +145,13 @@
|
||||
}
|
||||
|
||||
.btnClearAll:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btnCompare {
|
||||
flex: 1;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
@@ -43,7 +43,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -53,8 +53,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
@@ -87,23 +87,23 @@
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--brand);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
@@ -126,12 +126,12 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
@@ -142,7 +142,7 @@
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -185,22 +185,22 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.barCaptionCount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -212,9 +212,9 @@
|
||||
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
title: 'School comparison · schoolcompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||
: 'schoolcompare',
|
||||
url,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
}
|
||||
|
||||
.good {
|
||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.bad {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(var(--shadow-rgb), 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
|
||||
@@ -5,54 +5,60 @@ import styles from './HomeView.module.css';
|
||||
interface EditorialSectionProps {
|
||||
totalSchools: number | null;
|
||||
localAuthorityCount: number;
|
||||
latestYearLabel: string | null;
|
||||
earliestYearLabel: string | null;
|
||||
}
|
||||
|
||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||
export function EditorialSection({
|
||||
totalSchools,
|
||||
localAuthorityCount,
|
||||
latestYearLabel,
|
||||
earliestYearLabel,
|
||||
}: EditorialSectionProps) {
|
||||
/*
|
||||
* The coverage line replaces a six-row "Coverage at a glance" table.
|
||||
*
|
||||
* That table put six numbers on the page to say one thing, and the headline
|
||||
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
||||
* DataInfoResponse declared a `total_schools` field the API does not send
|
||||
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
||||
* undefined, so the fallback rendered and nothing failed. Every figure here
|
||||
* is now live, and any figure that isn't available is dropped rather than
|
||||
* replaced with a guess.
|
||||
*/
|
||||
const coverage = [
|
||||
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
||||
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
||||
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className={styles.editorial}>
|
||||
<div className={styles.editorialGrid}>
|
||||
// No class on the section: the page container owns the vertical rhythm
|
||||
// now, and this band carries its own ground on the card inside it.
|
||||
<section>
|
||||
<div className={styles.editorialCard}>
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>About school data</p>
|
||||
<h2 className={styles.sectionHeading}>
|
||||
Making England's school performance data actually readable
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||
and demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
full inspection report.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.factbox}>
|
||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Schools covered</span>
|
||||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Local authorities</span>
|
||||
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Phases</span>
|
||||
<span className={styles.factVal}>Primary & Secondary</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Latest results year</span>
|
||||
<span className={styles.factVal}>2024/25</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Historical data</span>
|
||||
<span className={styles.factVal}>2016–2025</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Metrics per school</span>
|
||||
<span className={styles.factVal}>40+</span>
|
||||
</div>
|
||||
</div>
|
||||
{coverage.length > 0 && (
|
||||
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-card, white);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 16px;
|
||||
min-height: 400px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -22,14 +22,14 @@
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-rgb), 0.3);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
/*
|
||||
* FilterBar — one component, two shapes.
|
||||
*
|
||||
* hero a single white pill on the Sand hero panel, carrying the one
|
||||
* coral action the page exists for. No card of its own: the panel
|
||||
* behind it belongs to the page, so the pill reads as the object.
|
||||
* compact the filter rail above the results — a white card of quiet
|
||||
* controls, brand for what is active, sage for what is selected.
|
||||
*
|
||||
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||
* from currentColor rather than a background image so they follow the theme.
|
||||
*/
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────── */
|
||||
|
||||
.filterBar {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: opacity 0.2s ease;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.filterBar.isLoading {
|
||||
@@ -14,112 +29,16 @@
|
||||
}
|
||||
|
||||
.heroMode {
|
||||
padding: 2.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem auto;
|
||||
box-shadow: 0 8px 24px rgba(26, 22, 18, 0.08);
|
||||
border-width: 2px;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
/* ── Search ───────────────────────────────────────────────────────── */
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
@@ -127,43 +46,90 @@
|
||||
|
||||
.omniBoxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||
.heroMode .omniBoxContainer {
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.heroMode .omniBoxContainer:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
.omniIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
|
||||
16px, which throws the user out of the hero on first tap. */
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card, white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.omniInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* Inside the pill the input carries no chrome — the pill holds the border
|
||||
and the focus ring, so the two never double up. */
|
||||
.heroMode .omniInput {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.6875rem 1.25rem;
|
||||
font-size: var(--step--1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 1.375rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -171,103 +137,86 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.filters {
|
||||
/* ── Hero supporting copy ─────────────────────────────────────────── */
|
||||
|
||||
.searchHint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.875rem center;
|
||||
background-size: 10px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.radiusWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
white-space: nowrap;
|
||||
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||
action and nothing beside it may compete for the same weight. */
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.45;
|
||||
color: var(--brand-strong);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
||||
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -275,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
@@ -289,76 +238,157 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Pill-style inline filter controls (radius + phase) */
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65rem center;
|
||||
background-size: 10px 6px;
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.radiusLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||
|
||||
.selectShell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.selectShell select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Drawn, not imaged: a background-image arrow has to hardcode its own
|
||||
colour, and would stay light-mode grey on the dark ground. */
|
||||
.selectChevron {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -2px;
|
||||
pointer-events: none;
|
||||
color: var(--text-muted);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
}
|
||||
|
||||
.filterSelect,
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
box-shadow var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterSelect:hover:not(:disabled),
|
||||
.controlSelect:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filterSelect:focus,
|
||||
.controlSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
|
||||
line: the same pairing the results use for "on". */
|
||||
.selectActive {
|
||||
background-color: rgba(var(--sage-rgb), 0.38);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selectActive:hover:not(:disabled) {
|
||||
background-color: rgba(var(--sage-rgb), 0.5);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ──────────────────────────────────────── */
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* When filters are applied, promote the toggle to a coral pill so users
|
||||
can see at a glance that the result list is being narrowed. */
|
||||
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||
the list below is being narrowed. */
|
||||
.advancedToggleActive {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advancedToggleActive:hover {
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
@@ -377,3 +407,94 @@
|
||||
.chevronUp {
|
||||
border-bottom: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
/* The hero pill stays a pill at every width — stacking it would lose the
|
||||
one shape the page is built around. Only the results bar stacks. */
|
||||
.filterBar:not(.heroMode) .omniBoxContainer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.geoError {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Narrow phones: the button drops below the input instead of sharing the row.
|
||||
*
|
||||
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
|
||||
* screen it took 48% of the row and left the input 102px of text space for a
|
||||
* 194px placeholder — the field showed "School name or" and stopped. At 320px
|
||||
* the input was down to 32px, which is too narrow to read what you are typing.
|
||||
*
|
||||
* Wrapping rather than restructuring: the pill stays one element, so it keeps
|
||||
* its border, shadow and :focus-within ring, and the button gets a full-width
|
||||
* tap target on the way. Giving the button a 100% flex-basis is what forces
|
||||
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
|
||||
*/
|
||||
@media (max-width: 480px) {
|
||||
.heroMode .omniBoxContainer {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.heroMode .searchButton {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Below this the pin costs more room than it earns. */
|
||||
.heroMode .omniIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
|
||||
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||
* colour is the one thing in a select that can't follow the theme.
|
||||
*/
|
||||
function SelectShell({
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.selectChevron} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
||||
const isSecondaryMode =
|
||||
currentPhase === "secondary" || genderOptions.length > 0;
|
||||
|
||||
// A select that is narrowing the results carries a sage tint; the class is
|
||||
// only ever additive, so the control's behaviour is untouched.
|
||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||
>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
{isHero && (
|
||||
<span className={styles.omniIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${styles.searchButton}`}
|
||||
className={`btn btn-action ${styles.searchButton}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
@@ -248,6 +292,7 @@ export function FilterBar({
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
@@ -259,14 +304,16 @@ export function FilterBar({
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
@@ -276,6 +323,7 @@ export function FilterBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -307,12 +355,13 @@ export function FilterBar({
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
@@ -322,13 +371,15 @@ export function FilterBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
@@ -338,16 +389,18 @@ export function FilterBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
@@ -357,28 +410,32 @@ export function FilterBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
@@ -388,6 +445,7 @@ export function FilterBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* The footer band.
|
||||
*
|
||||
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||
* band rather than to the page.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
background: var(--accent-navy, #2c3e50);
|
||||
color: var(--bg-secondary, #f3ede4);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--on-sunken);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -12,7 +22,7 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -20,31 +30,97 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||
|
||||
.lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg-primary, #faf7f2);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.lockupMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
/* Height drives; the artwork is taller than it is wide. */
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.lockupMark img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.wordmarkAccent {
|
||||
color: var(--sage);
|
||||
}
|
||||
|
||||
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||
for a full sentence. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/*
|
||||
* Quieter than .description, but by size only — not by contrast.
|
||||
*
|
||||
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
|
||||
* fine margin for decorative text and the wrong one for a disclaimer, whose
|
||||
* whole job is to be legible to someone checking whether this is a government
|
||||
* site. --on-sunken-muted is 5.66:1 on the same band.
|
||||
*/
|
||||
.independence {
|
||||
margin: 0;
|
||||
font-size: var(--step--2);
|
||||
line-height: 1.6;
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
/* --sage flips with the theme; this band does not (it is teal in both), so
|
||||
the pairing only held in one of them — the dark sage measured 4.08:1 here
|
||||
against a 4.5 floor, on every page. The --on-sunken-* family exists
|
||||
precisely because this surface is theme-invariant. */
|
||||
color: var(--on-sunken-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.links {
|
||||
@@ -57,43 +133,64 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.linkDisabled {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||
* colour the token layer exists to remove.
|
||||
*/
|
||||
.bottom {
|
||||
position: relative;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(250, 247, 242, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--on-sunken);
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.copyright,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.6);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
}
|
||||
|
||||
.disclaimer .link {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--on-sunken-link);
|
||||
}
|
||||
|
||||
.disclaimer .link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Site footer with links and info
|
||||
*
|
||||
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||
* that stays teal in both themes. Everything inside it therefore reads from
|
||||
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||
* invert and would go dark against the teal in one theme or the other.
|
||||
*/
|
||||
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
@@ -13,10 +18,31 @@ export function Footer() {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
{/*
|
||||
On the sunken band the mark inverts: the pin takes the band's
|
||||
foreground and the leaf is knocked out in the band itself. The
|
||||
header's defaults (brand pin, card leaf) would disappear here.
|
||||
*/}
|
||||
<h3 className={styles.lockup}>
|
||||
<span className={styles.lockupMark}>
|
||||
<LogoMark variant="onDark" size={34} />
|
||||
</span>
|
||||
<span className={styles.wordmark}>
|
||||
school<span className={styles.wordmarkAccent}>compare</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
{/*
|
||||
Says once, plainly, what the landing page's value props only
|
||||
imply: we publish official data, we are not an official body.
|
||||
Cheap to state and expensive to be wrong about.
|
||||
*/}
|
||||
<p className={styles.independence}>
|
||||
An independent site. Not affiliated with the Department for Education or Ofsted.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:contact@schoolcompare.co.uk"
|
||||
className={styles.link}
|
||||
@@ -31,7 +57,9 @@ export function Footer() {
|
||||
<ul className={styles.links}>
|
||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||
{/* "Compare", not "shortlist" — the nav, this link and the
|
||||
landing page all name the same feature the same way. */}
|
||||
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -69,7 +97,7 @@ export function Footer() {
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<p className={styles.copyright}>
|
||||
© {currentYear} SchoolCompare.co.uk
|
||||
© {currentYear} schoolcompare.co.uk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+292
-101
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -39,13 +40,22 @@ function daysUntil(month: number, day: number): number {
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
function nextOccurrence(month: number, day: number): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const target = new Date(y, month - 1, day);
|
||||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatShortDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface CountdownChipData {
|
||||
@@ -56,6 +66,16 @@ interface CountdownChipData {
|
||||
day: number;
|
||||
}
|
||||
|
||||
interface AdmissionsMilestone {
|
||||
chip: CountdownChipData;
|
||||
/** Whole days from today. Always present — computed during render. */
|
||||
days: number;
|
||||
/** "Sat, 31 October 2026" — for the headline bar. */
|
||||
dateLabel: string;
|
||||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||||
shortDate: string;
|
||||
}
|
||||
|
||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||
@@ -63,6 +83,116 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||
local because they exist for this one row and nothing else. */
|
||||
|
||||
const ICON_PROPS = {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 21,
|
||||
height: 21,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const;
|
||||
|
||||
function ShieldCheckIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
icon: React.ReactNode;
|
||||
tintClass: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||
* favourites). That feature does not exist in this product, so it is
|
||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||
* and is backed by the next-deadline bar further down this page.
|
||||
*
|
||||
* Every claim here must name something the product actually does. Two of the
|
||||
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
||||
* correctly said five), and "class sizes" described data the codebase has never
|
||||
* held — grep for it and this line was the only hit. Both are corrected below
|
||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
/*
|
||||
* "Built on official data", never "Official".
|
||||
*
|
||||
* The previous title was "Official & trusted", whose grammatical subject
|
||||
* is this site — it reads as a claim that schoolcompare is itself an
|
||||
* official service. It is not: it is an independent site that republishes
|
||||
* official figures. The distinction is the difference between describing
|
||||
* the data and describing ourselves, and only the first is true.
|
||||
*
|
||||
* Everywhere else the word appears ("official DfE figures", "the official
|
||||
* figure isn't in") it already qualifies the data, which is correct and
|
||||
* should stay.
|
||||
*/
|
||||
title: 'Built on official data',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to five schools side by side, on the measures that matter.',
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon />,
|
||||
tintClass: styles.propIconContext,
|
||||
title: 'Beyond the numbers',
|
||||
body: 'SEN support, pupil premium and attendance — not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
tintClass: styles.propIconDeadline,
|
||||
title: 'Never miss a deadline',
|
||||
body: 'Application and offer dates for both primary and secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -83,9 +213,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const mapParamsRef = useRef<string>('');
|
||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||
);
|
||||
/*
|
||||
* Computed during render, on the server as well as the client, so the
|
||||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||||
*
|
||||
* The previous version deferred this to an effect to dodge a hydration
|
||||
* mismatch, which meant the section had to reserve its own height — and the
|
||||
* reservation was a single guessed number for a block whose supporting line
|
||||
* wraps to a different height at every width. Measured, it was short at
|
||||
* every breakpoint, shifting the page by up to 108px on a phone.
|
||||
*
|
||||
* The mismatch it was dodging is real but tiny: a server rendering at
|
||||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||||
*/
|
||||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||||
chip,
|
||||
days: daysUntil(chip.month, chip.day),
|
||||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||||
shortDate: formatShortDate(chip.month, chip.day),
|
||||
}));
|
||||
measured.sort((a, b) => a.days - b.days);
|
||||
return measured;
|
||||
});
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -148,13 +299,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||
setSortedChips(withDays);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
track('results_load_more', { next_page: currentPage + 1 });
|
||||
@@ -243,6 +387,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
});
|
||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||
|
||||
/*
|
||||
* The coverage figure, or nothing at all.
|
||||
*
|
||||
* This used to be the string "24,000+", hardcoded in three places while the
|
||||
* database held 27,230 — and the fact box that was meant to show the live
|
||||
* number silently rendered its fallback, because DataInfoResponse declared a
|
||||
* `total_schools` field the API has never sent. Now there is one source: if
|
||||
* the real count is unavailable the sentence simply omits it rather than
|
||||
* inventing a floor.
|
||||
*/
|
||||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||||
|
||||
/*
|
||||
* The headline milestone is the next thing a parent can actually miss — an
|
||||
* application deadline. Offer days are dates you receive something on, so
|
||||
* they belong in the supporting line, not in the page's largest numeral.
|
||||
* Four equally-sized cards counting down to things 245 days away was the
|
||||
* loudest element on the page and asked nothing of anyone.
|
||||
*/
|
||||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||||
|
||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||
// split which surface drives compare adds.
|
||||
const addSchoolFromSearch = useCallback((school: School) => {
|
||||
@@ -255,113 +421,137 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||
CSS); the search arrangement is a filter bar directly above its results
|
||||
and wants none of it. */
|
||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
which is why FilterBar is rendered in two places rather than moved.
|
||||
The hero and the four value props are one group, so they sit closer
|
||||
together than the page's section gap. */}
|
||||
{!isSearchActive ? (
|
||||
<div className={styles.heroGroup}>
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
Find the school where your child will{' '}
|
||||
<em className={styles.heroEmph}>flourish.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
Compare every school in England with clarity and confidence.
|
||||
{/* The coverage sentence is the detail a first-time visitor needs
|
||||
but a returning one does not, so it drops away on phones where
|
||||
every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
isHero
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||
</div>
|
||||
<div
|
||||
className={styles.countdownRail}
|
||||
style={{
|
||||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{sortedChips.map(({ chip, days }) => {
|
||||
const isUrgent = days !== null && days <= 14;
|
||||
const chipClass = [
|
||||
styles.countdownChip,
|
||||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||
isUrgent ? styles.countdownChipUrgent : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const trackClass = [
|
||||
styles.chipTrack,
|
||||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||
].join(' ');
|
||||
return (
|
||||
<div key={chip.milestone} className={chipClass}>
|
||||
<span className={trackClass}>
|
||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||
{chip.track}
|
||||
</span>
|
||||
<div>
|
||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||
</div>
|
||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||
<div className={styles.chipDate}>
|
||||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/*
|
||||
Rendered after the content on purpose. On desktop it is absolutely
|
||||
positioned behind the panel, so order does not matter; below the
|
||||
one-column breakpoint it returns to normal flow, where DOM order is
|
||||
what puts the band under the search rather than above the headline.
|
||||
*/}
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why this site, in four lines. These titles are labels on a list,
|
||||
not section headings — as <h2> they outranked the page's real
|
||||
headings in the document outline and gave a screen-reader user
|
||||
four false landmarks before any content. */}
|
||||
<ul className={styles.valueProps}>
|
||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||
<li key={title} className={styles.valueProp}>
|
||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||
<div>
|
||||
<p className={styles.propTitle}>{title}</p>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={false}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary discovery — moved below deadlines so the admissions
|
||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.exploringRow}>
|
||||
<span className={styles.exploringLabel}>Start exploring</span>
|
||||
<div className={styles.exploringChips}>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated primary schools
|
||||
{/* Next admissions deadline — one bar, not four cards. */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||
{nextDeadline && (
|
||||
<>
|
||||
<a
|
||||
href="/admissions"
|
||||
className={
|
||||
nextDeadline.days <= 14
|
||||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||||
: styles.nextDeadline
|
||||
}
|
||||
>
|
||||
<span className={styles.nextDeadlineBody}>
|
||||
<span className={styles.nextDeadlineKicker}>
|
||||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||||
</span>
|
||||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||||
{nextDeadline.dateLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.nextDeadlineCount}>
|
||||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||||
</span>
|
||||
{nextDeadline.days !== 0 && (
|
||||
<span className={styles.nextDeadlineUnit}>days left</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated secondary schools
|
||||
</a>
|
||||
<a href="/compare" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Start a comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.laterDates} suppressHydrationWarning>
|
||||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||
{' '}
|
||||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||
{!isSearchActive && howItWorks}
|
||||
{!isSearchActive && editorial}
|
||||
|
||||
{/* Results Section */}
|
||||
{/* Results Section. Skipped entirely on the landing page when there is
|
||||
nothing to list — an empty <section> is still a flex child, so it was
|
||||
contributing a full section gap of blank space above the footer. */}
|
||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
@@ -547,6 +737,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -573,10 +764,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: rwmDelta >= 2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||
: rwmDelta <= -2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
@@ -601,13 +792,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
fontWeight: 600,
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||||
{ background: '#e0e0e0', color: '#666' }),
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||
}}
|
||||
>
|
||||
{ofstedBadge.label}
|
||||
|
||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||
];
|
||||
/*
|
||||
* The columns are deliberately unnamed.
|
||||
*
|
||||
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
||||
* and "St Mary's Catholic Primary" — beside invented results and invented
|
||||
* Ofsted grades. On a site whose entire proposition is official DfE and
|
||||
* Ofsted data, publishing made-up figures against identifiable schools is
|
||||
* the one thing it cannot do, and no illustrative intent survives being
|
||||
* screenshotted. Generic labels make the layout point just as well.
|
||||
*/
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
||||
|
||||
return (
|
||||
<section className={styles.howItWorks}>
|
||||
<div className={styles.hiwHeader}>
|
||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||
{/* One header pattern across every band on this page: kicker, heading,
|
||||
optional aside. The page previously ran four different treatments
|
||||
(two uppercase micro-labels — one left, one centred — a large heading
|
||||
with right-aligned grey text, and a kicker-plus-heading), which is
|
||||
most of why a designed page read as a stack of unrelated strips. */}
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>Every school page</p>
|
||||
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
||||
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
||||
</div>
|
||||
<div className={styles.hiwGrid}>
|
||||
{/* Card 1 — Performance */}
|
||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
||||
<div className={styles.comparePreview}>
|
||||
<div className={styles.compareHead}>
|
||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||
<div className={styles.compareHeadCell}>School A</div>
|
||||
<div className={styles.compareHeadCell}>School B</div>
|
||||
</div>
|
||||
{compareRows.map(({ label, a, b, aHi }) => (
|
||||
<div key={label} className={styles.compareRow}>
|
||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
||||
<span className={styles.compareRowVal}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Compare</div>
|
||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
<div className={styles.hiwTitle}>Side by side</div>
|
||||
{/* One verb for one feature. The site previously called this
|
||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||
which reads as three separate things it does not have. */}
|
||||
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The landing hero artwork.
|
||||
*
|
||||
* This is the supplied illustration, not a drawing of one — see
|
||||
* assets/hero-source.png and scripts/build-hero-images.js, which produces
|
||||
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
|
||||
*
|
||||
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
|
||||
* renders in the RSC pass and never reaches the client bundle.
|
||||
*
|
||||
* TWO CROPS, ONE <picture>
|
||||
* ------------------------
|
||||
* The slot is two different shapes. On desktop the artwork sits behind the
|
||||
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
|
||||
* area the illustration reserves on its left. Below the one-column breakpoint
|
||||
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
|
||||
* behind it.
|
||||
*
|
||||
* A single file under `object-fit: cover` centre-crops, and at the band's
|
||||
* extreme that slices a strip through the middle of the scene and loses the
|
||||
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
|
||||
* <source media> switches crop, not just resolution: `band` is pre-cropped
|
||||
* around the school and is already near 2.6:1, so the narrow band barely
|
||||
* crops it further.
|
||||
*
|
||||
* `object-position` is set in CSS per breakpoint and is load-bearing — see
|
||||
* .heroArt in HomeView.module.css.
|
||||
*
|
||||
* The artwork is decorative: the proposition beside it carries the meaning, so
|
||||
* alt is empty and it is hidden from assistive tech rather than described.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
|
||||
* padding (1rem under 768px), so the artwork's box is that minus the padding —
|
||||
* describing it as 100vw over-requests by ~48px worth of candidate at every
|
||||
* width, which on a slow connection is a larger file than needed for the LCP
|
||||
* element.
|
||||
*/
|
||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
||||
|
||||
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||
const WIDE_W = 2000;
|
||||
const WIDE_H = 1125;
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
<picture>
|
||||
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
|
||||
rules have to precede the unconstrained desktop ones. */}
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
The band's JPEG, and the reason it exists.
|
||||
|
||||
<img src> cannot vary by viewport, so it is always the wide crop. A
|
||||
browser that takes neither AVIF nor WebP would therefore fall through
|
||||
to the desktop frame on a phone and lose the schoolhouse — the exact
|
||||
failure this whole component is arranged to prevent, surviving in the
|
||||
one path nobody looks at. No `type` here, so it matches anywhere the
|
||||
media query does, and it sits after the modern formats so they still
|
||||
win where supported.
|
||||
*/}
|
||||
<source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
The hero is the largest thing above the fold, so it is almost certainly
|
||||
the LCP element: fetchPriority high, and never lazy. eslint's
|
||||
no-img-element wants next/image, which cannot art-direct between two
|
||||
different crops — that is the whole point of the <picture> above.
|
||||
*/}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/brand/hero-wide-1400.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={WIDE_W}
|
||||
height={WIDE_H}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||
below carries the visible circled "?" at its own explicit size. */
|
||||
font-size: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
/* The visible affordance: a small circled "?" centred in the 24px target. */
|
||||
.icon::before {
|
||||
content: '?';
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.icon:hover,
|
||||
.icon[aria-expanded='true'],
|
||||
.icon:focus-visible {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
arrow,
|
||||
useHover,
|
||||
useFocus,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
FloatingPortal,
|
||||
FloatingArrow,
|
||||
} from '@floating-ui/react';
|
||||
import styles from './InfoPopover.module.css';
|
||||
|
||||
export interface InfoPopoverProps {
|
||||
label?: string;
|
||||
plain?: string;
|
||||
detail?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: 'top',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
arrow({ element: arrowRef, padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
|
||||
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
|
||||
// so tap and hover never double-fire.
|
||||
const hover = useHover(context, { delay: { open: 100, close: 0 } });
|
||||
const focus = useFocus(context);
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: 'tooltip' });
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
focus,
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
]);
|
||||
|
||||
if (!plain) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={refs.setReference}
|
||||
className={styles.icon}
|
||||
aria-label={ariaLabel ?? 'More information'}
|
||||
aria-expanded={open}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={styles.tooltip}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
<span className={styles.plain}>{plain}</span>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* LeafletCutoffMapInner
|
||||
* The cut-off distances drawn around the school.
|
||||
*
|
||||
* L.circle takes a radius in metres and projects it properly, which is exactly
|
||||
* what these rings are: a straight-line distance from the school. (L.circleMarker
|
||||
* would take pixels and would silently stop meaning anything as the user zoomed.)
|
||||
*
|
||||
* The latest year is filled and labelled; earlier years are hairlines behind it,
|
||||
* so a tightening cut-off reads as a set of shrinking circles without needing a
|
||||
* legend to decode it. The map auto-fits the widest ring, so the whole history
|
||||
* is in frame whatever the distances are.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export interface CutoffRing {
|
||||
year: number;
|
||||
radiusM: number;
|
||||
latest: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lat: number;
|
||||
lng: number;
|
||||
rings: CutoffRing[];
|
||||
/** The parent's geocoded postcode, once they have entered one. */
|
||||
home: { lat: number; lng: number } | null;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
const SCHOOL_PIN = `
|
||||
<div style="position:relative;width:22px;height:22px">
|
||||
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
|
||||
</div>`;
|
||||
|
||||
// A square rotated 45° — a different SHAPE from the school's circle, not just a
|
||||
// different colour, so the two are still distinguishable to anyone who cannot
|
||||
// separate the hues.
|
||||
const HOME_PIN = `
|
||||
<div style="width:18px;height:18px;display:grid;place-items:center">
|
||||
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
|
||||
</div>`;
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layersRef = useRef<L.Layer[]>([]);
|
||||
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return;
|
||||
const map = L.map(elRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
touchZoom: false,
|
||||
}).setView([lat, lng], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
setTimeout(() => map.invalidateSize(), 60);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
layersRef.current = [];
|
||||
zoomCtrlRef.current = null;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
|
||||
// Redraw rings, pins and the viewport whenever the data changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
layersRef.current.forEach((l) => l.remove());
|
||||
layersRef.current = [];
|
||||
|
||||
const brand = cssVar('--brand', '#0F766E');
|
||||
const above = cssVar('--status-above', '#2F855A');
|
||||
|
||||
// Widest first, so the small recent ring is not buried under the old ones.
|
||||
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
|
||||
for (const r of ordered) {
|
||||
const circle = L.circle([lat, lng], {
|
||||
radius: r.radiusM,
|
||||
color: brand,
|
||||
weight: r.latest ? 3 : 1.5,
|
||||
opacity: r.latest ? 1 : 0.45,
|
||||
fill: r.latest,
|
||||
fillColor: brand,
|
||||
fillOpacity: r.latest ? 0.12 : 0,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(circle);
|
||||
}
|
||||
|
||||
const school = L.marker([lat, lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(school);
|
||||
|
||||
if (home) {
|
||||
const pin = L.marker([home.lat, home.lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(pin);
|
||||
|
||||
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
|
||||
color: above,
|
||||
weight: 2,
|
||||
dashArray: '5 5',
|
||||
opacity: 0.85,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(line);
|
||||
}
|
||||
|
||||
// Frame the widest ring — and the home pin when it falls outside it, so a
|
||||
// family beyond every cut-off can still see where they sit.
|
||||
const widest = ordered[0]?.radiusM ?? 500;
|
||||
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
|
||||
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
|
||||
map.fitBounds(bounds, { padding: [16, 16] });
|
||||
}, [lat, lng, rings, home]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const handlers = [
|
||||
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||||
];
|
||||
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||||
|
||||
if (interactive && !zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||||
zoomCtrlRef.current.addTo(map);
|
||||
} else if (!interactive && zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current.remove();
|
||||
zoomCtrlRef.current = null;
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 80);
|
||||
}, [interactive]);
|
||||
|
||||
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
@@ -22,10 +22,10 @@ interface LeafletHeroMapInnerProps {
|
||||
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||
const PIN_HTML = `
|
||||
<div style="position:relative;width:26px;height:26px">
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||||
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="var(--brand)"/>
|
||||
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
|
||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||
if (school.ofsted_rc_date) {
|
||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||||
}
|
||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||
const yearStr = year ? ` · ${year}` : '';
|
||||
if (school.ofsted_grade) {
|
||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||
const colours: Record<number, string> = {
|
||||
1: 'background:#d4f0ea;color:#2d7d7d',
|
||||
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
|
||||
3: 'background:#fef3cd;color:#7a6800',
|
||||
4: 'background:#fde8e0;color:#e07256',
|
||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||
};
|
||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||
}
|
||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||
@@ -99,7 +99,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: #e07256;
|
||||
background: var(--brand);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
@@ -145,13 +145,13 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const sign = diff >= 0 ? '+' : '';
|
||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||
// vs primary RWM % where ±2 pts is the threshold
|
||||
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
} else if (school.rwm_expected_pct != null) {
|
||||
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
let deltaLine = '';
|
||||
if (nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const text =
|
||||
diff >= 2 ? `+${diff} pts vs national` :
|
||||
diff <= -2 ? `${diff} pts vs national` :
|
||||
@@ -167,8 +167,8 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
}
|
||||
@@ -177,14 +177,14 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
|
||||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary, #f3ede4) 25%,
|
||||
rgba(224, 114, 86, 0.08) 50%,
|
||||
var(--bg-secondary, #f3ede4) 75%
|
||||
var(--bg-secondary) 25%,
|
||||
rgba(var(--brand-rgb), 0.08) 50%,
|
||||
var(--bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
@@ -56,7 +56,7 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@
|
||||
}
|
||||
|
||||
.skeletonListItem {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The schoolcompare mark.
|
||||
*
|
||||
* This is the supplied brand artwork, not a reconstruction of it. Two
|
||||
* colourways were extracted from the logo sheet and live in public/brand:
|
||||
*
|
||||
* mark.png the primary lockup's mark — teal pin, white window and
|
||||
* path, green leaves. Reads correctly on Warm White,
|
||||
* white cards and Sand, and on both dark-theme grounds.
|
||||
* mark-on-dark.png the on-dark colourway — white pin with the counter
|
||||
* knocked through to the ground. Required wherever the
|
||||
* ground is teal, because there the teal pin's silhouette
|
||||
* disappears and only the white window survives.
|
||||
*
|
||||
* Both are written onto one 176×208 canvas with the artwork at the same
|
||||
* height, so they are drop-in swappable — see ASPECT below for why that
|
||||
* matters rather than merely being neat.
|
||||
*
|
||||
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
|
||||
* <picture> source, so this needs no JavaScript and no client boundary.
|
||||
*
|
||||
* The wordmark beside it is live text in Manrope rather than the sheet's
|
||||
* raster: the written style guide specifies Manrope, and live text stays
|
||||
* selectable, scales cleanly and recolours with the theme.
|
||||
*
|
||||
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
|
||||
* which is ample for the header (36px), the favicon and the share card, but
|
||||
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
|
||||
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
|
||||
* consumer goes through this component or public/brand, so it is one swap.
|
||||
*/
|
||||
|
||||
const MARK_LIGHT = '/brand/mark.png';
|
||||
const MARK_ON_DARK = '/brand/mark-on-dark.png';
|
||||
|
||||
/**
|
||||
* Intrinsic size of the artwork files, used to derive width from height.
|
||||
*
|
||||
* Both colourways are deliberately written onto the SAME canvas at the same
|
||||
* artwork height, so this one ratio is correct for either of them. That is not
|
||||
* tidiness — it is required. `variant="auto"` renders a single <img> whose
|
||||
* srcset swaps the file underneath it, so the width/height attributes are
|
||||
* shared by both colourways and cannot be varied per file. When the two were
|
||||
* tightly cropped they had different ratios (0.810 and 0.875) and different
|
||||
* padding, which meant a wrong aspect hint before load — a reflow on load
|
||||
* under `width: auto` — and a visible jump in logo size whenever the OS theme
|
||||
* flipped. Re-crop or replace one file and you must re-normalise both.
|
||||
*/
|
||||
const ART_WIDTH = 176;
|
||||
const ART_HEIGHT = 208;
|
||||
const ASPECT = ART_WIDTH / ART_HEIGHT;
|
||||
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
/** Rendered height in px. Width follows the artwork's aspect ratio. */
|
||||
size?: number;
|
||||
/**
|
||||
* Which colourway to serve.
|
||||
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
|
||||
* - `onDark` always the white pin — for teal grounds, which are teal in
|
||||
* both themes (the footer band)
|
||||
*/
|
||||
variant?: 'auto' | 'onDark';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
|
||||
const height = size;
|
||||
const width = Math.round(size * ASPECT);
|
||||
const alt = title ?? '';
|
||||
|
||||
if (variant === 'onDark') {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_ON_DARK}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_LIGHT}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.icon {
|
||||
/* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.wrapper:hover .icon,
|
||||
.icon[aria-expanded="true"] {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: 220px;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
|
||||
.wrapper:hover .tooltip,
|
||||
.wrapper:focus-within .tooltip,
|
||||
.tooltipOpen {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltipOpen {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down */
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--border-color, #e8ddd4);
|
||||
}
|
||||
|
||||
.tooltipLabel {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.tooltipPlain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltipDetail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tooltip {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Anchor the bubble to open rightward on phones — icons follow their labels,
|
||||
which start at the left edge, so centring pushed the bubble off-screen. */
|
||||
@media (max-width: 640px) {
|
||||
.tooltip {
|
||||
left: -12px;
|
||||
right: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
left: 16px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
import styles from './MetricTooltip.module.css';
|
||||
import { InfoPopover } from './InfoPopover';
|
||||
|
||||
interface MetricTooltipProps {
|
||||
metricKey?: string;
|
||||
@@ -13,51 +12,13 @@ interface MetricTooltipProps {
|
||||
|
||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||
const tooltipLabel = label ?? explanation?.label;
|
||||
const tooltipPlain = plain ?? explanation?.plain;
|
||||
const tooltipDetail = detail ?? explanation?.detail;
|
||||
|
||||
// Tap/click/keyboard toggle so the definition is reachable on touch devices
|
||||
// and by keyboard, not just mouse hover (hover still works on desktop).
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (e: Event) => {
|
||||
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', dismiss);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', dismiss);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!tooltipPlain) return null;
|
||||
|
||||
const resolvedLabel = label ?? explanation?.label;
|
||||
return (
|
||||
<span className={styles.wrapper} ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.icon}
|
||||
aria-expanded={open}
|
||||
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
|
||||
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
||||
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
||||
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<InfoPopover
|
||||
label={resolvedLabel}
|
||||
plain={plain ?? explanation?.plain}
|
||||
detail={detail ?? explanation?.detail}
|
||||
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
background: rgba(var(--shadow-rgb), 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card, white);
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(26, 22, 18, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(var(--shadow-rgb), 0.2);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -62,22 +62,22 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -87,8 +87,8 @@
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -102,16 +102,16 @@
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Header and mobile tab bar, on the schoolcompare brand.
|
||||
*
|
||||
* The header is a white card band closed by a single hairline rule — no
|
||||
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||
* wordmark and the one active nav item, and nothing else.
|
||||
*/
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -16,112 +23,121 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||
sticky section nav docks against it with a hardcoded `top` in
|
||||
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||
places or not at all. */
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||
margin: -0.375rem -0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||
* currentColor, so there is deliberately no `color` here.
|
||||
*/
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-coral, #e07256);
|
||||
flex: 0 0 auto;
|
||||
/* The artwork is taller than it is wide (153:189), so height drives the box
|
||||
and width follows. Constraining both would squash the pin. */
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
width: 100%;
|
||||
.logoIcon img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||
* negative tracking is what makes the two halves read as a single word rather
|
||||
* than as two.
|
||||
*/
|
||||
.logoText {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.logoTextAccent {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.logo:hover .logoTextAccent {
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sliding underline effect */
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||
state never rests on colour alone. */
|
||||
.navLink.active {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.navLink.active::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
@@ -149,25 +165,23 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
@@ -179,7 +193,29 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||
the badge's anchor. */
|
||||
.tabIconWrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px -9px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.tabActive .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tab:active .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -190,19 +226,21 @@
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border: 2px solid var(--bg-card, white);
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border: 2px solid var(--bg-card);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
@@ -219,8 +257,12 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
@@ -230,9 +272,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 0.06);
|
||||
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||
landscape) insets so the tab content never sits under system UI. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Navigation.module.css';
|
||||
|
||||
type IconProps = { className?: string };
|
||||
@@ -78,6 +79,16 @@ export function Navigation() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||
* system that does not exist. Drawing chrome for features we do not have
|
||||
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||
* which the guideline omits — take their place, because they are real.
|
||||
*
|
||||
* If accounts ever ship, this is the list to extend.
|
||||
*/
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
@@ -89,15 +100,19 @@ export function Navigation() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
|
||||
{/*
|
||||
variant="auto": the teal pin on the white header, swapping to the
|
||||
on-dark artwork for dark-theme viewers. The footer has to force
|
||||
onDark instead, because its band is teal in both themes and the
|
||||
teal pin has no silhouette against it.
|
||||
*/}
|
||||
<span className={styles.logoIcon}>
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
<LogoMark size={38} />
|
||||
</span>
|
||||
<span className={styles.logoText}>
|
||||
school<span className={styles.logoTextAccent}>compare</span>
|
||||
</span>
|
||||
<span className={styles.logoText}>SchoolCompare</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Main navigation">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -21,18 +21,18 @@
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navButton:disabled {
|
||||
@@ -51,32 +51,32 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pageButtonActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.pageButtonActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
@@ -84,7 +84,7 @@
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
.trendSummary {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 0 4px 4px 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.covidNote,
|
||||
.chartHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
@@ -87,21 +87,21 @@
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chipActive:active {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.miniLegend {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
@@ -124,9 +125,17 @@ export function PerformanceChart({
|
||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||
const hasGap = gapYears.length > 0;
|
||||
|
||||
// Chart.js paints to a canvas, so it needs resolved colours rather than
|
||||
// var(). Reading them through the token bridge keeps the series on the
|
||||
// brand palette and lets them follow the theme.
|
||||
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
|
||||
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
borderColor: 'rgba(90,80,70,0.35)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [6, 4] as number[],
|
||||
@@ -140,8 +149,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: col('attainment_8_score'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -151,8 +160,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: col('english_maths_standard_pass_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -161,8 +170,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: col('progress_8_score'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -179,8 +188,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: col('rwm_expected_pct'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -190,8 +199,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Exceeding expected level',
|
||||
data: col('rwm_high_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -206,8 +215,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading progress',
|
||||
data: col('reading_progress'),
|
||||
borderColor: 'rgb(59,130,246)',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderColor: c4,
|
||||
backgroundColor: alpha('--chart-4', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -217,8 +226,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Writing progress',
|
||||
data: col('writing_progress'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -228,8 +237,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Maths progress',
|
||||
data: col('maths_progress'),
|
||||
borderColor: 'rgb(236,72,153)',
|
||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||
borderColor: c5,
|
||||
backgroundColor: alpha('--chart-5', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -301,7 +310,9 @@ export function PerformanceChart({
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 12,
|
||||
titleFont: { size: 13 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -321,7 +332,7 @@ export function PerformanceChart({
|
||||
type: 'linear', display: true, position: 'left',
|
||||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||
min: 0, max: isSecondary ? undefined : 100,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
@@ -346,7 +357,9 @@ export function PerformanceChart({
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
@@ -364,7 +377,7 @@ export function PerformanceChart({
|
||||
y: {
|
||||
type: 'linear', display: true, position: 'left',
|
||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||
},
|
||||
x: {
|
||||
@@ -429,9 +442,9 @@ export function PerformanceChart({
|
||||
belong together. */}
|
||||
{isMobile && activeChip === 'progress' && (
|
||||
<div className={styles.miniLegend}>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -47,34 +47,34 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
@@ -88,7 +88,7 @@
|
||||
.filterLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -98,44 +98,44 @@
|
||||
max-width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.filterSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.filterSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Rankings Section */
|
||||
.rankingsSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
@@ -151,15 +151,15 @@
|
||||
}
|
||||
|
||||
.rankingsTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
@@ -206,8 +206,8 @@
|
||||
|
||||
.rankingsTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:last-child td {
|
||||
@@ -216,24 +216,24 @@
|
||||
|
||||
/* Alternating row backgrounds for visual rhythm */
|
||||
.rankingsTable tbody tr:nth-child(even) {
|
||||
background: rgba(243, 237, 228, 0.5);
|
||||
background: rgba(var(--text-inverse-rgb), 0.5);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Top 3 Highlighting with Gold */
|
||||
.rank1 {
|
||||
background: linear-gradient(90deg, rgba(201, 162, 39, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank2 {
|
||||
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank3 {
|
||||
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rankCell {
|
||||
@@ -241,7 +241,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Styled rank badges for top 3 */
|
||||
@@ -254,8 +254,8 @@
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-on);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -265,30 +265,31 @@
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
|
||||
background: linear-gradient(135deg, rgba(var(--text-inverse-rgb), 0.4), transparent) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
.rankBadge1 {
|
||||
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge2 {
|
||||
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge3 {
|
||||
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolCell {
|
||||
@@ -296,18 +297,18 @@
|
||||
}
|
||||
|
||||
.schoolLink {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolLink:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.areaCell,
|
||||
.typeCell {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||
@@ -315,7 +316,7 @@
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.valueCell {
|
||||
@@ -324,7 +325,7 @@
|
||||
}
|
||||
|
||||
.valueCell strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.noResults p {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -41,20 +41,39 @@
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Per-bar national average marker ──
|
||||
Each bar compares against its own benchmark (expected vs higher standard /
|
||||
greater depth), so the marker sits on the individual bar's track rather than
|
||||
as one line spanning both bars. */
|
||||
/*
|
||||
* Knockout, not a colour.
|
||||
*
|
||||
* This marker was var(--brand) — the same value as .barExpected and
|
||||
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
|
||||
* rendered distinguishably at all. It scored 5.47:1 only against the empty
|
||||
* track, which means it was visible precisely when a school was BELOW the
|
||||
* national average and vanished for every school at or above it.
|
||||
*
|
||||
* No single colour fixes this, because the marker's position is data-driven:
|
||||
* it can land on the bar, on the empty track, or straddle the boundary. So it
|
||||
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
|
||||
* the core reads; on the pale track the edge reads. Both tokens flip with the
|
||||
* theme, so the pairing holds in dark mode too.
|
||||
*
|
||||
* The edge is box-shadow rather than border so it costs no layout width and
|
||||
* cannot shift the 50% translate.
|
||||
*/
|
||||
.natTick {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 0 0 1px var(--text-primary);
|
||||
border-radius: 2px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
@@ -63,13 +82,14 @@
|
||||
.natTick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 0 0 1px var(--text-primary);
|
||||
}
|
||||
|
||||
.barHeaderRight {
|
||||
@@ -81,7 +101,7 @@
|
||||
.natLabel {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -122,19 +142,31 @@
|
||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/*
|
||||
* Expected and Exceeding were both var(--brand) — one colour for two series,
|
||||
* distinguished only by which row you were looking at, while the legend
|
||||
* claimed two.
|
||||
*
|
||||
* They are a sequential pair, not two categories: "exceeding" is a subset of
|
||||
* the same cohort at a harder bar. So they take two steps of the same hue
|
||||
* rather than two different hues, with the harder measure the more intense
|
||||
* step. --brand-stronger is darker than --brand in the light theme and lighter
|
||||
* in the dark one, which is the right direction in both: further from the
|
||||
* ground.
|
||||
*/
|
||||
.barExpected {
|
||||
background: var(--accent-teal-light, #3a9e9e);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barExceeding {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.barLabel {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -142,7 +174,7 @@
|
||||
|
||||
.barLabelSuffix {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -153,7 +185,7 @@
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rulerTick {
|
||||
@@ -161,7 +193,7 @@
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rulerLabel {
|
||||
@@ -169,7 +201,7 @@
|
||||
top: 5px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
transform: translateX(-50%);
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.7;
|
||||
@@ -198,7 +230,7 @@
|
||||
gap: 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
|
||||
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
<SubjectColumn key={subject.name} subject={subject} />
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
The legend describes what is drawn, which it previously did not.
|
||||
|
||||
Both data swatches were var(--status-above) — green — while the bars
|
||||
they labelled were var(--brand) teal, and they were identical to each
|
||||
other, so two different series shared one swatch. Worse, the only
|
||||
swatch that matched the bar colour was the one labelled "National
|
||||
average": anyone reading the chart by matching colours would conclude
|
||||
the teal bars WERE the national average.
|
||||
|
||||
Each swatch now carries the exact value its bar carries, and the
|
||||
national-average swatch mirrors the knockout marker rather than being
|
||||
a flat colour, so it is recognisable as the thing on the chart.
|
||||
*/}
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
||||
Expected standard
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
<div
|
||||
className={styles.legendSwatch}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
boxShadow: '0 0 0 1px var(--text-primary)',
|
||||
width: '3px',
|
||||
height: '12px',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.card {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
@@ -8,13 +8,13 @@
|
||||
}
|
||||
|
||||
.card.cardInCompare {
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 1px var(--brand);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
border-left-color: var(--brand);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.title a:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.distance {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
background: var(--accent-teal-bg);
|
||||
background: var(--brand-bg);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
@@ -63,9 +63,9 @@
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -88,12 +88,13 @@
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -101,7 +102,7 @@
|
||||
|
||||
.metricValue strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -126,18 +127,18 @@
|
||||
}
|
||||
|
||||
.trendUp {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--status-above);
|
||||
background: var(--status-above-bg);
|
||||
}
|
||||
|
||||
.trendDown {
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--status-below);
|
||||
background: var(--status-below-bg);
|
||||
}
|
||||
|
||||
.trendStable {
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: rgba(138, 132, 122, 0.15);
|
||||
color: var(--text-muted);
|
||||
background: rgba(var(--muted-rgb), 0.15);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
.metricHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-weight: 400;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user