Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b7868cbf | ||
|
|
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 |
No files matched your search
@@ -5,3 +5,9 @@ __pycache__/
|
|||||||
pipeline/transform/target/
|
pipeline/transform/target/
|
||||||
pipeline/transform/logs/
|
pipeline/transform/logs/
|
||||||
pipeline/transform/.user.yml
|
pipeline/transform/.user.yml
|
||||||
|
|
||||||
|
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Playwright run artefacts written when the suite is run from the repo root
|
||||||
|
test-results/
|
||||||
+281
-45
@@ -7,6 +7,7 @@ Uses real data from UK Government Compare School Performance downloads.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -34,6 +35,7 @@ from .data_loader import (
|
|||||||
search_schools_typesense,
|
search_schools_typesense,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
|
from .places import build_place_registry
|
||||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json, convert_to_native
|
from .utils import clean_for_json, convert_to_native
|
||||||
|
|
||||||
@@ -48,11 +50,20 @@ PHASE_GROUPS: dict[str, set[str]] = {
|
|||||||
"all-through": {"all-through"},
|
"all-through": {"all-through"},
|
||||||
}
|
}
|
||||||
|
|
||||||
BASE_URL = "https://schoolcompare.co.uk"
|
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
|
||||||
|
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
|
||||||
|
BASE_URL = "https://www.schoolcompare.co.uk"
|
||||||
MAX_SLUG_LENGTH = 60
|
MAX_SLUG_LENGTH = 60
|
||||||
|
|
||||||
# In-memory sitemap cache
|
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
|
||||||
_sitemap_xml: str | None = None
|
# regenerate endpoint after a pipeline run.
|
||||||
|
_sitemaps: dict[str, str] | None = None
|
||||||
|
|
||||||
|
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
||||||
|
# never describe different corpora. Reset by the same admin endpoint.
|
||||||
|
_place_registry: dict | None = None
|
||||||
|
|
||||||
|
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
||||||
|
|
||||||
|
|
||||||
def _slugify(text: str) -> str:
|
def _slugify(text: str) -> str:
|
||||||
@@ -70,43 +81,191 @@ def _school_url(urn: int, school_name: str) -> str:
|
|||||||
return f"/school/{urn}-{slug}"
|
return f"/school/{urn}-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def build_sitemap() -> str:
|
# Routes worth submitting that are not a school page. /admissions was missing
|
||||||
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
# from the sitemap entirely despite being a static, indexable guide.
|
||||||
df = load_school_data()
|
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
|
||||||
|
|
||||||
static_urls = [
|
|
||||||
(BASE_URL + "/", "daily", "1.0"),
|
|
||||||
(BASE_URL + "/rankings", "weekly", "0.8"),
|
|
||||||
(BASE_URL + "/compare", "weekly", "0.8"),
|
|
||||||
]
|
|
||||||
|
|
||||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
# A page has something a search result could state if any of these is present
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
# 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(
|
def _has_publishable_data(row) -> bool:
|
||||||
f" <url><loc>{url}</loc>"
|
"""True when a school page has something a search result could state.
|
||||||
f"<changefreq>{freq}</changefreq>"
|
|
||||||
f"<priority>{priority}</priority></url>"
|
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:
|
# Latest row per URN first, so a school's most recent Ofsted date wins.
|
||||||
seen = set()
|
ordered = df.sort_values("year", ascending=False) if "year" in df.columns else df
|
||||||
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
|
||||||
|
for _, row in ordered.iterrows():
|
||||||
urn = int(row["urn"])
|
urn = int(row["urn"])
|
||||||
name = str(row["school_name"])
|
|
||||||
if urn in seen:
|
if urn in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(urn)
|
seen.add(urn)
|
||||||
path = _school_url(urn, name)
|
if urn not in publishable:
|
||||||
lines.append(
|
continue
|
||||||
f" <url><loc>{BASE_URL}{path}</loc>"
|
|
||||||
f"<changefreq>monthly</changefreq>"
|
|
||||||
f"<priority>0.6</priority></url>"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines.append("</urlset>")
|
lastmod = None
|
||||||
return "\n".join(lines)
|
ofsted_date = row.get("ofsted_date")
|
||||||
|
if ofsted_date is not None and not pd.isna(ofsted_date):
|
||||||
|
lastmod = pd.Timestamp(ofsted_date).date().isoformat()
|
||||||
|
|
||||||
|
rows.append(_url_element(
|
||||||
|
BASE_URL + _school_url(urn, str(row["school_name"])), lastmod))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# Sitemaps cap at 50,000 URLs per file. 10,000 keeps a child small enough to
|
||||||
|
# scan by eye in Search Console, which is the point of splitting at all:
|
||||||
|
# coverage is reported per submitted sitemap, so one file per page family is
|
||||||
|
# what makes an indexation problem attributable to a family.
|
||||||
|
SITEMAP_CHUNK_SIZE = 10_000
|
||||||
|
|
||||||
|
# Children are served under /sitemaps/ because Next.js only treats a whole
|
||||||
|
# bracketed path segment as dynamic — a route folder named "sitemap-[...parts]"
|
||||||
|
# is read as a literal static segment and never matches.
|
||||||
|
SITEMAP_CHILD_PREFIX = "/sitemaps"
|
||||||
|
|
||||||
|
|
||||||
|
def get_place_registry() -> dict:
|
||||||
|
"""The place registry, built once and cached for the process."""
|
||||||
|
global _place_registry
|
||||||
|
if _place_registry is None:
|
||||||
|
_place_registry = build_place_registry(load_school_data())
|
||||||
|
return _place_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _urlset(rows: list[str]) -> str:
|
||||||
|
return "\n".join([
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
*rows,
|
||||||
|
"</urlset>",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _place_url(place) -> str:
|
||||||
|
"""The canonical path for a place. Two namespaces, per the spec.
|
||||||
|
|
||||||
|
Towns and localities share /schools/[place]; authorities take their own
|
||||||
|
prefix because 67 town names collide with an authority name and neither
|
||||||
|
set contains the other.
|
||||||
|
"""
|
||||||
|
if place.kind == "authority":
|
||||||
|
return f"/schools/authority/{place.slug}"
|
||||||
|
if place.kind == "outcode":
|
||||||
|
return f"/schools/near/{place.slug}"
|
||||||
|
return f"/schools/{place.slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||||
|
return [
|
||||||
|
_url_element(BASE_URL + _place_url(p))
|
||||||
|
for p in sorted(get_place_registry().values(),
|
||||||
|
key=lambda p: (p.kind, p.slug))
|
||||||
|
if p.kind in kinds
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemaps() -> dict[str, str]:
|
||||||
|
"""Build the sitemap index and every child, keyed by name."""
|
||||||
|
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]:
|
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||||
@@ -284,7 +443,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan - startup and shutdown events."""
|
"""Application lifespan - startup and shutdown events."""
|
||||||
global _sitemap_xml
|
global _sitemaps
|
||||||
print("Loading school data from marts...")
|
print("Loading school data from marts...")
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
if df.empty:
|
if df.empty:
|
||||||
@@ -294,9 +453,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Pre-compute the latest-year snapshot so the first search request is fast
|
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||||
await asyncio.to_thread(load_latest_school_data)
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
try:
|
try:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemaps = build_sitemaps()
|
||||||
n = _sitemap_xml.count("<url>")
|
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||||
print(f"Sitemap built: {n} URLs.")
|
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: sitemap build failed on startup: {e}")
|
print(f"Warning: sitemap build failed on startup: {e}")
|
||||||
|
|
||||||
@@ -606,17 +765,21 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"religious_denomination": latest.get("religious_denomination", ""),
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"has_sixth_form": latest.get("has_sixth_form"),
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
"nursery_provision": latest.get("nursery_provision"),
|
||||||
"status": latest.get("status"),
|
"status": latest.get("status"),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": latest.get("phase"),
|
"phase": latest.get("phase"),
|
||||||
# GIAS fields
|
# GIAS fields
|
||||||
"website": latest.get("website"),
|
"website": latest.get("website"),
|
||||||
|
"telephone": latest.get("telephone"),
|
||||||
"headteacher_name": latest.get("headteacher_name"),
|
"headteacher_name": latest.get("headteacher_name"),
|
||||||
"capacity": latest.get("capacity"),
|
"capacity": latest.get("capacity"),
|
||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
|
"county": latest.get("county"),
|
||||||
|
"parliamentary_constituency": latest.get("parliamentary_constituency"),
|
||||||
}.items()
|
}.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +791,7 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
|
"admission_distance": supplementary.get("admission_distance"),
|
||||||
"sen_detail": supplementary.get("sen_detail"),
|
"sen_detail": supplementary.get("sen_detail"),
|
||||||
"phonics": supplementary.get("phonics"),
|
"phonics": supplementary.get("phonics"),
|
||||||
"deprivation": supplementary.get("deprivation"),
|
"deprivation": supplementary.get("deprivation"),
|
||||||
@@ -1002,6 +1166,62 @@ async def get_rankings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/places")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def list_places(request: Request):
|
||||||
|
"""Every published place. The sitemap and the link modules read this."""
|
||||||
|
registry = get_place_registry()
|
||||||
|
return {"places": [
|
||||||
|
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
|
||||||
|
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/places/{kind}/{slug}")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def get_place(request: Request, kind: str, slug: str,
|
||||||
|
phase: Optional[str] = None):
|
||||||
|
"""One place: its schools ranked, and its local averages."""
|
||||||
|
if kind not in VALID_PLACE_KINDS:
|
||||||
|
raise HTTPException(status_code=404, detail="No such place")
|
||||||
|
|
||||||
|
place = get_place_registry().get(f"{kind}:{slug}")
|
||||||
|
if place is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No such place")
|
||||||
|
|
||||||
|
df = load_latest_school_data()
|
||||||
|
rows = df[df["urn"].isin(place.urns)]
|
||||||
|
|
||||||
|
if phase:
|
||||||
|
wanted = PHASE_GROUPS.get(phase.lower())
|
||||||
|
if wanted and "phase" in rows.columns:
|
||||||
|
rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)]
|
||||||
|
|
||||||
|
# The metric the page ranks on, which is also the one it averages.
|
||||||
|
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
|
||||||
|
if metric in rows.columns:
|
||||||
|
rows = rows.sort_values(metric, ascending=False, na_position="last")
|
||||||
|
|
||||||
|
averages = {
|
||||||
|
m: (None if m not in rows.columns or rows[m].dropna().empty
|
||||||
|
else float(rows[m].dropna().mean()))
|
||||||
|
for m in ("rwm_expected_pct", "attainment_8_score")
|
||||||
|
}
|
||||||
|
|
||||||
|
cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
||||||
|
"rwm_expected_pct", "attainment_8_score",
|
||||||
|
"total_pupils"]
|
||||||
|
if c in rows.columns]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
|
||||||
|
"count": len(place.urns),
|
||||||
|
"parent_authority": place.parent_authority},
|
||||||
|
"schools": clean_for_json(rows[cols]),
|
||||||
|
"averages": averages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/data-info")
|
@app.get("/api/data-info")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_data_info(request: Request):
|
async def get_data_info(request: Request):
|
||||||
@@ -1082,16 +1302,28 @@ async def robots_txt():
|
|||||||
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
|
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/sitemap.xml")
|
def _serve_sitemap(name: str) -> Response:
|
||||||
async def sitemap_xml():
|
global _sitemaps
|
||||||
"""Serve sitemap.xml for search engine indexing."""
|
if _sitemaps is None:
|
||||||
global _sitemap_xml
|
|
||||||
if _sitemap_xml is None:
|
|
||||||
try:
|
try:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemaps = build_sitemaps()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||||
return Response(content=_sitemap_xml, media_type="application/xml")
|
if name not in _sitemaps:
|
||||||
|
raise HTTPException(status_code=404, detail="No such sitemap")
|
||||||
|
return Response(content=_sitemaps[name], media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sitemap.xml")
|
||||||
|
async def sitemap_xml():
|
||||||
|
"""Serve the sitemap index."""
|
||||||
|
return _serve_sitemap("sitemap.xml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sitemaps/{name}")
|
||||||
|
async def sitemap_child(name: str):
|
||||||
|
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
|
||||||
|
return _serve_sitemap(name)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/regenerate-sitemap")
|
@app.post("/api/admin/regenerate-sitemap")
|
||||||
@@ -1101,10 +1333,14 @@ async def regenerate_sitemap(
|
|||||||
_: bool = Depends(verify_admin_api_key),
|
_: bool = Depends(verify_admin_api_key),
|
||||||
):
|
):
|
||||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||||
global _sitemap_xml
|
global _sitemaps, _place_registry
|
||||||
_sitemap_xml = build_sitemap()
|
# Places and sitemap are rebuilt together — they read the same marts, and
|
||||||
n = _sitemap_xml.count("<url>")
|
# letting them drift apart would submit URLs for places that no longer
|
||||||
return {"status": "ok", "urls": n}
|
# 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)
|
# 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 .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactAdmissions,
|
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||||
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
|
|||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
|
s.telephone,
|
||||||
|
s.nursery_provision,
|
||||||
foi.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
foi.ofsted_date,
|
foi.ofsted_date,
|
||||||
foi.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
|
|||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
l.address_line2 AS address2,
|
l.address_line2 AS address2,
|
||||||
l.town,
|
l.town,
|
||||||
|
l.county,
|
||||||
|
l.parliamentary_constituency,
|
||||||
l.postcode,
|
l.postcode,
|
||||||
l.latitude,
|
l.latitude,
|
||||||
l.longitude,
|
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 = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
_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 = (
|
_GIAS_CODE_COLUMN_NAMES = (
|
||||||
"phase_code",
|
"phase_code",
|
||||||
"school_type_code",
|
"school_type_code",
|
||||||
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
|
|||||||
"admissions_policy_code",
|
"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]:
|
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||||
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
except Exception as exc2:
|
except Exception as exc2:
|
||||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
return pd.DataFrame()
|
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:
|
else:
|
||||||
print(f"Warning: Could not load school data from marts: {exc}")
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
return pd.DataFrame()
|
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:
|
def _census_dict(pc) -> dict:
|
||||||
return {
|
return {
|
||||||
"year": pc.year,
|
"year": pc.year,
|
||||||
@@ -713,6 +770,7 @@ def _empty_supplementary() -> dict:
|
|||||||
"census": None,
|
"census": None,
|
||||||
"admissions": None,
|
"admissions": None,
|
||||||
"admissions_history": [],
|
"admissions_history": [],
|
||||||
|
"admission_distance": None,
|
||||||
"sen_detail": None,
|
"sen_detail": None,
|
||||||
"phonics": None,
|
"phonics": None,
|
||||||
"deprivation": None,
|
"deprivation": None,
|
||||||
@@ -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
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admissions)
|
_safe(_admissions)
|
||||||
|
|
||||||
|
# Last distance offered — the latest year per URN, and only that.
|
||||||
|
#
|
||||||
|
# The mart holds every published year and the DAG keeps loading them; what
|
||||||
|
# changed is what leaves this process. Earlier years are being held back as
|
||||||
|
# a paid feature, and this API is public and unauthenticated — serving the
|
||||||
|
# history here would hand it to anyone who opened the network tab, whatever
|
||||||
|
# the page chose to render. Withholding it in the client would have been
|
||||||
|
# decoration, not a decision.
|
||||||
|
#
|
||||||
|
# Restoring it for entitled callers is a change to this function, not to
|
||||||
|
# the pipeline: fact_admission_distance is untouched and complete.
|
||||||
|
def _admission_distance():
|
||||||
|
rows = (
|
||||||
|
db.query(FactAdmissionDistance)
|
||||||
|
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||||
|
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
seen = set()
|
||||||
|
for d in rows:
|
||||||
|
if d.urn in seen:
|
||||||
|
continue
|
||||||
|
seen.add(d.urn)
|
||||||
|
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
||||||
|
_safe(_admission_distance)
|
||||||
|
|
||||||
# Deprivation — one row per URN.
|
# Deprivation — one row per URN.
|
||||||
def _deprivation():
|
def _deprivation():
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -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))
|
admissions_policy = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
|
class FactAdmissionDistance(Base):
|
||||||
|
"""Last distance offered — one row per URN per year.
|
||||||
|
|
||||||
|
Separate from FactAdmissions because the source is separate: EES publishes
|
||||||
|
admissions for the whole country, whereas cut-off distances exist only for
|
||||||
|
the local authorities that choose to publish them (57 at the time of
|
||||||
|
writing), and the two refresh on unrelated timetables.
|
||||||
|
"""
|
||||||
|
__tablename__ = "fact_admission_distance"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_admission_distance_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
# Straight-line distance in metres from the school to the last home offered
|
||||||
|
# a place that year.
|
||||||
|
distance_m = Column(Float)
|
||||||
|
# How many admission routes (ability bands, separate reception/junior
|
||||||
|
# intakes) were collapsed into distance_m. >1 means the figure is the
|
||||||
|
# furthest of several and the page must say so.
|
||||||
|
route_count = Column(Integer)
|
||||||
|
la_code = Column(Integer)
|
||||||
|
la_name = Column(String(100))
|
||||||
|
# The unit the council published in, so the page can lead with the unit a
|
||||||
|
# parent was given rather than always converting.
|
||||||
|
distance_unit_raw = Column(String(20))
|
||||||
|
source_file = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
class FactPupilCharacteristics(Base):
|
class FactPupilCharacteristics(Base):
|
||||||
"""School pupil composition from EES census — one row per URN per year."""
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
__tablename__ = "fact_pupil_characteristics"
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""The place registry: what places the site publishes, and what is in each.
|
||||||
|
|
||||||
|
One module owns this question. The pages, the sitemap and the internal-link
|
||||||
|
modules all read from here, so the threshold and the collision rules exist in
|
||||||
|
exactly one place and are testable without a browser or a database.
|
||||||
|
|
||||||
|
Two namespaces, never one. 67 viable town names collide with a local
|
||||||
|
authority name, and the authority is the larger set in only 43 of them —
|
||||||
|
postal towns cross authority boundaries, so neither can absorb the other.
|
||||||
|
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Five schools with publishable data. Below this a place has nothing to say
|
||||||
|
# that a list of schools does not, and publishing it is index bloat.
|
||||||
|
MIN_SCHOOLS = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Place:
|
||||||
|
kind: str # "town" | "locality" | "authority" | "outcode"
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
urns: tuple[int, ...]
|
||||||
|
parent_authority: str | None # authority NAME, for the 301 target
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.kind}:{self.slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _publishable_urns(df) -> set[int]:
|
||||||
|
"""URNs with something a page could state, deduplicated across years."""
|
||||||
|
from backend.app import _PUBLISHABLE_FIELDS
|
||||||
|
|
||||||
|
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||||
|
if not cols:
|
||||||
|
return set()
|
||||||
|
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_authority(group) -> str | None:
|
||||||
|
"""The most common authority in a group — the useful 301 target.
|
||||||
|
|
||||||
|
A town spanning several authorities has no single parent, so the mode is
|
||||||
|
the honest answer rather than an arbitrary first row.
|
||||||
|
"""
|
||||||
|
if "local_authority" not in group.columns:
|
||||||
|
return None
|
||||||
|
top = group["local_authority"].dropna()
|
||||||
|
return str(top.mode().iloc[0]) if not top.empty else None
|
||||||
|
|
||||||
|
|
||||||
|
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
||||||
|
"""One Place per distinct value of `column` that clears the threshold."""
|
||||||
|
from backend.app import _slugify
|
||||||
|
|
||||||
|
if column not in df.columns:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict[str, Place] = {}
|
||||||
|
for name, group in df.groupby(column, dropna=True):
|
||||||
|
name = str(name).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||||
|
if len(urns) < MIN_SCHOOLS:
|
||||||
|
continue
|
||||||
|
slug = _slugify(name)
|
||||||
|
if not slug:
|
||||||
|
continue
|
||||||
|
place = Place(
|
||||||
|
kind=kind, slug=slug, name=name, urns=urns,
|
||||||
|
parent_authority=_parent_authority(group) if kind == "town" else None,
|
||||||
|
)
|
||||||
|
out[place.key] = place
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
||||||
|
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
||||||
|
|
||||||
|
|
||||||
|
def _outcode(postcode) -> str | None:
|
||||||
|
if not isinstance(postcode, str):
|
||||||
|
return None
|
||||||
|
m = _OUTCODE_RE.match(postcode.upper().strip())
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
||||||
|
"""One Place per postcode district clearing the threshold.
|
||||||
|
|
||||||
|
These carry no phase variants: nobody searches "primary schools in SW11".
|
||||||
|
"""
|
||||||
|
if "postcode" not in df.columns:
|
||||||
|
return {}
|
||||||
|
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||||
|
working = working[working["_oc"].notna()]
|
||||||
|
|
||||||
|
out: dict[str, Place] = {}
|
||||||
|
for oc, group in working.groupby("_oc"):
|
||||||
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||||
|
if len(urns) < MIN_SCHOOLS:
|
||||||
|
continue
|
||||||
|
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
||||||
|
urns=urns, parent_authority=_parent_authority(group))
|
||||||
|
out[place.key] = place
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _locality_places(df, publishable: set[int],
|
||||||
|
town_slugs: set[str]) -> dict[str, Place]:
|
||||||
|
"""One Place per curated locality clearing the threshold."""
|
||||||
|
from backend.localities import LOCALITY_OUTCODES
|
||||||
|
|
||||||
|
if "postcode" not in df.columns:
|
||||||
|
return {}
|
||||||
|
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||||
|
|
||||||
|
out: dict[str, Place] = {}
|
||||||
|
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||||
|
if slug in town_slugs:
|
||||||
|
# Skip, do not raise. The guard exists so a locality never
|
||||||
|
# silently shadows a town — skipping achieves that, and the error
|
||||||
|
# log makes it loud.
|
||||||
|
#
|
||||||
|
# Raising here took down sitemap generation for all 25,000 school
|
||||||
|
# pages when "richmond" met the GIAS town Richmond in North
|
||||||
|
# Yorkshire. Worse, GIAS town names change without any code change,
|
||||||
|
# so a raise means curated data can break the site spontaneously.
|
||||||
|
# A curation mistake must cost one page, not the sitemap.
|
||||||
|
logger.error(
|
||||||
|
"locality %r collides with the published town of the same "
|
||||||
|
"slug and has been skipped; rename it or remove it", slug)
|
||||||
|
continue
|
||||||
|
group = working[working["_oc"].isin(outcodes)]
|
||||||
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||||
|
if len(urns) < MIN_SCHOOLS:
|
||||||
|
# Not an error — a locality can legitimately be too small. Logged
|
||||||
|
# because one you meant to publish quietly vanishing is the
|
||||||
|
# failure worth hearing about.
|
||||||
|
logger.warning(
|
||||||
|
"locality %s (%s) has %d publishable schools, below the "
|
||||||
|
"threshold of %d - not published",
|
||||||
|
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
||||||
|
continue
|
||||||
|
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
||||||
|
parent_authority=_parent_authority(group))
|
||||||
|
out[place.key] = place
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_place_registry(df) -> dict[str, Place]:
|
||||||
|
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||||
|
if df.empty or "urn" not in df.columns:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
publishable = _publishable_urns(df)
|
||||||
|
registry: dict[str, Place] = {}
|
||||||
|
registry.update(_group(df, "local_authority", "authority", publishable))
|
||||||
|
|
||||||
|
towns = _group(df, "town", "town", publishable)
|
||||||
|
registry.update(towns)
|
||||||
|
|
||||||
|
town_slugs = {p.slug for p in towns.values()}
|
||||||
|
registry.update(_locality_places(df, publishable, town_slugs))
|
||||||
|
registry.update(_outcode_places(df, publishable))
|
||||||
|
return registry
|
||||||
@@ -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():
|
def test_missing_column_name_no_match_returns_none():
|
||||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is 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,270 @@
|
|||||||
|
"""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]
|
||||||
@@ -8,8 +8,31 @@ from backend import data_loader
|
|||||||
from backend.data_loader import get_supplementary_data_batch
|
from backend.data_loader import get_supplementary_data_batch
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(criterion):
|
||||||
|
"""(column name, descending) for a SQLAlchemy order_by argument.
|
||||||
|
|
||||||
|
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
|
||||||
|
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
|
||||||
|
.element.
|
||||||
|
"""
|
||||||
|
name = getattr(criterion, "key", None)
|
||||||
|
if name is not None:
|
||||||
|
return name, False
|
||||||
|
element = getattr(criterion, "element", None)
|
||||||
|
name = getattr(element, "key", None)
|
||||||
|
return name, "DESC" in str(criterion).upper()
|
||||||
|
|
||||||
|
|
||||||
class _FakeQuery:
|
class _FakeQuery:
|
||||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
"""Records that a query ran and serves canned rows filtered by an in-list.
|
||||||
|
|
||||||
|
order_by is honoured rather than ignored. The batch loader picks a row per
|
||||||
|
URN by position — first for "latest Ofsted", last for "latest cut-off
|
||||||
|
distance" — which is only correct because the database returned them
|
||||||
|
sorted. A double that drops the ORDER BY makes those picks depend on
|
||||||
|
fixture insertion order instead, so the test would pass with the sort
|
||||||
|
reversed or removed and prove nothing about the query.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, recorder, model_name, rows):
|
def __init__(self, recorder, model_name, rows):
|
||||||
self._rec = recorder
|
self._rec = recorder
|
||||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
|||||||
def filter(self, *args, **kwargs):
|
def filter(self, *args, **kwargs):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def order_by(self, *args, **kwargs):
|
def order_by(self, *criteria):
|
||||||
|
for crit in reversed(criteria): # reversed = stable multi-key sort
|
||||||
|
name, descending = _sort_key(crit)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
values = [getattr(r, name, None) for r in self._rows]
|
||||||
|
# Only sort on plainly comparable values. Some fixtures stand dates
|
||||||
|
# up as namespace objects, which raise on <; leaving those in their
|
||||||
|
# given order matches what the real query would produce for them.
|
||||||
|
if not all(isinstance(v, (int, float, str)) for v in values):
|
||||||
|
continue
|
||||||
|
self._rows = sorted(
|
||||||
|
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
@@ -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():
|
def test_one_query_per_table_and_latest_row_per_urn():
|
||||||
rows = {
|
rows = {
|
||||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||||
@@ -78,6 +121,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
_ofsted_row(2, "2021-06-01", 1),
|
_ofsted_row(2, "2021-06-01", 1),
|
||||||
],
|
],
|
||||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||||
|
# URN 1 has three years of cut-offs; only the most recent is served.
|
||||||
|
# Deliberately not in year order — the ordering is the query's job.
|
||||||
|
"FactAdmissionDistance": [
|
||||||
|
_dist_row(1, 2026, 529.47),
|
||||||
|
_dist_row(1, 2024, 772.49),
|
||||||
|
_dist_row(1, 2025, 1421.05),
|
||||||
|
_dist_row(2, 2023, 2029.38, route_count=4),
|
||||||
|
],
|
||||||
"FactPupilCharacteristics": [],
|
"FactPupilCharacteristics": [],
|
||||||
"FactDeprivation": [],
|
"FactDeprivation": [],
|
||||||
"FactFinance": [],
|
"FactFinance": [],
|
||||||
@@ -85,10 +136,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
session = _FakeSession(rows)
|
session = _FakeSession(rows)
|
||||||
out = get_supplementary_data_batch(session, [1, 2])
|
out = get_supplementary_data_batch(session, [1, 2])
|
||||||
|
|
||||||
# Exactly one query per table — five total, regardless of two URNs.
|
# Exactly one query per table — six total, regardless of two URNs.
|
||||||
assert sorted(session.queries) == [
|
assert sorted(session.queries) == [
|
||||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Latest Ofsted kept per URN
|
# 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[1]["admissions"]["year"] == 202627
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||||
|
|
||||||
|
# Cut-off distance: the latest year only. Earlier years stay in the mart
|
||||||
|
# but are held back as a paid feature, and this API is public — serving
|
||||||
|
# them here would hand them to anyone reading the response. The fixture
|
||||||
|
# rows are deliberately out of order, so "latest" only comes out right if
|
||||||
|
# the query's ORDER BY is doing the work.
|
||||||
|
assert out[1]["admission_distance"]["year"] == 2026
|
||||||
|
assert "admission_distance_history" not in out[1]
|
||||||
|
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||||
|
# route_count travels with the figure — the page needs it to say the
|
||||||
|
# distance is the furthest of several bands rather than the only one.
|
||||||
|
assert out[2]["admission_distance"]["route_count"] == 4
|
||||||
|
|
||||||
# Empty tables degrade to the null block, not a crash
|
# Empty tables degrade to the null block, not a crash
|
||||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||||
|
|
||||||
@@ -109,3 +172,5 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
|||||||
single = data_loader.get_supplementary_data(session, 5)
|
single = data_loader.get_supplementary_data(session, 5)
|
||||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||||
assert single["admissions_history"] == []
|
assert single["admissions_history"] == []
|
||||||
|
assert single["admission_distance"] is None
|
||||||
|
assert "admission_distance_history" not in single
|
||||||
@@ -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,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".
|
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||||
|
|
||||||
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("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`).
|
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||||
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||||
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- Severity guess: P2
|
- Severity guess: P2
|
||||||
|
|
||||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "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).
|
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||||
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||||
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||||
|
|||||||
@@ -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/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
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
|
# Set correct permissions
|
||||||
RUN chown -R nextjs:nodejs /app
|
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,30 @@
|
|||||||
|
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||||
|
|
||||||
|
jest.mock('@/lib/places', () => ({
|
||||||
|
...jest.requireActual('@/lib/places'),
|
||||||
|
fetchPlace: jest.fn(async (kind: string, slug: string) =>
|
||||||
|
slug === 'atlantis' ? null : ({
|
||||||
|
place: { kind, slug, name: 'Brentwood', count: 29,
|
||||||
|
parent_authority: 'Essex' },
|
||||||
|
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||||
|
})),
|
||||||
|
fetchPlaces: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('place page metadata', () => {
|
||||||
|
it('titles the page the way the place is searched', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||||
|
expect(m.title).toMatch(/schools in brentwood/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalises to its own path on the www host', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||||
|
expect(m.alternates?.canonical)
|
||||||
|
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
||||||
|
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
||||||
|
expect(m.title).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,93 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import type { PlaceDetail } from '@/lib/places';
|
||||||
|
|
||||||
|
const detail: PlaceDetail = {
|
||||||
|
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
|
||||||
|
parent_authority: 'Essex' },
|
||||||
|
schools: [
|
||||||
|
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
|
||||||
|
ofsted_grade: 1 } as never,
|
||||||
|
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
|
||||||
|
ofsted_grade: 3 } as never,
|
||||||
|
],
|
||||||
|
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PlaceView', () => {
|
||||||
|
it('leads with an H1 that matches how the place is searched', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByRole('heading', { level: 1 }))
|
||||||
|
.toHaveTextContent(/primary schools in brentwood/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('states the count so the page says something before the table', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares the local average against England, which a list cannot', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
|
||||||
|
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links every school in scope, which is what de-orphans them', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to the parent authority so the place sits in a hierarchy', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByRole('link', { name: /Essex/i }))
|
||||||
|
.toHaveAttribute('href', '/schools/authority/essex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to neighbouring places so the page is not a dead end', () => {
|
||||||
|
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||||
|
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
|
||||||
|
expect(screen.getByRole('link', { name: /Romford/ }))
|
||||||
|
.toHaveAttribute('href', '/schools/romford');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about an average it does not have', () => {
|
||||||
|
render(<PlaceView detail={{ ...detail, averages:
|
||||||
|
{ rwm_expected_pct: null, attainment_8_score: null } }}
|
||||||
|
phase="primary" englandAverage={61} neighbours={[]} />);
|
||||||
|
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlaceView structured data', () => {
|
||||||
|
function jsonLd() {
|
||||||
|
const { container } = render(<PlaceView detail={detail} phase="primary"
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
const el = container.querySelector('script[type="application/ld+json"]');
|
||||||
|
return JSON.parse(el!.textContent!);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('declares the page as a ranked list, not prose', () => {
|
||||||
|
const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']);
|
||||||
|
expect(types).toContain('ItemList');
|
||||||
|
expect(types).toContain('BreadcrumbList');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every listed school an absolute URL on the canonical host', () => {
|
||||||
|
const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
|
||||||
|
expect(list.itemListElement).toHaveLength(2);
|
||||||
|
for (const item of list.itemListElement) {
|
||||||
|
expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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 type { Metadata } from 'next';
|
||||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
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:
|
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() {
|
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 { fetchComparison, fetchMetrics } from '@/lib/api';
|
||||||
import { ComparisonView } from '@/components/ComparisonView';
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface ComparePageProps {
|
interface ComparePageProps {
|
||||||
@@ -14,13 +15,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:
|
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:
|
keywords:
|
||||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
};
|
alternates: { canonical: absoluteUrl('/compare') },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!urns) return base;
|
||||||
|
|
||||||
|
return { ...base, robots: { index: false, follow: true } };
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
// can still use Next.js's per-call revalidate cache.
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|||||||
+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 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 Script from 'next/script';
|
||||||
import { Navigation } from '@/components/Navigation';
|
import { Navigation } from '@/components/Navigation';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import { SITE_URL } from '@/lib/site';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
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'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700'],
|
weight: ['500', '600', '700'],
|
||||||
variable: '--font-dm-sans',
|
variable: '--font-manrope',
|
||||||
display: 'swap',
|
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'],
|
subsets: ['latin'],
|
||||||
weight: ['600', '700'],
|
weight: ['400', '500', '600', '700'],
|
||||||
variable: '--font-playfair',
|
variable: '--font-inter',
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,42 +33,47 @@ export const viewport: Viewport = {
|
|||||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||||
viewportFit: 'cover',
|
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: [
|
themeColor: [
|
||||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
title: 'SchoolCompare',
|
title: 'schoolcompare',
|
||||||
statusBarStyle: 'default',
|
statusBarStyle: 'default',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: 'SchoolCompare | Compare School Performance',
|
default: 'Compare Schools Side by Side | schoolcompare',
|
||||||
template: '%s | 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',
|
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||||
authors: [{ name: 'SchoolCompare' }],
|
authors: [{ name: 'schoolcompare' }],
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
icons: {
|
// No `icons` key on purpose: setting it here would override the file
|
||||||
icon: '/favicon.svg',
|
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||||
shortcut: '/favicon.svg',
|
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||||
apple: '/favicon.svg',
|
metadataBase: new URL(SITE_URL),
|
||||||
},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
title: 'SchoolCompare | Compare School Performance',
|
title: 'Compare Schools Side by Side | schoolcompare',
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
description:
|
||||||
url: 'https://schoolcompare.co.uk',
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||||
siteName: 'SchoolCompare',
|
url: SITE_URL,
|
||||||
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary',
|
// summary_large_image now that there is an image worth showing.
|
||||||
title: 'SchoolCompare | Compare School Performance',
|
card: 'summary_large_image',
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
title: 'Compare Schools Side by Side | schoolcompare',
|
||||||
|
description:
|
||||||
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +83,15 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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>
|
<head>
|
||||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||||
@@ -94,8 +113,7 @@ export default function RootLayout({
|
|||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
<body>
|
||||||
<div className="noise-overlay" />
|
|
||||||
<ComparisonProvider>
|
<ComparisonProvider>
|
||||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||||
<Navigation />
|
<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
|
* 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 { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||||
import { EditorialSection } from '@/components/EditorialSection';
|
import { EditorialSection } from '@/components/EditorialSection';
|
||||||
@@ -24,9 +27,32 @@ interface HomePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
/*
|
||||||
title: 'Home',
|
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||||
description: 'Search and compare school performance across England',
|
* 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.
|
// 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 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 (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={resolvedFilters}
|
filters={resolvedFilters}
|
||||||
totalSchools={total}
|
totalSchools={total}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
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) {
|
} catch (error) {
|
||||||
@@ -99,7 +135,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
filters={emptyFilters}
|
filters={emptyFilters}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
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 { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
||||||
import { RankingsView } from '@/components/RankingsView';
|
import { RankingsView } from '@/components/RankingsView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface RankingsPageProps {
|
interface RankingsPageProps {
|
||||||
@@ -17,9 +18,15 @@ interface RankingsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'School Rankings',
|
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
// 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',
|
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||||
|
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||||
|
// now. W3 replaces them with real indexable paths.
|
||||||
|
alternates: { canonical: absoluteUrl('/rankings') },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
@@ -14,6 +15,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
disallow: ['/api/', '/_next/'],
|
disallow: ['/api/', '/_next/'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
|
sitemap: absoluteUrl('/sitemap.xml'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,18 @@
|
|||||||
* URL format: /school/138267-school-name-here
|
* 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 { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
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 { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
|
import type { NationalAverages } from '@/lib/types';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,8 +98,8 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
url: absoluteUrl(canonicalPath),
|
||||||
siteName: 'SchoolCompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary',
|
card: 'summary',
|
||||||
@@ -100,7 +107,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
canonical: absoluteUrl(canonicalPath),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -124,16 +131,24 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
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 data;
|
||||||
|
let nationalAvg: NationalAverages | null = null;
|
||||||
try {
|
try {
|
||||||
data = await fetchSchoolDetails(urn);
|
[data, nationalAvg] = await Promise.all([
|
||||||
|
fetchSchoolDetails(urn),
|
||||||
|
fetchNationalAverages().catch(() => null),
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch school ${urn}:`, error);
|
console.error(`Failed to fetch school ${urn}:`, error);
|
||||||
notFound();
|
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
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -143,13 +158,32 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
|
|
||||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||||
const isAllThrough = phaseStr === 'all-through';
|
const isAllThrough = phaseStr === 'all-through';
|
||||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||||
const isSecondary = !isAllThrough && (
|
const isSecondary = !isAllThrough && (
|
||||||
phaseStr.includes('secondary')
|
phaseStr.includes('secondary')
|
||||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
|| 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
|
// Generate JSON-LD structured data for SEO
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -184,20 +218,13 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
{isSecondary ? (
|
{isSecondary ? (
|
||||||
<SecondarySchoolDetailView
|
<SchoolDetailShell
|
||||||
schoolInfo={school_info}
|
schoolInfo={school_info}
|
||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
|
||||||
ofsted={ofsted ?? null}
|
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
navItems={secondaryNavItems}
|
||||||
senDetail={sen_detail ?? null}
|
>
|
||||||
phonics={phonics ?? null}
|
<SecondarySchoolSections
|
||||||
deprivation={deprivation ?? null}
|
|
||||||
finance={finance ?? null}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SchoolDetailView
|
|
||||||
schoolInfo={school_info}
|
schoolInfo={school_info}
|
||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
absenceData={absence_data}
|
absenceData={absence_data}
|
||||||
@@ -205,11 +232,35 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
senDetail={sen_detail ?? null}
|
admissionDistance={admission_distance ?? null}
|
||||||
phonics={phonics ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? 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: `${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,91 @@
|
|||||||
|
/**
|
||||||
|
* Town and locality pages.
|
||||||
|
*
|
||||||
|
* A place below the five-school threshold is not in the registry, so
|
||||||
|
* fetchPlace returns null and the request 404s rather than rendering a page
|
||||||
|
* with nothing to say.
|
||||||
|
*/
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
||||||
|
import { fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { PlaceView } from '@/components/places/PlaceView';
|
||||||
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ place: string }> }
|
||||||
|
|
||||||
|
// ISR: place aggregates change only when the pipeline runs.
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
||||||
|
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||||
|
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||||
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||||
|
try {
|
||||||
|
return (await fetchPlaces())
|
||||||
|
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||||
|
.map((p) => ({ place: p.slug }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(slug: string) {
|
||||||
|
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Other towns in the same authority — the cheapest honest definition of
|
||||||
|
* "nearby", and enough to stop each place page being a dead end. */
|
||||||
|
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
||||||
|
if (!detail.place.parent_authority) return [];
|
||||||
|
const all = await fetchPlaces();
|
||||||
|
return all
|
||||||
|
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { place: slug } = await params;
|
||||||
|
const detail = await resolve(slug);
|
||||||
|
if (!detail) return { title: 'Place Not Found' };
|
||||||
|
|
||||||
|
const { name, count } = detail.place;
|
||||||
|
return {
|
||||||
|
title: `Schools in ${name} — Compare ${count} Schools | schoolcompare`,
|
||||||
|
description:
|
||||||
|
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
|
||||||
|
+ `the local average against England, and how close you had to live to get a place.`,
|
||||||
|
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PlacePage({ params }: Props) {
|
||||||
|
const { place: slug } = await params;
|
||||||
|
const detail = await resolve(slug);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
// Global constraint: no page without a local average. A place with too few
|
||||||
|
// schools carrying results has nothing to say that a list does not, so it
|
||||||
|
// defers to its authority rather than publishing a thin page.
|
||||||
|
if (detail.averages.rwm_expected_pct == null
|
||||||
|
&& detail.averages.attainment_8_score == null) {
|
||||||
|
if (detail.place.parent_authority) {
|
||||||
|
redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`);
|
||||||
|
}
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const national = await fetchNationalAverages().catch(() => null);
|
||||||
|
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
||||||
|
// — not flat. Reading it flat silently yields undefined and the page renders
|
||||||
|
// with no comparison, which is the one thing that makes it not a list.
|
||||||
|
return (
|
||||||
|
<PlaceView
|
||||||
|
detail={detail}
|
||||||
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||||
|
neighbours={await neighboursOf(detail)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: `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: `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 @@
|
|||||||
/**
|
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||||
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
|
||||||
*
|
|
||||||
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
|
||||||
* baking the backend host into the build, so one image works in every
|
|
||||||
* environment. robots.ts points crawlers here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
function backendOrigin(): string {
|
|
||||||
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
|
||||||
return base.replace(/\/api$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
let upstream: Response;
|
return proxySitemap('/sitemap.xml');
|
||||||
try {
|
|
||||||
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
|
||||||
} catch {
|
|
||||||
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await upstream.text();
|
|
||||||
return new NextResponse(body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
|
||||||
|
* validated here rather than passed through, so this route cannot be used to
|
||||||
|
* reach arbitrary backend paths.
|
||||||
|
*/
|
||||||
|
const CHILD = /^(static|schools-\d+|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 { Line } from 'react-chartjs-2';
|
||||||
import { ChartOptions } from 'chart.js';
|
import { ChartOptions } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
|
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
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 yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||||
const yMax = Math.min(100, Math.ceil((hi + 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'> = {
|
const options: ChartOptions<'line'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
backgroundColor: cInverse,
|
||||||
|
titleColor: cInverseText,
|
||||||
|
bodyColor: cInverseText,
|
||||||
padding: 10,
|
padding: 10,
|
||||||
titleFont: { size: 12 },
|
titleFont: { size: 12 },
|
||||||
bodyFont: { size: 12 },
|
bodyFont: { size: 12 },
|
||||||
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
y: {
|
y: {
|
||||||
min: yMin,
|
min: yMin,
|
||||||
max: yMax,
|
max: yMax,
|
||||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
grid: { color: cGrid },
|
||||||
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
data: values,
|
data: values,
|
||||||
clip: false as const,
|
clip: false as const,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
borderColor: '#e07256',
|
borderColor: cBrand,
|
||||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
backgroundColor: alpha('--brand', 0.10),
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||||
pointBackgroundColor: '#e07256',
|
pointBackgroundColor: cBrand,
|
||||||
pointBorderColor: '#fff',
|
pointBorderColor: cCard,
|
||||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 6,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
padding-left: 0.85rem;
|
padding-left: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
border-left: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink {
|
.navLink {
|
||||||
@@ -51,33 +51,33 @@
|
|||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s ease, border-color 0.15s ease;
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:hover {
|
.navLink:hover {
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLinkActive {
|
.navLinkActive {
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--brand);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-left-color: var(--accent-teal, #2d7d7d);
|
border-left-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navDot {
|
.navDot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--border-color, #e5dfd5);
|
background: var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLinkActive .navDot {
|
.navLinkActive .navDot {
|
||||||
background: var(--accent-teal, #2d7d7d);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Hero ───────────────────────────────────────────── */
|
/* ─── Hero ───────────────────────────────────────────── */
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--brand);
|
||||||
background: rgba(45, 125, 125, 0.1);
|
background: rgba(var(--brand-rgb), 0.1);
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -107,22 +107,22 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-teal, #2d7d7d);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
font-size: 2.75rem;
|
font-size: 2.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroSub {
|
.heroSub {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
|
|
||||||
.countdownSection {
|
.countdownSection {
|
||||||
padding: 0 0 2.5rem;
|
padding: 0 0 2.5rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownRail {
|
.countdownRail {
|
||||||
@@ -155,11 +155,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem 1.1rem 0.9rem;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -177,12 +177,12 @@
|
|||||||
border-radius: 12px 12px 0 0;
|
border-radius: 12px 12px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
.chipDeadline::before { background: var(--brand); }
|
||||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
.chipOffer::before { background: var(--status-above); }
|
||||||
|
|
||||||
.chipUrgent {
|
.chipUrgent {
|
||||||
border-color: rgba(224, 114, 86, 0.4);
|
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||||
background: rgba(224, 114, 86, 0.04);
|
background: rgba(var(--status-below-rgb), 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrack {
|
.chipTrack {
|
||||||
@@ -196,8 +196,8 @@
|
|||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
.chipTrackDeadline { color: var(--brand-strong); }
|
||||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
.chipTrackOffer { color: var(--status-above); }
|
||||||
|
|
||||||
.chipTrackDot {
|
.chipTrackDot {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
@@ -208,7 +208,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chipDays {
|
.chipDays {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 2.6rem;
|
font-size: 2.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -217,17 +218,17 @@
|
|||||||
|
|
||||||
.chipDeadline .chipDays,
|
.chipDeadline .chipDays,
|
||||||
.chipUrgent .chipDays {
|
.chipUrgent .chipDays {
|
||||||
color: var(--accent-coral-dark, #c45a3f);
|
color: var(--status-below);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipOffer .chipDays {
|
.chipOffer .chipDays {
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--status-above);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDaysUnit {
|
.chipDaysUnit {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-left: 0.2rem;
|
margin-left: 0.2rem;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
@@ -236,14 +237,14 @@
|
|||||||
.chipMilestone {
|
.chipMilestone {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDate {
|
.chipDate {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-top: 0.05rem;
|
margin-top: 0.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,8 +252,8 @@
|
|||||||
|
|
||||||
.track {
|
.track {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 1.75rem 2rem 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;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,22 +279,22 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trackTitle {
|
.trackTitle {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trackSub {
|
.trackSub {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -317,13 +318,13 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trackDateVal {
|
.trackDateVal {
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Timeline ───────────────────────────────────────── */
|
/* ─── Timeline ───────────────────────────────────────── */
|
||||||
@@ -356,27 +357,27 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card);
|
||||||
border: 2px solid var(--border-color, #e5dfd5);
|
border: 2px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: border-color 0.2s ease, background 0.2s ease;
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepDeadline .stepDot {
|
.stepDeadline .stepDot {
|
||||||
background: rgba(224, 114, 86, 0.15);
|
background: rgba(var(--brand-rgb), 0.15);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepOffer .stepDot {
|
.stepOffer .stepDot {
|
||||||
background: rgba(45, 125, 125, 0.15);
|
background: rgba(var(--status-above-rgb), 0.15);
|
||||||
border-color: var(--accent-teal, #2d7d7d);
|
border-color: var(--status-above);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepLine {
|
.stepLine {
|
||||||
width: 2px;
|
width: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
background: var(--border-color, #e5dfd5);
|
background: var(--border);
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,27 +396,28 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
.stepDeadline .stepDate { color: var(--brand-strong); }
|
||||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
.stepOffer .stepDate { color: var(--status-above); }
|
||||||
|
|
||||||
.stepTitle {
|
.stepTitle {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
.stepDeadline .stepTitle { color: var(--brand-strong); }
|
||||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
.stepOffer .stepTitle { color: var(--status-above); }
|
||||||
|
|
||||||
.stepBody {
|
.stepBody {
|
||||||
|
font-family: var(--font-prose);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -427,13 +429,13 @@
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--status-above);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: gap 0.2s ease, color 0.2s ease;
|
transition: gap 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepTool:hover {
|
.stepTool:hover {
|
||||||
color: var(--accent-coral-dark, #c45a3f);
|
color: var(--brand-strong);
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,9 +457,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
background: var(--status-below-bg);
|
||||||
border: 1px solid rgba(201, 162, 39, 0.28);
|
border: 1px solid rgba(var(--status-below-rgb), 0.28);
|
||||||
border-left: 3px solid var(--accent-gold, #c9a227);
|
border-left: 3px solid var(--status-below);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,8 +469,8 @@
|
|||||||
height: 1.05rem;
|
height: 1.05rem;
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-gold, #c9a227);
|
background: var(--status-below);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.05rem;
|
line-height: 1.05rem;
|
||||||
@@ -482,14 +484,15 @@
|
|||||||
.calloutTitle {
|
.calloutTitle {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-gold-text, #7a6800);
|
color: var(--status-below);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calloutBody {
|
.calloutBody {
|
||||||
|
font-family: var(--font-prose);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -501,10 +504,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tipsHeading {
|
.tipsHeading {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,16 +518,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tipCard {
|
.tipCard {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipNumber {
|
.tipNumber {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
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;
|
line-height: 1;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
@@ -532,14 +536,15 @@
|
|||||||
.tipHeading {
|
.tipHeading {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipBody {
|
.tipBody {
|
||||||
|
font-family: var(--font-prose);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -557,8 +562,8 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
background: var(--bg-primary, #faf8f3);
|
background: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
margin: 0 -1.25rem 1rem;
|
margin: 0 -1.25rem 1rem;
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -590,8 +595,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navLinkActive {
|
.navLinkActive {
|
||||||
background: var(--text-primary, #1a1612);
|
background: var(--text-primary);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
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.',
|
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',
|
date: '31 October',
|
||||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
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.',
|
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',
|
date: '15 January',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, .12);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip[aria-pressed="true"] {
|
.chip[aria-pressed="true"] {
|
||||||
background: rgba(0, 0, 0, .06);
|
background: var(--bg-secondary);
|
||||||
border-color: rgba(0, 0, 0, .35);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDot {
|
.chipDot {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
|
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||||||
import { buildCompareChart } from '@/lib/compareChartData';
|
import { buildCompareChart } from '@/lib/compareChartData';
|
||||||
import type { ComparisonData } from '@/lib/types';
|
import type { ComparisonData } from '@/lib/types';
|
||||||
import {
|
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) {
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||||
const isMobile = useIsMobile();
|
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);
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||||
|
|
||||||
// A focused school that leaves the basket must not linger.
|
// 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 datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||||
const school = schools[series.schoolIndex];
|
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;
|
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
datasets.push({
|
datasets.push({
|
||||||
label: built.englandDataset.label,
|
label: built.englandDataset.label,
|
||||||
data: built.englandDataset.data,
|
data: built.englandDataset.data,
|
||||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
borderColor: cRef,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderDash: built.englandDataset.borderDash,
|
borderDash: built.englandDataset.borderDash,
|
||||||
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
backgroundColor: cInverse,
|
||||||
|
titleColor: cInverseText,
|
||||||
|
bodyColor: cInverseText,
|
||||||
padding: isMobile ? 10 : 12,
|
padding: isMobile ? 10 : 12,
|
||||||
titleFont: {
|
titleFont: {
|
||||||
size: isMobile ? 12 : 14,
|
size: isMobile ? 12 : 14,
|
||||||
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
...(isMobile && { maxTicksLimit: 5 }),
|
...(isMobile && { maxTicksLimit: 5 }),
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
color: cGrid,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: var(--bg-primary, #faf7f2);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary, #2c2420);
|
color: var(--text-primary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
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-color, #e8ddd4);
|
border: 1px solid var(--border);
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -62,13 +62,13 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-primary, #2c2420);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapseBtn {
|
.collapseBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collapseBtn:hover {
|
.collapseBtn:hover {
|
||||||
color: var(--text-primary, #2c2420);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolList {
|
.schoolList {
|
||||||
@@ -95,13 +95,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm, 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-primary, #2c2420);
|
color: var(--text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
.removeSchoolBtn {
|
.removeSchoolBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.removeSchoolBtn:hover {
|
.removeSchoolBtn:hover {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastActions {
|
.toastActions {
|
||||||
@@ -129,13 +129,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-top: 0.625rem;
|
padding-top: 0.625rem;
|
||||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnClearAll {
|
.btnClearAll {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -145,13 +145,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btnClearAll:hover {
|
.btnClearAll:hover {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnCompare {
|
.btnCompare {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
.btnCompare:hover {
|
.btnCompare:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
background: var(--accent-coral-dark, #c9614a);
|
background: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 60ch;
|
max-width: 60ch;
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
padding: 0.625rem 1.5rem;
|
padding: 0.625rem 1.5rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -62,21 +62,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.phaseTab:not(:last-child) {
|
.phaseTab:not(:last-child) {
|
||||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTab:hover {
|
.phaseTab:hover {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive {
|
.phaseTabActive {
|
||||||
background: var(--accent-coral-dark, #b04a2e);
|
background: var(--brand-strong);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive:hover {
|
.phaseTabActive:hover {
|
||||||
background: var(--accent-coral-darker, #9c3f26);
|
background: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||||
@@ -87,23 +87,23 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 65px;
|
top: 65px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: var(--bg-primary, #faf7f2);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolChip {
|
.schoolChip {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-light, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-top: 3px solid var(--accent-coral, #e07256);
|
border-top: 3px solid var(--brand);
|
||||||
border-radius: 8px;
|
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;
|
padding: 0.55rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -126,12 +126,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipName:hover {
|
.chipName:hover {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Full name on desktop, short name on the compact mobile pills. */
|
/* Full name on desktop, short name on the compact mobile pills. */
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
.chipMeta {
|
.chipMeta {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -185,22 +185,22 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barCaptionCount {
|
.barCaptionCount {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipRemove {
|
.chipRemove {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -212,9 +212,9 @@
|
|||||||
|
|
||||||
.footnote {
|
.footnote {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
max-width: 75ch;
|
max-width: 75ch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
|||||||
const url = window.location.href;
|
const url = window.location.href;
|
||||||
const count = selectedSchools.length;
|
const count = selectedSchools.length;
|
||||||
const shareData = {
|
const shareData = {
|
||||||
title: 'School comparison · SchoolCompare',
|
title: 'School comparison · schoolcompare',
|
||||||
text:
|
text:
|
||||||
count > 0
|
count > 0
|
||||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||||
: 'SchoolCompare',
|
: 'schoolcompare',
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -23,16 +23,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.good {
|
.good {
|
||||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
background: var(--status-above-bg);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--status-above);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bad {
|
.bad {
|
||||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
background: var(--status-below-bg);
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--status-below);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neutral {
|
.neutral {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 2px solid var(--bg-card);
|
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 {
|
.pointLabel {
|
||||||
|
|||||||
@@ -5,54 +5,60 @@ import styles from './HomeView.module.css';
|
|||||||
interface EditorialSectionProps {
|
interface EditorialSectionProps {
|
||||||
totalSchools: number | null;
|
totalSchools: number | null;
|
||||||
localAuthorityCount: number;
|
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 (
|
return (
|
||||||
<section className={styles.editorial}>
|
// No class on the section: the page container owns the vertical rhythm
|
||||||
<div className={styles.editorialGrid}>
|
// 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.editorialText}>
|
||||||
<div className={styles.editorialKicker}>About school data</div>
|
|
||||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
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
|
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.
|
and demographics — each in its own table, each with its own jargon.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
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
|
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
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
full inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.factbox}>
|
{coverage.length > 0 && (
|
||||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 2px solid var(--border-color, #e5dfd5);
|
border: 2px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
min-height: 400px;
|
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 {
|
.icon {
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -22,14 +22,14 @@
|
|||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin: 0 0 2rem 0;
|
margin: 0 0 2rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
padding: 0.875rem 2rem;
|
padding: 0.875rem 2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--brand-strong);
|
||||||
transform: translateY(-1px);
|
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 {
|
.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 {
|
.filterBar {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1rem 1.125rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
box-shadow: var(--shadow-soft);
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterBar.isLoading {
|
.filterBar.isLoading {
|
||||||
@@ -14,112 +29,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heroMode {
|
.heroMode {
|
||||||
padding: 2.5rem;
|
background: none;
|
||||||
max-width: 800px;
|
border: 0;
|
||||||
margin: 0 auto 3rem auto;
|
border-radius: 0;
|
||||||
box-shadow: 0 8px 24px rgba(26, 22, 18, 0.08);
|
box-shadow: none;
|
||||||
border-width: 2px;
|
padding: 0;
|
||||||
border-color: var(--accent-coral, #e07256);
|
max-width: 640px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroMode .omniInput {
|
/* ── Search ───────────────────────────────────────────────────────── */
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -127,43 +46,90 @@
|
|||||||
|
|
||||||
.omniBoxContainer {
|
.omniBoxContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.omniInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.875rem 1.25rem;
|
min-width: 0;
|
||||||
font-size: 1.05rem;
|
padding: 0.6875rem 0.875rem;
|
||||||
border: 2px solid var(--border-color, #e5dfd5);
|
font-family: var(--font-ui);
|
||||||
border-radius: 8px;
|
/* 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;
|
outline: none;
|
||||||
transition: all 0.2s ease;
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.omniInput::placeholder {
|
.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 {
|
.searchButton {
|
||||||
padding: 0.875rem 2rem;
|
flex: 0 0 auto;
|
||||||
font-size: 1.05rem;
|
padding: 0.6875rem 1.25rem;
|
||||||
border-radius: 8px;
|
font-size: var(--step--1);
|
||||||
min-width: 120px;
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .searchButton {
|
||||||
|
padding: 0.75rem 1.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 20px;
|
width: 1.0625rem;
|
||||||
height: 20px;
|
height: 1.0625rem;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||||
|
border-top-color: currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: white;
|
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,103 +137,86 @@
|
|||||||
to { transform: rotate(360deg); }
|
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;
|
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;
|
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;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.375rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radiusLabel {
|
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||||
font-size: 0.8125rem;
|
action and nothing beside it may compete for the same weight. */
|
||||||
font-weight: 500;
|
.nearMeBtn {
|
||||||
color: var(--text-secondary, #5a554d);
|
display: inline-flex;
|
||||||
white-space: nowrap;
|
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 {
|
.controlsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -275,7 +224,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.875rem;
|
margin-top: 0.875rem;
|
||||||
padding-top: 0.875rem;
|
padding-top: 0.875rem;
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsRow .advancedToggle {
|
.controlsRow .advancedToggle {
|
||||||
@@ -289,76 +238,157 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pill-style inline filter controls (radius + phase) */
|
.radiusLabel {
|
||||||
.controlSelect {
|
font-size: var(--step--2);
|
||||||
appearance: none;
|
font-weight: 600;
|
||||||
-webkit-appearance: none;
|
color: var(--text-secondary);
|
||||||
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;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlSelect:hover {
|
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||||
border-color: var(--text-muted, #8a847a);
|
|
||||||
background-color: var(--bg-secondary, #f8f4ef);
|
.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 {
|
.controlSelect:focus {
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
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 {
|
.advancedToggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.4rem 0.875rem;
|
font-family: var(--font-ui);
|
||||||
font-size: 0.8125rem;
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #5a554d);
|
line-height: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: border-color var(--transition), background-color var(--transition),
|
||||||
|
color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedToggle:hover {
|
.advancedToggle:hover {
|
||||||
border-color: var(--text-muted, #8a847a);
|
border-color: var(--border-strong);
|
||||||
background-color: var(--bg-secondary, #f8f4ef);
|
background-color: var(--bg-secondary);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When filters are applied, promote the toggle to a coral pill so users
|
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||||
can see at a glance that the result list is being narrowed. */
|
the list below is being narrowed. */
|
||||||
.advancedToggleActive {
|
.advancedToggleActive {
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
background: var(--brand-bg);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedToggleActive:hover {
|
.advancedToggleActive:hover {
|
||||||
border-color: var(--accent-coral-dark, #c45a3f);
|
border-color: var(--brand);
|
||||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
background: var(--brand-bg);
|
||||||
color: var(--accent-coral-dark, #c45a3f);
|
color: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
padding: 0.4375rem 0.875rem;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevronDown,
|
.chevronDown,
|
||||||
@@ -377,3 +407,94 @@
|
|||||||
.chevronUp {
|
.chevronUp {
|
||||||
border-bottom: 4.5px solid currentColor;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from "@/lib/utils";
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
|||||||
geoError?: string | null;
|
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({
|
export function FilterBar({
|
||||||
filters,
|
filters,
|
||||||
isHero,
|
isHero,
|
||||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
|||||||
const isSecondaryMode =
|
const isSecondaryMode =
|
||||||
currentPhase === "secondary" || genderOptions.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||||
<div className={styles.omniBoxContainer}>
|
<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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`btn btn-primary ${styles.searchButton}`}
|
className={`btn btn-action ${styles.searchButton}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isHero && (
|
{isHero && (
|
||||||
@@ -248,6 +292,7 @@ export function FilterBar({
|
|||||||
{currentPostcode && (
|
{currentPostcode && (
|
||||||
<div className={styles.radiusControl}>
|
<div className={styles.radiusControl}>
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
<label className={styles.radiusLabel}>Within:</label>
|
||||||
|
<SelectShell>
|
||||||
<select
|
<select
|
||||||
value={currentRadius}
|
value={currentRadius}
|
||||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||||
@@ -259,14 +304,16 @@ export function FilterBar({
|
|||||||
<option value="3">3 miles</option>
|
<option value="3">3 miles</option>
|
||||||
<option value="5">5 miles</option>
|
<option value="5">5 miles</option>
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phaseOptions.length > 0 && (
|
{phaseOptions.length > 0 && (
|
||||||
|
<SelectShell>
|
||||||
<select
|
<select
|
||||||
value={currentPhase}
|
value={currentPhase}
|
||||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||||
className={styles.controlSelect}
|
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Phases</option>
|
<option value="">All Phases</option>
|
||||||
@@ -276,6 +323,7 @@ export function FilterBar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -307,12 +355,13 @@ export function FilterBar({
|
|||||||
|
|
||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
|
<SelectShell wide>
|
||||||
<select
|
<select
|
||||||
value={currentLA}
|
value={currentLA}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("local_authority", e.target.value)
|
handleFilterChange("local_authority", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Local Authorities</option>
|
<option value="">All Local Authorities</option>
|
||||||
@@ -322,13 +371,15 @@ export function FilterBar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
|
<SelectShell wide>
|
||||||
<select
|
<select
|
||||||
value={currentType}
|
value={currentType}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("school_type", e.target.value)
|
handleFilterChange("school_type", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All School Types</option>
|
<option value="">All School Types</option>
|
||||||
@@ -338,16 +389,18 @@ export function FilterBar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
{isSecondaryMode && (
|
{isSecondaryMode && (
|
||||||
<>
|
<>
|
||||||
{genderOptions.length > 0 && (
|
{genderOptions.length > 0 && (
|
||||||
|
<SelectShell wide>
|
||||||
<select
|
<select
|
||||||
value={currentGender}
|
value={currentGender}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("gender", e.target.value)
|
handleFilterChange("gender", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">Boys, Girls & Mixed</option>
|
<option value="">Boys, Girls & Mixed</option>
|
||||||
@@ -357,28 +410,32 @@ export function FilterBar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SelectShell wide>
|
||||||
<select
|
<select
|
||||||
value={currentHasSixthForm}
|
value={currentHasSixthForm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("has_sixth_form", e.target.value)
|
handleFilterChange("has_sixth_form", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">With or without sixth form</option>
|
<option value="">With or without sixth form</option>
|
||||||
<option value="yes">With sixth form</option>
|
<option value="yes">With sixth form</option>
|
||||||
<option value="no">Without sixth form</option>
|
<option value="no">Without sixth form</option>
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
{admissionsPolicyOptions.length > 0 && (
|
{admissionsPolicyOptions.length > 0 && (
|
||||||
|
<SelectShell wide>
|
||||||
<select
|
<select
|
||||||
value={currentAdmissionsPolicy}
|
value={currentAdmissionsPolicy}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("admissions_policy", e.target.value)
|
handleFilterChange("admissions_policy", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All admissions types</option>
|
<option value="">All admissions types</option>
|
||||||
@@ -388,6 +445,7 @@ export function FilterBar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 {
|
.footer {
|
||||||
background: var(--accent-navy, #2c3e50);
|
background: var(--surface-sunken);
|
||||||
color: var(--bg-secondary, #f3ede4);
|
color: var(--on-sunken);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +22,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.5fr 1fr 1fr;
|
grid-template-columns: 1.6fr 1fr 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
@@ -20,31 +30,97 @@
|
|||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lockup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
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 {
|
.description {
|
||||||
margin: 0;
|
margin: -0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
line-height: 1.6;
|
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 {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-size: var(--step--1);
|
||||||
color: var(--accent-gold, #c9a227);
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.links {
|
.links {
|
||||||
@@ -57,43 +133,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
font-size: 0.875rem;
|
font-family: var(--font-ui);
|
||||||
color: rgba(250, 247, 242, 0.7);
|
font-size: var(--step--1);
|
||||||
|
color: var(--on-sunken-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
color: var(--accent-gold, #c9a227);
|
color: var(--on-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkDisabled {
|
.linkDisabled {
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
color: rgba(250, 247, 242, 0.4);
|
color: var(--on-sunken-faint);
|
||||||
cursor: not-allowed;
|
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 {
|
.bottom {
|
||||||
|
position: relative;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
border-top: 1px solid rgba(250, 247, 242, 0.15);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--on-sunken);
|
||||||
|
opacity: 0.16;
|
||||||
|
}
|
||||||
|
|
||||||
.copyright,
|
.copyright,
|
||||||
.disclaimer {
|
.disclaimer {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
color: rgba(250, 247, 242, 0.6);
|
color: var(--on-sunken-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer .link {
|
.disclaimer .link {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--on-sunken-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer .link:hover {
|
.disclaimer .link:hover {
|
||||||
color: var(--accent-gold, #c9a227);
|
color: var(--on-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Footer Component
|
* 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';
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
@@ -13,10 +18,31 @@ export function Footer() {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.section}>
|
<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}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</p>
|
||||||
|
{/*
|
||||||
|
Says once, plainly, what the landing page's value props only
|
||||||
|
imply: we publish official data, we are not an official body.
|
||||||
|
Cheap to state and expensive to be wrong about.
|
||||||
|
*/}
|
||||||
|
<p className={styles.independence}>
|
||||||
|
An independent site. Not affiliated with the Department for Education or Ofsted.
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:contact@schoolcompare.co.uk"
|
href="mailto:contact@schoolcompare.co.uk"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
@@ -31,7 +57,9 @@ export function Footer() {
|
|||||||
<ul className={styles.links}>
|
<ul className={styles.links}>
|
||||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||||
<li><a href="/rankings" className={styles.link}>Rankings</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>
|
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +97,7 @@ export function Footer() {
|
|||||||
|
|
||||||
<div className={styles.bottom}>
|
<div className={styles.bottom}>
|
||||||
<p className={styles.copyright}>
|
<p className={styles.copyright}>
|
||||||
© {currentYear} SchoolCompare.co.uk
|
© {currentYear} schoolcompare.co.uk
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
|
import { HeroIllustration } from './Illustration';
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
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);
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const y = today.getFullYear();
|
const y = today.getFullYear();
|
||||||
let target = new Date(y, month - 1, day);
|
const target = new Date(y, month - 1, day);
|
||||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
}
|
||||||
|
|
||||||
|
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 {
|
interface CountdownChipData {
|
||||||
@@ -56,6 +66,16 @@ interface CountdownChipData {
|
|||||||
day: number;
|
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[] = [
|
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
{ 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 },
|
{ 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 },
|
{ 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) {
|
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -83,9 +213,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||||
const [geoError, setGeoError] = useState<string | null>(null);
|
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 hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
@@ -148,13 +299,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
.catch(() => {});
|
.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 () => {
|
const handleLoadMore = async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
track('results_load_more', { next_page: currentPage + 1 });
|
track('results_load_more', { next_page: currentPage + 1 });
|
||||||
@@ -243,6 +387,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
});
|
});
|
||||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
}, [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
|
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||||
// split which surface drives compare adds.
|
// split which surface drives compare adds.
|
||||||
const addSchoolFromSearch = useCallback((school: School) => {
|
const addSchoolFromSearch = useCallback((school: School) => {
|
||||||
@@ -255,113 +421,137 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}, [addSchool, selectedSchools.length]);
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeView}>
|
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||||
{/* Combined Hero + Search and Filters */}
|
CSS); the search arrangement is a filter bar directly above its results
|
||||||
{!isSearchActive && (
|
and wants none of it. */
|
||||||
<div className={styles.heroSection}>
|
<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.heroEyebrow}>
|
||||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
Updated for the 2026/27 admissions round
|
Updated for the 2026/27 admissions round
|
||||||
</span>
|
</span>
|
||||||
<h1 className={styles.heroTitle}>
|
<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>
|
</h1>
|
||||||
<p className={styles.heroDescription}>
|
<p className={styles.heroDescription}>
|
||||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
Compare every school in England with clarity and confidence.
|
||||||
freshness, standing in for the hidden eyebrow too) on phones,
|
{/* The coverage sentence is the detail a first-time visitor needs
|
||||||
where every line above the fold costs. */}
|
but a returning one does not, so it drops away on phones where
|
||||||
|
every line above the fold costs. */}
|
||||||
<span className={styles.heroDescriptionFull}>
|
<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.
|
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||||
</span>
|
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||||
<span className={styles.heroDescriptionCompact}>
|
side, in one place.
|
||||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
isHero={!isSearchActive}
|
isHero
|
||||||
resultFilters={initialSchools.result_filters}
|
resultFilters={initialSchools.result_filters}
|
||||||
onNearMe={handleNearMe}
|
onNearMe={handleNearMe}
|
||||||
geoState={geoState}
|
geoState={geoState}
|
||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Admissions countdown strip — only on landing page */}
|
{/*
|
||||||
{!isSearchActive && (
|
Rendered after the content on purpose. On desktop it is absolutely
|
||||||
<section className={styles.admissionsStrip}>
|
positioned behind the panel, so order does not matter; below the
|
||||||
<div className={styles.stripHeader}>
|
one-column breakpoint it returns to normal flow, where DOM order is
|
||||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
what puts the band under the search rather than above the headline.
|
||||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
*/}
|
||||||
</div>
|
<div className={styles.heroArt}>
|
||||||
<div
|
<HeroIllustration />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
{/* Next admissions deadline — one bar, not four cards. */}
|
||||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
{!isSearchActive && (
|
||||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||||
<div className={styles.exploringRow}>
|
{nextDeadline && (
|
||||||
<span className={styles.exploringLabel}>Start exploring</span>
|
<>
|
||||||
<div className={styles.exploringChips}>
|
<a
|
||||||
<a href="/rankings" className={styles.exploringChip}>
|
href="/admissions"
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
className={
|
||||||
Top-rated primary schools
|
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>
|
||||||
<a href="/rankings" className={styles.exploringChip}>
|
<p className={styles.laterDates} suppressHydrationWarning>
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||||
Top-rated secondary schools
|
{' '}
|
||||||
</a>
|
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||||
<a href="/compare" className={styles.exploringChip}>
|
</p>
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
</>
|
||||||
Start a comparison
|
)}
|
||||||
</a>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||||
{!isSearchActive && howItWorks}
|
{!isSearchActive && howItWorks}
|
||||||
{!isSearchActive && editorial}
|
{!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 : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
@@ -547,6 +737,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -573,10 +764,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
|||||||
rwmDelta == null
|
rwmDelta == null
|
||||||
? {}
|
? {}
|
||||||
: rwmDelta >= 2
|
: rwmDelta >= 2
|
||||||
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||||
: rwmDelta <= -2
|
: rwmDelta <= -2
|
||||||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.compactItem}>
|
<div className={styles.compactItem}>
|
||||||
@@ -601,13 +792,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
|
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||||
{ background: '#e0e0e0', color: '#666' }),
|
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ofstedBadge.label}
|
{ofstedBadge.label}
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
|||||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
{ 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 = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.howItWorks}>
|
<section className={styles.howItWorks}>
|
||||||
<div className={styles.hiwHeader}>
|
{/* One header pattern across every band on this page: kicker, heading,
|
||||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
optional aside. The page previously ran four different treatments
|
||||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
(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>
|
||||||
<div className={styles.hiwGrid}>
|
<div className={styles.hiwGrid}>
|
||||||
{/* Card 1 — Performance */}
|
{/* Card 1 — Performance */}
|
||||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.comparePreview}>
|
<div className={styles.comparePreview}>
|
||||||
<div className={styles.compareHead}>
|
<div className={styles.compareHead}>
|
||||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
<div className={styles.compareHeadCell}>School A</div>
|
||||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
<div className={styles.compareHeadCell}>School B</div>
|
||||||
</div>
|
</div>
|
||||||
{compareRows.map(({ label, a, b, aHi }) => (
|
{compareRows.map(({ label, a, b, aHi }) => (
|
||||||
<div key={label} className={styles.compareRow}>
|
<div key={label} className={styles.compareRow}>
|
||||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.compareRowVal}>{b}</span>
|
<span className={styles.compareRowVal}>{b}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Compare</div>
|
<div className={styles.hiwStep}>Compare</div>
|
||||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
<div className={styles.hiwTitle}>Side by side</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>
|
{/* 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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||||
below carries the visible circled "?" at its own explicit size. */
|
below carries the visible circled "?" at its own explicit size. */
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -34,17 +34,17 @@
|
|||||||
.icon:hover,
|
.icon:hover,
|
||||||
.icon[aria-expanded='true'],
|
.icon[aria-expanded='true'],
|
||||||
.icon:focus-visible {
|
.icon:focus-visible {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: min(260px, calc(100vw - 24px));
|
max-width: min(260px, calc(100vw - 24px));
|
||||||
background: var(--bg-primary, #faf7f2);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color, #e8ddd4);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -52,26 +52,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
fill: var(--bg-primary, #faf7f2);
|
fill: var(--bg-primary);
|
||||||
stroke: var(--border-color, #e8ddd4);
|
stroke: var(--border);
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-primary, #2c2420);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plain {
|
.plain {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #5a4a44);
|
color: var(--text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted, #8a7a72);
|
color: var(--text-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
@@ -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).
|
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||||
const PIN_HTML = `
|
const PIN_HTML = `
|
||||||
<div style="position:relative;width:26px;height:26px">
|
<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))">
|
<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"/>
|
<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="#fff"/>
|
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
|||||||
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
|
|||||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||||
if (school.ofsted_rc_date) {
|
if (school.ofsted_rc_date) {
|
||||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
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 year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||||
const yearStr = year ? ` · ${year}` : '';
|
const yearStr = year ? ` · ${year}` : '';
|
||||||
if (school.ofsted_grade) {
|
if (school.ofsted_grade) {
|
||||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||||
const colours: Record<number, string> = {
|
const colours: Record<number, string> = {
|
||||||
1: 'background:#d4f0ea;color:#2d7d7d',
|
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||||
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
|
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||||
3: 'background:#fef3cd;color:#7a6800',
|
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||||
4: 'background:#fde8e0;color:#e07256',
|
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||||
};
|
};
|
||||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
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) {
|
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({
|
const refIcon = L.divIcon({
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
width: 20px; height: 20px;
|
width: 20px; height: 20px;
|
||||||
background: #e07256;
|
background: var(--brand);
|
||||||
border: 3px solid white;
|
border: 3px solid white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
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 ? '+' : '';
|
const sign = diff >= 0 ? '+' : '';
|
||||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||||
// vs primary RWM % where ±2 pts is the threshold
|
// 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');
|
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||||
}
|
}
|
||||||
metricHtml = `<div style="margin-bottom:4px">
|
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:20px;font-weight:700;color:var(--text-primary);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:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||||
${deltaLine}
|
${deltaLine}
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (school.rwm_expected_pct != null) {
|
} else if (school.rwm_expected_pct != null) {
|
||||||
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
|||||||
let deltaLine = '';
|
let deltaLine = '';
|
||||||
if (nationalAvgRwm != null) {
|
if (nationalAvgRwm != null) {
|
||||||
const diff = Math.round(rwm - nationalAvgRwm);
|
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 =
|
const text =
|
||||||
diff >= 2 ? `+${diff} pts vs national` :
|
diff >= 2 ? `+${diff} pts vs national` :
|
||||||
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>`;
|
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||||
}
|
}
|
||||||
metricHtml = `<div style="margin-bottom:4px">
|
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:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||||
${deltaLine}
|
${deltaLine}
|
||||||
</div>`;
|
</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">
|
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">
|
<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>
|
<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>
|
||||||
<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}
|
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||||
</div>
|
</div>
|
||||||
${metricHtml}
|
${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>`;
|
</div>`;
|
||||||
|
|
||||||
marker.bindPopup(popupContent);
|
marker.bindPopup(popupContent);
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeletonCard {
|
.skeletonCard {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
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 {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--bg-secondary, #f3ede4) 25%,
|
var(--bg-secondary) 25%,
|
||||||
rgba(224, 114, 86, 0.08) 50%,
|
rgba(var(--brand-rgb), 0.08) 50%,
|
||||||
var(--bg-secondary, #f3ede4) 75%
|
var(--bg-secondary) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +82,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeletonListItem {
|
.skeletonListItem {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
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 {
|
.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,7 +1,7 @@
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(26, 22, 18, 0.6);
|
background: rgba(var(--shadow-rgb), 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -20,15 +20,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border-radius: 16px;
|
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;
|
max-height: 90vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: slideIn 0.3s ease;
|
animation: slideIn 0.3s ease;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -62,22 +62,22 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:hover {
|
.closeButton:hover {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -102,16 +102,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content::-webkit-scrollbar-track {
|
.content::-webkit-scrollbar-track {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content::-webkit-scrollbar-thumb {
|
.content::-webkit-scrollbar-thumb {
|
||||||
background: var(--border-color, #e5dfd5);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content::-webkit-scrollbar-thumb:hover {
|
.content::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-muted, #8a847a);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@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 {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -16,112 +23,121 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.625rem;
|
||||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||||
margin: -0.375rem -0.5rem;
|
margin: -0.375rem -0.5rem;
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
transition: color var(--transition);
|
||||||
font-weight: 700;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover {
|
.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 {
|
.logoIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 36px;
|
flex: 0 0 auto;
|
||||||
height: 36px;
|
/* The artwork is taller than it is wide (153:189), so height drives the box
|
||||||
color: var(--accent-coral, #e07256);
|
and width follows. Constraining both would squash the pin. */
|
||||||
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon svg {
|
.logoIcon img {
|
||||||
width: 100%;
|
display: block;
|
||||||
height: 100%;
|
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 {
|
.logoText {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-2);
|
||||||
font-weight: 700;
|
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 {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink {
|
.navLink {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4375rem;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.5rem 0.875rem;
|
||||||
font-size: 0.9375rem;
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
transition: all 0.2s ease;
|
transition: color var(--transition), background-color var(--transition);
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:hover {
|
.navLink:hover {
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
|
||||||
|
|
||||||
.navLink:hover::after {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||||
|
state never rests on colour alone. */
|
||||||
.navLink.active {
|
.navLink.active {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand);
|
||||||
background: var(--accent-coral-bg);
|
background: var(--brand-bg);
|
||||||
}
|
font-weight: 600;
|
||||||
|
|
||||||
.navLink.active::after {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.25rem;
|
min-width: 1.125rem;
|
||||||
height: 1.25rem;
|
height: 1.125rem;
|
||||||
padding: 0 0.375rem;
|
padding: 0 0.375rem;
|
||||||
font-size: 0.75rem;
|
font-family: var(--font-ui);
|
||||||
font-weight: 600;
|
font-size: var(--step--2);
|
||||||
color: white;
|
font-weight: 700;
|
||||||
background: var(--accent-coral, #e07256);
|
line-height: 1;
|
||||||
|
color: var(--brand-on);
|
||||||
|
background: var(--brand);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
animation: badgePop 0.3s ease-out;
|
animation: badgePop 0.3s ease-out;
|
||||||
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes badgePop {
|
@keyframes badgePop {
|
||||||
@@ -149,25 +165,23 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.2rem;
|
gap: 0.25rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
padding: 0.375rem 0.25rem;
|
padding: 0.375rem 0.25rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
transition: color 0.15s ease, background-color 0.15s ease;
|
transition: color var(--transition);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:active {
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabIconWrap {
|
.tabIconWrap {
|
||||||
@@ -179,7 +193,29 @@
|
|||||||
height: 24px;
|
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 {
|
.tabIcon {
|
||||||
|
position: relative;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
@@ -190,19 +226,21 @@
|
|||||||
|
|
||||||
.tabBadge {
|
.tabBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: -7px;
|
||||||
right: -10px;
|
right: -7px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
line-height: 1;
|
||||||
background: var(--accent-coral, #e07256);
|
color: var(--brand-on);
|
||||||
border: 2px solid var(--bg-card, white);
|
background: var(--brand);
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
animation: badgePop 0.3s ease-out;
|
animation: badgePop 0.3s ease-out;
|
||||||
}
|
}
|
||||||
@@ -219,8 +257,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoText {
|
||||||
|
font-size: var(--step-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomBar {
|
.bottomBar {
|
||||||
@@ -230,9 +272,9 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
border-top: 1px solid var(--border);
|
||||||
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 0.06);
|
||||||
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||||
landscape) insets so the tab content never sits under system UI. */
|
landscape) insets so the tab content never sits under system UI. */
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
import { LogoMark } from './Logo';
|
||||||
import styles from './Navigation.module.css';
|
import styles from './Navigation.module.css';
|
||||||
|
|
||||||
type IconProps = { className?: string };
|
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 = [
|
const items = [
|
||||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||||
@@ -89,15 +100,19 @@ export function Navigation() {
|
|||||||
<>
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.container}>
|
<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}>
|
<span className={styles.logoIcon}>
|
||||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<LogoMark size={38} />
|
||||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
</span>
|
||||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
<span className={styles.logoText}>
|
||||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
school<span className={styles.logoTextAccent}>compare</span>
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.logoText}>SchoolCompare</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className={styles.nav} aria-label="Main navigation">
|
<nav className={styles.nav} aria-label="Main navigation">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
.info {
|
.info {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -21,18 +21,18 @@
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navButton:hover:not(:disabled) {
|
.navButton:hover:not(:disabled) {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navButton:disabled {
|
.navButton:disabled {
|
||||||
@@ -51,32 +51,32 @@
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageButton {
|
.pageButton {
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageButton:hover {
|
.pageButton:hover {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageButtonActive {
|
.pageButtonActive {
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageButtonActive:hover {
|
.pageButtonActive:hover {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--brand-strong);
|
||||||
border-color: var(--accent-coral-dark, #c45a3f);
|
border-color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
.trendSummary {
|
.trendSummary {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
padding: 0.5rem 0.875rem;
|
padding: 0.5rem 0.875rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
border-left: 3px solid var(--brand);
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
.covidNote,
|
.covidNote,
|
||||||
.chartHint {
|
.chartHint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip:active {
|
.chip:active {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip:disabled {
|
.chip:disabled {
|
||||||
@@ -87,21 +87,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chipActive {
|
.chipActive {
|
||||||
background: var(--accent-coral-dark, #b04a2e);
|
background: var(--brand-strong);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipActive:active {
|
.chipActive:active {
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
background: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniLegend {
|
.miniLegend {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.875rem;
|
gap: 0.875rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
margin-top: -0.25rem;
|
margin-top: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
@@ -124,9 +125,17 @@ export function PerformanceChart({
|
|||||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||||
const hasGap = gapYears.length > 0;
|
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) ─────
|
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||||
const refLineStyle = {
|
const refLineStyle = {
|
||||||
borderColor: 'rgba(90,80,70,0.35)',
|
borderColor: cRef,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderDash: [6, 4] as number[],
|
borderDash: [6, 4] as number[],
|
||||||
@@ -140,8 +149,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Attainment 8',
|
label: 'Attainment 8',
|
||||||
data: col('attainment_8_score'),
|
data: col('attainment_8_score'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: c1,
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: alpha('--chart-1', 0.08),
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 4,
|
pointRadius: 4,
|
||||||
@@ -151,8 +160,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'English & Maths Grade 4+',
|
label: 'English & Maths Grade 4+',
|
||||||
data: col('english_maths_standard_pass_pct'),
|
data: col('english_maths_standard_pass_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: c2,
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: alpha('--chart-2', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -161,8 +170,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Progress 8',
|
label: 'Progress 8',
|
||||||
data: col('progress_8_score'),
|
data: col('progress_8_score'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: c3,
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: alpha('--chart-3', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -179,8 +188,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Reading, Writing & Maths expected %',
|
label: 'Reading, Writing & Maths expected %',
|
||||||
data: col('rwm_expected_pct'),
|
data: col('rwm_expected_pct'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: c1,
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: alpha('--chart-1', 0.08),
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 4,
|
pointRadius: 4,
|
||||||
@@ -190,8 +199,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Exceeding expected level',
|
label: 'Exceeding expected level',
|
||||||
data: col('rwm_high_pct'),
|
data: col('rwm_high_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: c2,
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: alpha('--chart-2', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -206,8 +215,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Reading progress',
|
label: 'Reading progress',
|
||||||
data: col('reading_progress'),
|
data: col('reading_progress'),
|
||||||
borderColor: 'rgb(59,130,246)',
|
borderColor: c4,
|
||||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
backgroundColor: alpha('--chart-4', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -217,8 +226,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Writing progress',
|
label: 'Writing progress',
|
||||||
data: col('writing_progress'),
|
data: col('writing_progress'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: c3,
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: alpha('--chart-3', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -228,8 +237,8 @@ export function PerformanceChart({
|
|||||||
{
|
{
|
||||||
label: 'Maths progress',
|
label: 'Maths progress',
|
||||||
data: col('maths_progress'),
|
data: col('maths_progress'),
|
||||||
borderColor: 'rgb(236,72,153)',
|
borderColor: c5,
|
||||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
backgroundColor: alpha('--chart-5', 0.08),
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 3,
|
pointRadius: 3,
|
||||||
@@ -301,7 +310,9 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
backgroundColor: cInverse,
|
||||||
|
titleColor: cInverseText,
|
||||||
|
bodyColor: cInverseText,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
titleFont: { size: 13 },
|
titleFont: { size: 13 },
|
||||||
bodyFont: { size: 12 },
|
bodyFont: { size: 12 },
|
||||||
@@ -321,7 +332,7 @@ export function PerformanceChart({
|
|||||||
type: 'linear', display: true, position: 'left',
|
type: 'linear', display: true, position: 'left',
|
||||||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||||
min: 0, max: isSecondary ? undefined : 100,
|
min: 0, max: isSecondary ? undefined : 100,
|
||||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
grid: { color: cGrid },
|
||||||
ticks: { font: { size: 11 } },
|
ticks: { font: { size: 11 } },
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
@@ -346,7 +357,9 @@ export function PerformanceChart({
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
backgroundColor: cInverse,
|
||||||
|
titleColor: cInverseText,
|
||||||
|
bodyColor: cInverseText,
|
||||||
padding: 10,
|
padding: 10,
|
||||||
titleFont: { size: 12 },
|
titleFont: { size: 12 },
|
||||||
bodyFont: { size: 11 },
|
bodyFont: { size: 11 },
|
||||||
@@ -364,7 +377,7 @@ export function PerformanceChart({
|
|||||||
y: {
|
y: {
|
||||||
type: 'linear', display: true, position: 'left',
|
type: 'linear', display: true, position: 'left',
|
||||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
grid: { color: cGrid },
|
||||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
@@ -429,9 +442,9 @@ export function PerformanceChart({
|
|||||||
belong together. */}
|
belong together. */}
|
||||||
{isMobile && activeChip === 'progress' && (
|
{isMobile && activeChip === 'progress' && (
|
||||||
<div className={styles.miniLegend}>
|
<div className={styles.miniLegend}>
|
||||||
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||||||
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||||||
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
padding: 0.625rem 1.5rem;
|
padding: 0.625rem 1.5rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -47,34 +47,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.phaseTab:not(:last-child) {
|
.phaseTab:not(:last-child) {
|
||||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTab:hover {
|
.phaseTab:hover {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive {
|
.phaseTabActive {
|
||||||
background: var(--accent-coral-dark, #b04a2e);
|
background: var(--brand-strong);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseTabActive:hover {
|
.phaseTabActive:hover {
|
||||||
background: var(--accent-coral-darker, #9c3f26);
|
background: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filters */
|
/* Filters */
|
||||||
.filters {
|
.filters {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1.5rem;
|
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 {
|
.filterGroup {
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
.filterLabel {
|
.filterLabel {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,44 +98,44 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect:hover {
|
.filterSelect:hover {
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect:focus {
|
.filterSelect:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect optgroup {
|
.filterSelect optgroup {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect option {
|
.filterSelect option {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
padding: 0.375rem 1rem;
|
padding: 0.375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rankings Section */
|
/* Rankings Section */
|
||||||
.rankingsSection {
|
.rankingsSection {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
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 {
|
.tableWrapper {
|
||||||
@@ -151,15 +151,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rankingsTable thead {
|
.rankingsTable thead {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankingsTable th {
|
.rankingsTable th {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
border-bottom: 2px solid var(--border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -206,8 +206,8 @@
|
|||||||
|
|
||||||
.rankingsTable td {
|
.rankingsTable td {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankingsTable tbody tr:last-child td {
|
.rankingsTable tbody tr:last-child td {
|
||||||
@@ -216,24 +216,24 @@
|
|||||||
|
|
||||||
/* Alternating row backgrounds for visual rhythm */
|
/* Alternating row backgrounds for visual rhythm */
|
||||||
.rankingsTable tbody tr:nth-child(even) {
|
.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 {
|
.rankingsTable tbody tr:hover {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top 3 Highlighting with Gold */
|
/* Top 3 Highlighting with Gold */
|
||||||
.rank1 {
|
.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 {
|
.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 {
|
.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 {
|
.rankCell {
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styled rank badges for top 3 */
|
/* Styled rank badges for top 3 */
|
||||||
@@ -254,8 +254,8 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,30 +265,31 @@
|
|||||||
inset: -2px;
|
inset: -2px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid transparent;
|
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: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankBadge1 {
|
.rankBadge1 {
|
||||||
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
|
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankBadge2 {
|
.rankBadge2 {
|
||||||
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
|
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankBadge3 {
|
.rankBadge3 {
|
||||||
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
|
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankNumber {
|
.rankNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolCell {
|
.schoolCell {
|
||||||
@@ -296,18 +297,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolLink {
|
.schoolLink {
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolLink:hover {
|
.schoolLink:hover {
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.areaCell,
|
.areaCell,
|
||||||
.typeCell {
|
.typeCell {
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* LA subline under the school name — mobile only (Area column hidden there). */
|
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||||
@@ -315,7 +316,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.valueCell {
|
.valueCell {
|
||||||
@@ -324,7 +325,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.valueCell strong {
|
.valueCell strong {
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--status-above);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +347,7 @@
|
|||||||
.noResults {
|
.noResults {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.noResults p {
|
.noResults p {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,20 +41,39 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Per-bar national average marker ──
|
/* ── Per-bar national average marker ──
|
||||||
Each bar compares against its own benchmark (expected vs higher standard /
|
Each bar compares against its own benchmark (expected vs higher standard /
|
||||||
greater depth), so the marker sits on the individual bar's track rather than
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
as one line spanning both bars. */
|
as one line spanning both bars. */
|
||||||
|
/*
|
||||||
|
* Knockout, not a colour.
|
||||||
|
*
|
||||||
|
* This marker was var(--brand) — the same value as .barExpected and
|
||||||
|
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
|
||||||
|
* rendered distinguishably at all. It scored 5.47:1 only against the empty
|
||||||
|
* track, which means it was visible precisely when a school was BELOW the
|
||||||
|
* national average and vanished for every school at or above it.
|
||||||
|
*
|
||||||
|
* No single colour fixes this, because the marker's position is data-driven:
|
||||||
|
* it can land on the bar, on the empty track, or straddle the boundary. So it
|
||||||
|
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
|
||||||
|
* the core reads; on the pale track the edge reads. Both tokens flip with the
|
||||||
|
* theme, so the pairing holds in dark mode too.
|
||||||
|
*
|
||||||
|
* The edge is box-shadow rather than border so it costs no layout width and
|
||||||
|
* cannot shift the 50% translate.
|
||||||
|
*/
|
||||||
.natTick {
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
width: 2px;
|
width: 3px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -63,13 +82,14 @@
|
|||||||
.natTick::before {
|
.natTick::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -4px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 5px;
|
width: 6px;
|
||||||
height: 5px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barHeaderRight {
|
.barHeaderRight {
|
||||||
@@ -81,7 +101,7 @@
|
|||||||
.natLabel {
|
.natLabel {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--brand-strong);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
@@ -122,19 +142,31 @@
|
|||||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Expected and Exceeding were both var(--brand) — one colour for two series,
|
||||||
|
* distinguished only by which row you were looking at, while the legend
|
||||||
|
* claimed two.
|
||||||
|
*
|
||||||
|
* They are a sequential pair, not two categories: "exceeding" is a subset of
|
||||||
|
* the same cohort at a harder bar. So they take two steps of the same hue
|
||||||
|
* rather than two different hues, with the harder measure the more intense
|
||||||
|
* step. --brand-stronger is darker than --brand in the light theme and lighter
|
||||||
|
* in the dark one, which is the right direction in both: further from the
|
||||||
|
* ground.
|
||||||
|
*/
|
||||||
.barExpected {
|
.barExpected {
|
||||||
background: var(--accent-teal-light, #3a9e9e);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barExceeding {
|
.barExceeding {
|
||||||
background: var(--accent-teal, #2d7d7d);
|
background: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barLabel {
|
.barLabel {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
@@ -142,7 +174,7 @@
|
|||||||
|
|
||||||
.barLabelSuffix {
|
.barLabelSuffix {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -153,7 +185,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulerTick {
|
.rulerTick {
|
||||||
@@ -161,7 +193,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--border-color, #e5dfd5);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulerLabel {
|
.rulerLabel {
|
||||||
@@ -169,7 +201,7 @@
|
|||||||
top: 5px;
|
top: 5px;
|
||||||
font-size: 0.5rem;
|
font-size: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -198,7 +230,7 @@
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legendSwatch {
|
.legendSwatch {
|
||||||
|
|||||||
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
<SubjectColumn key={subject.name} subject={subject} />
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
The legend describes what is drawn, which it previously did not.
|
||||||
|
|
||||||
|
Both data swatches were var(--status-above) — green — while the bars
|
||||||
|
they labelled were var(--brand) teal, and they were identical to each
|
||||||
|
other, so two different series shared one swatch. Worse, the only
|
||||||
|
swatch that matched the bar colour was the one labelled "National
|
||||||
|
average": anyone reading the chart by matching colours would conclude
|
||||||
|
the teal bars WERE the national average.
|
||||||
|
|
||||||
|
Each swatch now carries the exact value its bar carries, and the
|
||||||
|
national-average swatch mirrors the knockout marker rather than being
|
||||||
|
a flat colour, so it is recognisable as the thing on the chart.
|
||||||
|
*/}
|
||||||
<div className={styles.legend}>
|
<div className={styles.legend}>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
||||||
Expected standard
|
Expected standard
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<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
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<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
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.card {
|
.card {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1rem 1.125rem;
|
padding: 1rem 1.125rem;
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card.cardInCompare {
|
.card.cardInCompare {
|
||||||
border-color: var(--accent-teal, #2d7d7d);
|
border-color: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
|
box-shadow: 0 0 0 1px var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
border-left-color: var(--accent-coral, #e07256);
|
border-left-color: var(--brand);
|
||||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,24 +31,24 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title a {
|
.title a {
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title a:hover {
|
.title a:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.distance {
|
.distance {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--brand);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: var(--accent-teal-bg);
|
background: var(--brand-bg);
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.125rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
|
|
||||||
.metaItem {
|
.metaItem {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.125rem 0.5rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +75,9 @@
|
|||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
@@ -88,12 +88,13 @@
|
|||||||
|
|
||||||
.metricLabel {
|
.metricLabel {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metricValue {
|
.metricValue {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
|
|
||||||
.metricValue strong {
|
.metricValue strong {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,18 +127,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trendUp {
|
.trendUp {
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--status-above);
|
||||||
background: var(--accent-teal-bg);
|
background: var(--status-above-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trendDown {
|
.trendDown {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--status-below);
|
||||||
background: var(--accent-coral-bg);
|
background: var(--status-below-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trendStable {
|
.trendStable {
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
background: rgba(138, 132, 122, 0.15);
|
background: rgba(var(--muted-rgb), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
|
|
||||||
.metricHint {
|
.metricHint {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 210px;
|
height: 210px;
|
||||||
background: #dfe6e2;
|
background: var(--bg-secondary);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
|
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -85,11 +85,11 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, .85);
|
background: rgba(255, 255, 255, .85);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
|
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .16);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: translateY(4px);
|
||||||
transition: opacity .16s ease, transform .16s ease;
|
transition: opacity .16s ease, transform .16s ease;
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
rgba(255, 255, 255, .35) 35%,
|
rgba(255, 255, 255, .35) 35%,
|
||||||
rgba(255, 255, 255, .75) 62%,
|
rgba(255, 255, 255, .75) 62%,
|
||||||
rgba(255, 255, 255, .95) 82%,
|
rgba(255, 255, 255, .95) 82%,
|
||||||
var(--bg-card, #fff) 100%);
|
var(--bg-card) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeBtn {
|
.closeBtn {
|
||||||
@@ -134,9 +134,9 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, .92);
|
background: rgba(255, 255, 255, .92);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
|
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeBtn:hover {
|
.closeBtn:hover {
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreenBtn:hover {
|
.fullscreenBtn:hover {
|
||||||
background: #f4f4f4;
|
background: var(--bg-secondary);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
.spinner {
|
.spinner {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border: 3px solid rgba(224, 114, 86, 0.3);
|
border: 3px solid rgba(var(--brand-rgb), 0.3);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: var(--accent-coral, #e07256);
|
border-top-color: var(--brand);
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
@@ -12,17 +12,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row:hover {
|
||||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase border colours */
|
/* Phase border colours */
|
||||||
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
|
.phasePrimary { border-left-color: var(--phase-primary); }
|
||||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||||
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
|
.phaseNursery { border-left-color: var(--phase-nursery); }
|
||||||
|
|
||||||
.rowInCompare {
|
.rowInCompare {
|
||||||
border-left-color: var(--accent-teal, #2d7d7d);
|
border-left-color: var(--brand);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rowFadeIn {
|
@keyframes rowFadeIn {
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName:hover {
|
.schoolName:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase label pill */
|
/* Phase label pill */
|
||||||
@@ -96,8 +96,8 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: rgba(26, 22, 18, 0.05);
|
background: rgba(var(--shadow-rgb), 0.05);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 3: stats */
|
/* Line 3: stats */
|
||||||
@@ -115,10 +115,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statValue {
|
.statValue {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
|
|
||||||
.statLabel {
|
.statLabel {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +138,9 @@
|
|||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trendUp { color: var(--accent-teal, #2d7d7d); }
|
.trendUp { color: var(--status-above); }
|
||||||
.trendDown { color: var(--accent-coral, #e07256); }
|
.trendDown { color: var(--status-below); }
|
||||||
.trendStable { color: var(--text-muted, #8a847a); }
|
.trendStable { color: var(--text-muted); }
|
||||||
|
|
||||||
/* Line 4: location */
|
/* Line 4: location */
|
||||||
.line4 {
|
.line4 {
|
||||||
@@ -148,13 +149,13 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line4 span:not(:last-child)::after {
|
.line4 span:not(:last-child)::after {
|
||||||
content: '·';
|
content: '·';
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.4rem;
|
||||||
color: var(--border-color, #e5dfd5);
|
color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.distanceBadge {
|
.distanceBadge {
|
||||||
@@ -162,8 +163,8 @@
|
|||||||
padding: 0.0625rem 0.375rem;
|
padding: 0.0625rem 0.375rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--accent-teal, #2d7d7d);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,23 +201,23 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||||
|
|
||||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||||
/* ofsted1–4 already defined above; these cover the two new framework states */
|
/* ofsted1–4 already defined above; these cover the two new framework states */
|
||||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||||
distinct from the grey "Not yet inspected" pending state. */
|
distinct from the grey "Not yet inspected" pending state. */
|
||||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||||
|
|
||||||
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
|
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
|
||||||
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
|
.vsNational { font-size: 0.7rem; color: var(--status-above); font-weight: 600; }
|
||||||
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
|
.vsNationalNeg { font-size: 0.7rem; color: var(--brand); font-weight: 600; }
|
||||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
|
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
|
||||||
|
|
||||||
/* ── Mobile ──────────────────────────────────────────── */
|
/* ── Mobile ──────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -257,7 +258,7 @@
|
|||||||
|
|
||||||
/* GIAS "Open, but proposed to close" marker */
|
/* GIAS "Open, but proposed to close" marker */
|
||||||
.attrClosing {
|
.attrClosing {
|
||||||
background: #fdf6e3;
|
background: var(--status-below-bg);
|
||||||
color: #8a6200;
|
color: var(--status-below);
|
||||||
border: 1px solid #e2c96f;
|
border: 1px solid var(--status-below-bg);
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--status-below-bg);
|
||||||
border: 1px solid var(--accent-gold, #c9a227);
|
border: 1px solid var(--status-below);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -21,21 +21,21 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 2px solid var(--border-color, #e5dfd5);
|
border: 2px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput::placeholder {
|
.searchInput::placeholder {
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput:focus {
|
.searchInput:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchSpinner {
|
.searchSpinner {
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
border: 2px solid rgba(224, 114, 86, 0.3);
|
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: var(--accent-coral, #e07256);
|
border-top-color: var(--brand);
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,17 +71,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.results::-webkit-scrollbar-track {
|
.results::-webkit-scrollbar-track {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results::-webkit-scrollbar-thumb {
|
.results::-webkit-scrollbar-thumb {
|
||||||
background: var(--border-color, #e5dfd5);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results::-webkit-scrollbar-thumb:hover {
|
.results::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-muted, #8a847a);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resultItem {
|
.resultItem {
|
||||||
@@ -90,16 +90,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resultItem:hover {
|
.resultItem:hover {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--brand);
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolInfo {
|
.schoolInfo {
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolMeta span {
|
.schoolMeta span {
|
||||||
@@ -138,14 +138,14 @@
|
|||||||
.noResults {
|
.noResults {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,930 +0,0 @@
|
|||||||
/**
|
|
||||||
* SecondarySchoolDetailView Component
|
|
||||||
* Dedicated detail view for secondary schools with scroll-to-section navigation.
|
|
||||||
* All sections render at once; the sticky nav scrolls to each.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
|
||||||
import { MetricTooltip } from './MetricTooltip';
|
|
||||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
|
||||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
import type {
|
|
||||||
School, SchoolResult, AbsenceData,
|
|
||||||
OfstedInspection, SchoolCensus,
|
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
|
||||||
} from '@/lib/types';
|
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
|
||||||
import { DeltaChip } from './DeltaChip';
|
|
||||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
|
||||||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
|
||||||
};
|
|
||||||
|
|
||||||
const RC_LABELS: Record<number, string> = {
|
|
||||||
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
|
||||||
};
|
|
||||||
|
|
||||||
const RC_CATEGORIES = [
|
|
||||||
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
|
||||||
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
|
||||||
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
|
||||||
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
|
||||||
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
|
||||||
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
|
||||||
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
|
||||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
|
||||||
if (val == null) return '';
|
|
||||||
if (val > 0) return modStyles.progressPositive;
|
|
||||||
if (val < 0) return modStyles.progressNegative;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deprivationDesc(decile: number): string {
|
|
||||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
|
||||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
|
||||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecondarySchoolDetailViewProps {
|
|
||||||
schoolInfo: School;
|
|
||||||
yearlyData: SchoolResult[];
|
|
||||||
absenceData: AbsenceData | null;
|
|
||||||
ofsted: OfstedInspection | null;
|
|
||||||
census: SchoolCensus | null;
|
|
||||||
admissions: SchoolAdmissions | null;
|
|
||||||
senDetail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
|
||||||
finance: SchoolFinance | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
|
||||||
schoolInfo, yearlyData,
|
|
||||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
|
||||||
}: SecondarySchoolDetailViewProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
// Hero map — the "View on map" link opens its fullscreen view.
|
|
||||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
|
||||||
const isInComparison = isSelected(schoolInfo.urn);
|
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<string>('');
|
|
||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
|
||||||
|
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/national-averages')
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => { if (data) setNationalAvg(data); })
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
|
||||||
|
|
||||||
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
|
||||||
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
|
|
||||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
|
||||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
|
||||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
|
||||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
|
||||||
const hasResults = latestResults?.attainment_8_score != null;
|
|
||||||
|
|
||||||
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
|
||||||
// so their headline attainment is far below the mainstream average by design.
|
|
||||||
// Drop the England comparison + "below" framing so the page doesn't portray
|
|
||||||
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
|
||||||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
|
||||||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
|
||||||
// mainstream school still shows its real value and comparison.
|
|
||||||
const isSpecial = isSpecialSchool(schoolInfo);
|
|
||||||
const suppressComparison = isSpecial;
|
|
||||||
|
|
||||||
const admissionsTag = (() => {
|
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
|
||||||
if (policy.includes('selective')) return 'Selective';
|
|
||||||
const denom = schoolInfo.religious_denomination ?? '';
|
|
||||||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handleComparisonToggle = () => {
|
|
||||||
if (isInComparison) {
|
|
||||||
removeSchool(schoolInfo.urn);
|
|
||||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
|
||||||
} else {
|
|
||||||
addSchool(schoolInfo);
|
|
||||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Back returns wherever the user came from; deep-links fall back to search
|
|
||||||
// so the button never dead-ends or leaves the site.
|
|
||||||
const handleBack = () => {
|
|
||||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.push('/search');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
track('school_viewed', {
|
|
||||||
urn: schoolInfo.urn,
|
|
||||||
phase: schoolInfo.phase || 'secondary',
|
|
||||||
local_authority: schoolInfo.local_authority || 'unknown',
|
|
||||||
from: getNavigationSource(),
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [schoolInfo.urn]);
|
|
||||||
|
|
||||||
// Build nav items dynamically based on available data.
|
|
||||||
// Engagement-led order (matches the primary page): recognised Ofsted badge,
|
|
||||||
// then the most-sought sections — results, admissions, history — with the
|
|
||||||
// experience and context sections following.
|
|
||||||
const navItems: { id: string; label: string }[] = [];
|
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
|
||||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
|
||||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
|
||||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
|
||||||
|
|
||||||
// Track active section as user scrolls
|
|
||||||
useEffect(() => {
|
|
||||||
const ids = navItems.map(n => n.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
const observers: IntersectionObserver[] = [];
|
|
||||||
const ratioMap: Record<string, number> = {};
|
|
||||||
const pickActive = () => {
|
|
||||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
|
||||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
|
||||||
};
|
|
||||||
ids.forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
ratioMap[id] = 0;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
|
|
||||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
observers.push(obs);
|
|
||||||
});
|
|
||||||
return () => observers.forEach(o => o.disconnect());
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
|
||||||
|
|
||||||
// A report card is identified by the presence of report-card area
|
|
||||||
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
|
||||||
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
|
||||||
// the old `framework === 'ReportCard'` test never matched and report cards
|
|
||||||
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
|
||||||
const isReportCard = !!(
|
|
||||||
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
|
||||||
);
|
|
||||||
// Report cards are dated by their own inspection (rc_inspection_date), never
|
|
||||||
// the legacy inspection_date (report cards exist only from Nov 2025).
|
|
||||||
const ofstedInspectedDate = isReportCard
|
|
||||||
? ofsted?.rc_inspection_date ?? null
|
|
||||||
: ofsted?.inspection_date ?? null;
|
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
|
||||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
|
||||||
const oeifAllSameGrade =
|
|
||||||
!!ofsted &&
|
|
||||||
!isReportCard &&
|
|
||||||
oeifAreas.length >= 3 &&
|
|
||||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
|
||||||
|
|
||||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
|
||||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* Standalone back link, above the header — returns wherever the user
|
|
||||||
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
|
|
||||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
|
||||||
<span aria-hidden="true">←</span> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* ── Header — the location map band blends into the school title ── */}
|
|
||||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
|
||||||
{hasLocation && (
|
|
||||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
|
||||||
)}
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<div className={styles.titleSection}>
|
|
||||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
|
||||||
<div className={styles.badges}>
|
|
||||||
{schoolInfo.school_type && (
|
|
||||||
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
|
||||||
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.age_range && (
|
|
||||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
|
||||||
)}
|
|
||||||
{hasSixthForm && (
|
|
||||||
<span className={styles.badge}>Sixth form</span>
|
|
||||||
)}
|
|
||||||
{admissionsTag && (
|
|
||||||
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
|
||||||
{admissionsTag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isProposedToClose(schoolInfo) && (
|
|
||||||
<div className={styles.closingStrip} role="note">
|
|
||||||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
|
||||||
check with the local authority before applying.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{schoolInfo.address && (
|
|
||||||
<p className={styles.address}>
|
|
||||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
|
||||||
{hasLocation && (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.mapLink}
|
|
||||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
|
||||||
>
|
|
||||||
View on map ↗
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className={styles.headerDetails}>
|
|
||||||
{schoolInfo.headteacher_name && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.website && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<a
|
|
||||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
data-umami-event="external_link_clicked"
|
|
||||||
data-umami-event-target="school_website"
|
|
||||||
>
|
|
||||||
School website ↗
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
|
|
||||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.trust_name && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
onClick={handleComparisonToggle}
|
|
||||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
|
||||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
|
||||||
>
|
|
||||||
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
|
||||||
<span className={styles.btnCompareLabel}>
|
|
||||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
|
||||||
</span>
|
|
||||||
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
|
||||||
{isInComparison ? '✓' : '+'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Sticky section navigation ─────────────────────── */}
|
|
||||||
<nav className={styles.tabNav} aria-label="Page sections">
|
|
||||||
<div className={styles.tabNavInner}>
|
|
||||||
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top">↑ Top</button>
|
|
||||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
|
||||||
{navItems.map(({ id, label }) => (
|
|
||||||
<a
|
|
||||||
key={id}
|
|
||||||
href={`#${id}`}
|
|
||||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
|
||||||
onClick={() => track('section_nav_used', { section: id })}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ── Ofsted ─────────────────────────────────────── */}
|
|
||||||
{ofsted && (
|
|
||||||
<section id="ofsted" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>
|
|
||||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
|
||||||
{ofstedInspectedDate && (
|
|
||||||
<span className={styles.ofstedDate}>
|
|
||||||
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${schoolInfo.urn}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.ofstedReportLink}
|
|
||||||
data-umami-event="external_link_clicked"
|
|
||||||
data-umami-event-target="ofsted"
|
|
||||||
>
|
|
||||||
Ofsted reports ↗
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
{isReportCard ? (
|
|
||||||
<>
|
|
||||||
<p className={styles.ofstedDisclaimer}>
|
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
|
||||||
</p>
|
|
||||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
|
||||||
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
|
||||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
|
|
||||||
const value = ofsted[key] as number | null;
|
|
||||||
return value != null ? (
|
|
||||||
<div key={key} className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
|
||||||
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
|
||||||
{RC_LABELS[value]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : ofsted.overall_effectiveness ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.ofstedHeader}>
|
|
||||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
|
||||||
{OFSTED_LABELS[ofsted.overall_effectiveness]}
|
|
||||||
</span>
|
|
||||||
{ofsted.previous_overall != null &&
|
|
||||||
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
|
||||||
<span className={styles.ofstedPrevious}>
|
|
||||||
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={styles.ofstedDisclaimer}>
|
|
||||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
|
||||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
|
||||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
|
||||||
</p>
|
|
||||||
{oeifAllSameGrade ? (
|
|
||||||
<p className={styles.ofstedAllSame}>
|
|
||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
|
||||||
{oeifAreas.map(({ label, value }) => (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
|
||||||
{OFSTED_LABELS[value]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
From September 2024, Ofsted no longer gives a single overall grade.
|
|
||||||
</p>
|
|
||||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
|
||||||
{[
|
|
||||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
|
||||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
|
||||||
{ label: 'Personal Development', value: ofsted.personal_development },
|
|
||||||
{ label: 'Leadership & Management', value: ofsted.leadership_management },
|
|
||||||
].filter(({ value }) => value != null).map(({ label, value }) => (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
|
||||||
{OFSTED_LABELS[value!]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── GCSE Results ───────────────────────────────── */}
|
|
||||||
{hasResults && latestResults && (
|
|
||||||
<section id="gcse" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>
|
|
||||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
|
||||||
</h2>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<SpecialSchoolNote school={schoolInfo} />
|
|
||||||
|
|
||||||
{p8Suspended && (
|
|
||||||
<div className={styles.p8Banner}>
|
|
||||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
|
||||||
(COVID), so DfE has no starting point to measure their progress from.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hero stat cards — top GCSE metrics */}
|
|
||||||
<div className={styles.heroStatGrid}>
|
|
||||||
{latestResults.attainment_8_score != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
Attainment 8 score
|
|
||||||
<MetricTooltip metricKey="attainment_8_score" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroStatValue}>
|
|
||||||
{latestResults.attainment_8_score.toFixed(1)}
|
|
||||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
|
||||||
<DeltaChip
|
|
||||||
value={latestResults.attainment_8_score}
|
|
||||||
baseline={secondaryAvg.attainment_8_score}
|
|
||||||
unit="pts"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.progress_8_score != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
Progress 8 score
|
|
||||||
<MetricTooltip metricKey="progress_8_score" />
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
|
||||||
{formatProgress(latestResults.progress_8_score)}
|
|
||||||
</div>
|
|
||||||
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
|
||||||
<div className={styles.heroStatHint}>
|
|
||||||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.english_maths_strong_pass_pct != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
English & Maths Grade 5+
|
|
||||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroStatValue}>
|
|
||||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
|
||||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
|
||||||
<DeltaChip
|
|
||||||
value={latestResults.english_maths_strong_pass_pct}
|
|
||||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
|
||||||
unit="pts"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.english_maths_standard_pass_pct != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
English & Maths Grade 4+
|
|
||||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroStatValue}>
|
|
||||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
|
||||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
|
||||||
<DeltaChip
|
|
||||||
value={latestResults.english_maths_standard_pass_pct}
|
|
||||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
|
||||||
unit="pts"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
|
||||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
|
||||||
"school vs national", so it's dropped for special schools where
|
|
||||||
that comparison isn't meaningful. */}
|
|
||||||
{!suppressComparison && latestResults.attainment_8_score != null && (
|
|
||||||
<div className={styles.att8Viz}>
|
|
||||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
|
||||||
<div className={styles.att8VizTrack}>
|
|
||||||
<div
|
|
||||||
className={styles.att8VizFill}
|
|
||||||
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
|
||||||
<div
|
|
||||||
className={styles.att8VizNatLine}
|
|
||||||
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
|
||||||
>
|
|
||||||
<div className={styles.att8VizNatPill}>
|
|
||||||
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.att8VizTicks}>
|
|
||||||
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress 8 number line with CI */}
|
|
||||||
{latestResults.progress_8_score != null && !p8Suspended && (
|
|
||||||
<div className={styles.p8Viz}>
|
|
||||||
<div className={styles.p8VizLabel}>Progress 8 — relative to national baseline (0)</div>
|
|
||||||
{(() => {
|
|
||||||
const p8 = latestResults.progress_8_score!;
|
|
||||||
const lo = latestResults.progress_8_lower_ci ?? p8;
|
|
||||||
const hi = latestResults.progress_8_upper_ci ?? p8;
|
|
||||||
const range = 6; // −3 to +3
|
|
||||||
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
|
||||||
return (
|
|
||||||
<div className={styles.p8VizTrack}>
|
|
||||||
{/* CI band */}
|
|
||||||
<div
|
|
||||||
className={styles.p8VizCi}
|
|
||||||
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
|
||||||
/>
|
|
||||||
{/* Zero line */}
|
|
||||||
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
|
||||||
{/* Score dot */}
|
|
||||||
<div
|
|
||||||
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
|
||||||
style={{ left: toX(p8) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<div className={styles.p8VizTicks}>
|
|
||||||
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress 8 component breakdown */}
|
|
||||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
|
||||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{[
|
|
||||||
{ label: 'English', val: latestResults.progress_8_english },
|
|
||||||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
|
||||||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
|
||||||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
|
||||||
].filter(r => r.val != null).map(({ label, val }) => (
|
|
||||||
<div key={label} className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>{label}</span>
|
|
||||||
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
|
|
||||||
{formatProgress(val!)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* EBacc */}
|
|
||||||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
|
||||||
English Baccalaureate (EBacc)
|
|
||||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
|
||||||
</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{latestResults.ebacc_entry_pct != null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.ebacc_standard_pass_pct != null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.ebacc_strong_pass_pct != null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.ebacc_avg_score != null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>EBacc average point score</span>
|
|
||||||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Admissions ─────────────────────────────────── */}
|
|
||||||
{admissions && (
|
|
||||||
<section id="admissions" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>Admissions</h2>
|
|
||||||
|
|
||||||
{admissionsTag && (
|
|
||||||
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
|
||||||
<strong>{admissionsTag}</strong>{' '}
|
|
||||||
{admissionsTag === 'Selective'
|
|
||||||
? '— Entry to this school is by selective examination (e.g. 11+).'
|
|
||||||
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
{admissions.places_offered != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
|
||||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{admissions.total_applications != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Total applications</div>
|
|
||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{admissions.first_preference_applications != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>1st preference applications</div>
|
|
||||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{admissions.first_preference_offer_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{admissions.oversubscribed != null && (
|
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
|
||||||
{admissions.oversubscribed
|
|
||||||
? '⚠ Applications exceeded places last year'
|
|
||||||
: '✓ Places were available last year'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
|
||||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{hasSixthForm && (
|
|
||||||
<div className={styles.sixthFormNote}>
|
|
||||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── History table ──────────────────────────────── */}
|
|
||||||
{yearlyData.length > 1 && (
|
|
||||||
<section id="history" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
|
||||||
{yearlyData.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
|
||||||
<div className={styles.chartContainer}>
|
|
||||||
<PerformanceChart
|
|
||||||
data={yearlyData}
|
|
||||||
schoolName={schoolInfo.school_name}
|
|
||||||
isSecondary={true}
|
|
||||||
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
|
||||||
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<details className={styles.historyDisclosure}>
|
|
||||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
|
||||||
<div className={styles.tableWrapper}>
|
|
||||||
<table className={styles.dataTable}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Year</th>
|
|
||||||
<th>Attainment 8</th>
|
|
||||||
<th>Progress 8</th>
|
|
||||||
<th>Eng & Maths 4+</th>
|
|
||||||
<th>EBacc entry %</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{yearlyData.map((result) => (
|
|
||||||
<tr key={result.year}>
|
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
|
||||||
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
|
||||||
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
|
||||||
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
|
||||||
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
|
||||||
{hasWellbeing && (
|
|
||||||
<section id="wellbeing" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>Wellbeing & Context</h2>
|
|
||||||
|
|
||||||
{/* SEN */}
|
|
||||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
|
||||||
<div className={styles.heroStatGrid}>
|
|
||||||
{latestResults?.sen_support_pct != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
SEN support
|
|
||||||
<MetricTooltip metricKey="sen_support_pct" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
|
||||||
<div className={styles.heroStatHint}>Without an EHCP</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults?.sen_ehcp_pct != null && (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>
|
|
||||||
Pupils with EHCP
|
|
||||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
|
||||||
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
|
|
||||||
if (total == null) return null;
|
|
||||||
const female = census?.female_pupils ?? null;
|
|
||||||
const male = census?.male_pupils ?? null;
|
|
||||||
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
|
||||||
const hasSplit = isMixed && female != null && male != null && female + male > 0;
|
|
||||||
const sum = hasSplit ? female! + male! : 0;
|
|
||||||
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
|
|
||||||
const boysPct = hasSplit ? 100 - girlsPct : 0;
|
|
||||||
return (
|
|
||||||
<div className={styles.heroStatCard}>
|
|
||||||
<div className={styles.heroStatLabel}>Total pupils</div>
|
|
||||||
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
|
|
||||||
{hasSplit && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={styles.genderBar}
|
|
||||||
role="img"
|
|
||||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
|
||||||
>
|
|
||||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
|
||||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.genderSplitHint}>
|
|
||||||
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
|
|
||||||
<span className={styles.genderSplitSep}> · </span>
|
|
||||||
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{schoolInfo.capacity != null && !hasSplit && (
|
|
||||||
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Deprivation */}
|
|
||||||
{hasDeprivation && deprivation && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
|
|
||||||
Local Area Context
|
|
||||||
<MetricTooltip metricKey="idaci_decile" />
|
|
||||||
</h3>
|
|
||||||
<div className={styles.deprivationDots}>
|
|
||||||
{Array.from({ length: 10 }, (_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
|
||||||
title={`Decile ${i + 1}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.deprivationScaleLabel}>
|
|
||||||
<span>Most deprived</span>
|
|
||||||
<span>Least deprived</span>
|
|
||||||
</div>
|
|
||||||
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Finances ───────────────────────────────────── */}
|
|
||||||
{hasFinance && finance && (
|
|
||||||
<section id="finances" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
Per-pupil spending shows how much the school has to spend on each child's education.
|
|
||||||
</p>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Total spend per pupil per year</div>
|
|
||||||
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
|
|
||||||
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
|
||||||
</div>
|
|
||||||
{finance.teacher_cost_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
|
|
||||||
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{finance.staff_cost_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
|
|
||||||
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{finance.premises_cost_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
|
||||||
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
@@ -12,17 +12,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row:hover {
|
||||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase border colours */
|
/* Phase border colours */
|
||||||
.phaseSecondary { border-left-color: var(--phase-secondary, #9b6bb0); }
|
.phaseSecondary { border-left-color: var(--phase-secondary); }
|
||||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||||
.phasePost16 { border-left-color: var(--phase-post16, #c4915e); }
|
.phasePost16 { border-left-color: var(--phase-post16); }
|
||||||
|
|
||||||
.rowInCompare {
|
.rowInCompare {
|
||||||
border-left-color: var(--accent-teal, #2d7d7d);
|
border-left-color: var(--brand);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rowFadeIn {
|
@keyframes rowFadeIn {
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName:hover {
|
.schoolName:hover {
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 2: context tags */
|
/* Line 2: context tags */
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: rgba(26, 22, 18, 0.05);
|
background: rgba(var(--shadow-rgb), 0.05);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 3: KS4 stats */
|
/* Line 3: KS4 stats */
|
||||||
@@ -100,22 +100,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statValueLarge {
|
.statValueLarge {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statValue {
|
.statValue {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statLabel {
|
.statLabel {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +127,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deltaPositive { color: #2f7a2f; }
|
.deltaPositive { color: var(--status-above); }
|
||||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
.deltaNegative { color: var(--status-below); }
|
||||||
|
|
||||||
/* Line 4: location + distance */
|
/* Line 4: location + distance */
|
||||||
.line4 {
|
.line4 {
|
||||||
@@ -135,13 +137,13 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line4 span:not(:last-child)::after {
|
.line4 span:not(:last-child)::after {
|
||||||
content: '·';
|
content: '·';
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.4rem;
|
||||||
color: var(--border-color, #e5dfd5);
|
color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.distanceBadge {
|
.distanceBadge {
|
||||||
@@ -149,8 +151,8 @@
|
|||||||
padding: 0.0625rem 0.375rem;
|
padding: 0.0625rem 0.375rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--accent-teal, #2d7d7d);
|
background: var(--brand);
|
||||||
color: white;
|
color: var(--brand-on);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,15 +177,15 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
background: rgba(26, 22, 18, 0.05);
|
background: rgba(var(--shadow-rgb), 0.05);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectiveTag {
|
.selectiveTag {
|
||||||
background: rgba(180, 120, 0, 0.1);
|
background: rgba(var(--status-below-rgb), 0.1);
|
||||||
color: #8a6200;
|
color: var(--status-below);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Ofsted badge ────────────────────────────────────── */
|
/* ── Ofsted badge ────────────────────────────────────── */
|
||||||
@@ -202,17 +204,17 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||||
|
|
||||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||||
distinct from the grey "Not yet inspected" pending state. */
|
distinct from the grey "Not yet inspected" pending state. */
|
||||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||||
|
|
||||||
/* ── Right actions column ────────────────────────────── */
|
/* ── Right actions column ────────────────────────────── */
|
||||||
.rowActions {
|
.rowActions {
|
||||||
@@ -268,7 +270,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.closingTag {
|
.closingTag {
|
||||||
background: #fdf6e3;
|
background: var(--status-below-bg);
|
||||||
color: #8a6200;
|
color: var(--status-below);
|
||||||
border: 1px solid #e2c96f;
|
border: 1px solid var(--status-below-bg);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||||
warning. Shared by both detail views so the styling can't drift. */
|
warning. Shared by both detail views so the styling can't drift. */
|
||||||
.note {
|
.note {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary);
|
||||||
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
border-left: 4px solid var(--brand);
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.note strong {
|
.note strong {
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user