diff --git a/backend/app.py b/backend/app.py index e5d5b62..23826c2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -40,20 +40,13 @@ from .data_loader import ( from .data_loader import get_data_info as get_db_info from . import flags from .places import build_place_index, build_place_registry, places_for_urn -from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS +from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS +from .similar_schools import is_secondary_phase, select_similar from .utils import clean_for_json, convert_to_native # Values to exclude from filter dropdowns (empty strings, non-applicable labels) EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"} -# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they include. -# All-through schools appear in both primary and secondary results. -PHASE_GROUPS: dict[str, set[str]] = { - "primary": {"primary", "middle deemed primary", "all-through"}, - "secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"}, - "all-through": {"all-through"}, -} - # Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a # sitemap that redirects wastes a crawl on every URL it lists. BASE_URL = "https://www.schoolcompare.co.uk" @@ -273,6 +266,29 @@ def _places_payload(urn: int) -> list[dict]: return payload +def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]: + """Nearby schools this page may offer as alternatives. + + Wrapped: a failure in selection must never 500 a page that is otherwise + complete, which is the posture get_supplementary_data already takes. The + section simply does not render. + """ + try: + # Decided in similar_schools, beside the PHASE_GROUPS bucket it selects + # from, so the two cannot drift. A substring test for "secondary" here + # would miss "16 plus" and hand a sixth-form college the primary bucket. + return select_similar( + load_latest_school_data(), int(urn), is_secondary_phase(phase) + ) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "Similar schools selection failed for urn=%s", urn + ) + return [] + + def _place_sitemap_rows(kinds: tuple[str, ...], registry=None) -> list[str]: """A per place, plus a phase variant wherever that phase clears the threshold on its own. @@ -983,6 +999,11 @@ async def get_school_details(request: Request, urn: int): # and authority both fall below the publish threshold has nowhere to # point, and the page renders without the module. "places": _places_payload(urn), + # Nearby schools of the same phase and a comparable intake. Always + # present on a build with this code; the frontend treats absent and + # empty identically, which is what lets the two images deploy + # independently. + "similar_schools": _similar_schools_payload(urn, latest.get("phase")), "yearly_data": clean_for_json(school_data), # Supplementary data (null if not yet populated by Kestra) "ofsted": supplementary.get("ofsted"), diff --git a/backend/schemas.py b/backend/schemas.py index 14853f0..8d49c3c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -532,6 +532,18 @@ RANKING_COLUMNS = [ "gcse_grade_91_pct", ] +# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they +# include. All-through schools appear in both primary and secondary results, +# which is why this is a set per phase rather than a single string comparison. +# +# Lives here rather than in app.py because similar_schools.py needs it too, and +# importing app from there would be a cycle. +PHASE_GROUPS: dict[str, set[str]] = { + "primary": {"primary", "middle deemed primary", "all-through"}, + "secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"}, + "all-through": {"all-through"}, +} + # School listing columns SCHOOL_COLUMNS = [ "urn", diff --git a/backend/similar_schools.py b/backend/similar_schools.py new file mode 100644 index 0000000..0e75467 --- /dev/null +++ b/backend/similar_schools.py @@ -0,0 +1,261 @@ +"""Which nearby schools a detail page may offer as alternatives. + +Two kinds of rule, and they are not interchangeable. + +HARD FILTERS encode claims the section is not allowed to make. A selective +school is not an alternative to a non-selective one, a special school is not +comparable to a mainstream one, and a Girls school is not an option for a Boys +school's reader. These never relax, at any distance, even where that means the +section does not render at all. + +SOFT PREFERENCES describe how closely an intake resembles this school's. They +relax in tiers, and every card reports the tier that actually took it so the +page can say what is shared rather than implying more. They relax only far +enough to reach a usable set, never far enough to fill the last of the slots. + +Pure functions over a DataFrame: no I/O, no FastAPI, no database. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pandas as pd + +from .schemas import PHASE_GROUPS + +# A cap, not a quota: the section shows everything that qualified at the tiers +# it used, up to this many. Three fit the row; the rest are behind the arrows. +MAX_SCHOOLS = 6 +# Tiers stop relaxing once this many have been found. Without it, a cap of six +# would reliably drag in tier-3 schools ten miles away to fill a row that three +# good matches had already earned. +ENOUGH = 3 +MINIMUM = 2 + +# (tier, radius in miles). Faith relaxes before gender: a faith mismatch +# changes the character of a school, while a gender mismatch can mean the +# school is not available to this reader's child at all. +TIERS: tuple[tuple[int, float], ...] = ((1, 3.0), (2, 5.0), (3, 10.0)) + +EARTH_RADIUS_MILES = 3958.8 + +_SPECIAL = re.compile(r"\bspecial\b|pupil referral|alternative provision", re.I) + +# Values that mean "this school has no religious character". +_NO_FAITH = {"", "none", "does not apply", "not applicable"} + + +def is_special_provision(school_type: str | None) -> bool: + """Mirror of isSpecialSchool() in nextjs-app/lib/utils.ts. + + Special schools carry a mainstream phase, so phase alone cannot identify + them. The two implementations must agree: a school the frontend treats as + special for benchmarking but this treats as mainstream would be dropped + from its own England comparison and then offered as a peer to a mainstream + school on the next page along. + """ + return bool(_SPECIAL.search(school_type or "")) + + +def is_selective(admissions_policy: str | None) -> bool: + """Strictly selective. Unknown counts as non-selective, which is the safe + direction: it can only ever exclude a pairing, never invent one.""" + return (admissions_policy or "").strip().lower() == "selective" + + +def faith_key(denomination: str | None) -> str: + value = (denomination or "").strip().lower() + return "" if value in _NO_FAITH else value + + +def faith_label(denomination: str | None) -> str: + return denomination.strip() if faith_key(denomination) else "No religious character" + + +def genders_compatible(a: str | None, b: str | None) -> bool: + single = {"boys", "girls"} + left, right = (a or "").strip().lower(), (b or "").strip().lower() + return not (left in single and right in single and left != right) + + +def phase_label(phase: str | None) -> str: + text = (phase or "").strip() + if not text: + return "School" + if text.lower() == "all-through": + return "All-through school" + return f"{text.capitalize()} school" + + +def is_secondary_phase(phase: str | None) -> bool: + """Whether this phase takes the secondary side: secondary group membership, + minus all-through. + + Membership is read from PHASE_GROUPS rather than tested with `"secondary" in + phase`, because that substring misses "16 plus" — GIAS phase 6, which + PHASE_GROUPS deliberately files as secondary. The substring version fails + silently rather than loudly: a sixth-form college is simply handed the + primary bucket and offered infant schools as peers. + + All-through is the exception. PHASE_GROUPS lists it on both sides because it + belongs on both phases' place pages, but the detail page renders it with the + primary template, and the metric follows the template. + """ + text = (phase or "").strip().lower() + return text != "all-through" and text in PHASE_GROUPS["secondary"] + + +def _phase_group(is_secondary: bool) -> set[str]: + return PHASE_GROUPS["secondary" if is_secondary else "primary"] + + +def _haversine_miles(lat1: float, lon1: float, lat2, lon2): + """Vectorised, matching the postcode search in app.py.""" + lat1_r, lon1_r = np.radians(lat1), np.radians(lon1) + lat2_r, lon2_r = np.radians(lat2.astype(float)), np.radians(lon2.astype(float)) + dlat, dlon = lat2_r - lat1_r, lon2_r - lon1_r + a = np.sin(dlat / 2) ** 2 + np.cos(lat1_r) * np.cos(lat2_r) * np.sin(dlon / 2) ** 2 + return 2 * EARTH_RADIUS_MILES * np.arcsin(np.sqrt(a)) + + +def _native(value): + """NaN and numpy scalars both reach JSONResponse badly; normalise here so + the caller never has to remember to.""" + if value is None: + return None + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, float) and np.isnan(value): + return None + return value + + +def _mask(series: pd.Series, predicate) -> pd.Series: + """A boolean mask that survives an empty frame. + + `Series.apply` on an empty Series returns an empty *DataFrame*, and using + that as a mask silently drops every column — so the next column lookup + raises KeyError rather than yielding no rows. This is not hypothetical: a + special school with no special school near it empties the frame at the + provision filter, which is the ordinary case for most special schools. + """ + return pd.Series([predicate(value) for value in series], index=series.index, dtype=bool) + + +def _chips(subject: pd.Series, candidate: pd.Series, tier: int, is_secondary: bool) -> list[str]: + if tier >= 3: + return [phase_label(candidate.get("phase"))] + + chips = [str(subject.get("gender") or "").strip()] + if is_secondary: + policy = (candidate.get("admissions_policy") or "").strip() + if policy and policy.lower() not in {"not applicable", "unknown"}: + chips.append(policy) + if tier == 1: + chips.append(faith_label(candidate.get("religious_denomination"))) + return [chip for chip in chips if chip] + + +def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]: + """Up to MAX_SCHOOLS nearby schools this page may offer, or [] below MINIMUM. + + Selected by tier, displayed by distance: the tier decides which schools + earn a slot, and the render order is then closest-first, because "nearby" + is the promise in the heading. + """ + subject_rows = frame[frame["urn"] == urn] + if subject_rows.empty: + return [] + subject = subject_rows.iloc[0] + + lat, lon = _native(subject.get("latitude")), _native(subject.get("longitude")) + if lat is None or lon is None: + return [] + + metric_key = "attainment_8_score" if is_secondary else "rwm_expected_pct" + + candidates = frame[frame["urn"] != urn].copy() + for column in ("latitude", "longitude"): + candidates = candidates[candidates[column].notna()] + if candidates.empty: + return [] + + # ── Hard filters ──────────────────────────────────────────────────── + allowed_phases = _phase_group(is_secondary) + candidates = candidates[ + candidates["phase"].fillna("").str.lower().isin(allowed_phases) + ] + candidates = candidates[candidates["status"].fillna("").str.lower().str.startswith("open")] + + subject_special = is_special_provision(subject.get("school_type")) + special = _mask(candidates["school_type"], is_special_provision) + candidates = candidates[special if subject_special else ~special] + + subject_selective = is_selective(subject.get("admissions_policy")) + selective = _mask(candidates["admissions_policy"], is_selective) + candidates = candidates[selective if subject_selective else ~selective] + + subject_gender = subject.get("gender") + candidates = candidates[ + _mask(candidates["gender"], lambda g: genders_compatible(subject_gender, g)) + ] + if candidates.empty: + return [] + + candidates["distance_miles"] = _haversine_miles( + lat, lon, candidates["latitude"].values, candidates["longitude"].values + ).round(1) + + # ── Soft preferences, in tiers ────────────────────────────────────── + subject_faith = faith_key(subject.get("religious_denomination")) + subject_gender_key = (subject_gender or "").strip().lower() + same_gender = candidates["gender"].fillna("").str.strip().str.lower() == subject_gender_key + same_faith = _mask( + candidates["religious_denomination"], lambda d: faith_key(d) == subject_faith + ) + + tier_masks = { + 1: same_gender & same_faith, + 2: same_gender, + 3: pd.Series(True, index=candidates.index), + } + + # Descend the tiers only until the set reaches ENOUGH. The tier that gets + # there is the last one opened, and the remaining slots up to MAX_SCHOOLS + # are filled from the tiers already used — never by widening again. + picked: dict[int, tuple[int, pd.Series]] = {} + for tier, radius in TIERS: + within = candidates[tier_masks[tier] & (candidates["distance_miles"] <= radius)] + for _, row in within.sort_values("distance_miles").iterrows(): + candidate_urn = int(row["urn"]) + if candidate_urn in picked: + continue + picked[candidate_urn] = (tier, row) + if len(picked) >= MAX_SCHOOLS: + break + if len(picked) >= ENOUGH: + break + + if len(picked) < MINIMUM: + return [] + + selected = sorted( + picked.values(), key=lambda pair: float(pair[1]["distance_miles"]) + )[:MAX_SCHOOLS] + return [ + { + "urn": int(row["urn"]), + "school_name": str(row.get("school_name") or ""), + "distance_miles": float(row["distance_miles"]), + "school_type": _native(row.get("school_type")), + "age_range": _native(row.get("age_range")), + "shared": _chips(subject, row, tier, is_secondary), + "tier": tier, + "metric_value": _native(row.get(metric_key)), + "metric_key": metric_key, + "metric_year": _native(row.get("year")), + } + for tier, row in selected + ] diff --git a/backend/tests/test_similar_schools.py b/backend/tests/test_similar_schools.py new file mode 100644 index 0000000..ad4aca7 --- /dev/null +++ b/backend/tests/test_similar_schools.py @@ -0,0 +1,330 @@ +"""Selection rules for the "similar schools nearby" section. + +The hard filters encode claims the section is not allowed to make — that a +selective school is an alternative to a non-selective one, that a special +school is comparable to a mainstream one, or that a Girls school is an option +for a Boys school's reader. They never relax. The soft preferences describe +how close the intake is, and they do — but only far enough to reach a usable +set, never far enough to fill the last of the six slots. +""" + +import numpy as np +import pandas as pd + +from backend.similar_schools import is_secondary_phase, select_similar + +# Roughly 0.7 miles apart in latitude at this longitude. +BASE_LAT, BASE_LON = 51.5000, -0.1000 + + +def _row(urn, name, **overrides): + base = { + "urn": urn, + "school_name": name, + "local_authority": "Testshire", + "school_type": "Community school", + "phase": "Primary", + "age_range": "4-11", + "status": "Open", + "gender": "Mixed", + "religious_denomination": "None", + "admissions_policy": "Not applicable", + "latitude": BASE_LAT, + "longitude": BASE_LON, + "year": 202425, + "rwm_expected_pct": 70.0, + "attainment_8_score": np.nan, + } + base.update(overrides) + return base + + +def _frame(*rows): + return pd.DataFrame(list(rows)) + + +def _at(miles): + """A latitude `miles` north of BASE_LAT.""" + return BASE_LAT + miles / 69.0 + + +def test_returns_nearest_same_phase_schools(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Near", latitude=_at(0.5)), + _row(100003, "Mid", latitude=_at(1.0)), + _row(100004, "Far", latitude=_at(2.0)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert [s["urn"] for s in result] == [100002, 100003, 100004] + assert result[0]["distance_miles"] == 0.5 + + +def test_excludes_the_subject_school(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + assert 100001 not in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + + +def test_selective_never_meets_non_selective(): + frame = _frame( + _row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"), + _row(100002, "Comp A", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.5)), + _row(100003, "Comp B", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.6)), + ) + assert select_similar(frame, 100001, is_secondary=True) == [] + + reverse = select_similar(frame, 100002, is_secondary=True) + assert 100001 not in {s["urn"] for s in reverse} + + +def test_special_schools_match_only_each_other(): + frame = _frame( + _row(100001, "Special", school_type="Community special school"), + _row(100002, "Mainstream A", latitude=_at(0.5)), + _row(100003, "Mainstream B", latitude=_at(0.6)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + assert select_similar(frame, 100002, is_secondary=False) == [] + + +def test_boys_never_meets_girls(): + frame = _frame( + _row(100001, "Boys School", gender="Boys"), + _row(100002, "Girls School", gender="Girls", latitude=_at(0.5)), + _row(100003, "Mixed School", gender="Mixed", latitude=_at(0.6)), + _row(100004, "Another Mixed", gender="Mixed", latitude=_at(0.7)), + ) + urns = {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + assert 100002 not in urns + assert urns == {100003, 100004} + + +def test_closed_schools_and_missing_coordinates_are_dropped(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Closed", status="Closed", latitude=_at(0.5)), + _row(100003, "No coords", latitude=np.nan, longitude=np.nan), + _row(100004, "Good A", latitude=_at(0.6)), + _row(100005, "Good B", latitude=_at(0.7)), + ) + assert {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} == {100004, 100005} + + +def test_tiers_relax_faith_before_gender(): + frame = _frame( + _row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"), + # Tier 1: same gender and same faith. + _row(100002, "Tier one", gender="Boys", religious_denomination="Roman Catholic", latitude=_at(2.0)), + # Tier 2: same gender, different faith — closer, but a weaker match. + _row(100003, "Tier two", gender="Boys", religious_denomination="None", latitude=_at(0.5)), + # Tier 3: mixed gender, different faith. + _row(100004, "Tier three", gender="Mixed", religious_denomination="None", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + tier_by_urn = {s["urn"]: s["tier"] for s in result} + assert tier_by_urn == {100002: 1, 100003: 2, 100004: 3} + # Selected by tier, displayed by distance. + assert [s["urn"] for s in result] == [100003, 100004, 100002] + + +def test_caps_at_six_taking_the_nearest(): + frame = _frame( + _row(100001, "Subject"), + *[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)], + ) + result = select_similar(frame, 100001, is_secondary=False) + assert len(result) == 6 + # The seventh-nearest is the one dropped, not an arbitrary one. + assert 100016 not in {s["urn"] for s in result} + + +def test_tiers_stop_once_enough_are_found(): + """Four tier-1 matches are a usable set, so tier 2 is never opened — even + though it holds a school that is closer than any of them.""" + frame = _frame( + _row(100001, "Subject", religious_denomination="Roman Catholic"), + _row(100002, "RC one", religious_denomination="Roman Catholic", latitude=_at(0.5)), + _row(100003, "RC two", religious_denomination="Roman Catholic", latitude=_at(0.6)), + _row(100004, "RC three", religious_denomination="Roman Catholic", latitude=_at(0.7)), + _row(100005, "RC four", religious_denomination="Roman Catholic", latitude=_at(0.8)), + # Closer than every one of them, but only a tier-2 match. + _row(100006, "Secular and nearer", religious_denomination="None", latitude=_at(0.2)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert 100006 not in {s["urn"] for s in result} + assert len(result) == 4 + assert all(s["tier"] == 1 for s in result) + + +def test_a_school_is_never_taken_twice(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert len(result) == len({s["urn"] for s in result}) + + +def test_fewer_than_two_matches_returns_empty(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Only neighbour", latitude=_at(0.5)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + + +def test_beyond_the_widest_radius_is_not_offered(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(11.0)), + _row(100003, "B", latitude=_at(12.0)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + + +def test_all_through_is_offered_on_both_phase_sides(): + frame = _frame( + _row(100001, "Primary subject", phase="Primary"), + _row(100002, "All through", phase="All-through", latitude=_at(0.5)), + _row(100003, "Primary peer", phase="Primary", latitude=_at(0.6)), + ) + assert 100002 in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + + secondary = _frame( + _row(100010, "Secondary subject", phase="Secondary"), + _row(100002, "All through", phase="All-through", latitude=_at(0.5)), + _row(100011, "Secondary peer", phase="Secondary", latitude=_at(0.6)), + ) + assert 100002 in {s["urn"] for s in select_similar(secondary, 100010, is_secondary=True)} + + +def test_sixteen_plus_is_matched_against_secondary_not_primary(): + """GIAS phase 6 is "16 plus", and PHASE_GROUPS puts it in the secondary + group — a sixth-form college's peers are secondaries and other colleges, + never primary schools. A substring test for "secondary" misses it silently: + no crash, just a page offering infant schools to a sixth form.""" + frame = _frame( + _row(100001, "Sixth Form College", phase="16 plus", age_range="16-19"), + _row(100002, "Nearby Secondary", phase="Secondary", latitude=_at(0.5), + attainment_8_score=52.0), + _row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6), + attainment_8_score=np.nan), + _row(100004, "Nearby Primary", phase="Primary", latitude=_at(0.1)), + ) + result = select_similar(frame, 100001, is_secondary=is_secondary_phase("16 plus")) + urns = {s["urn"] for s in result} + assert 100004 not in urns, "a primary school is not a peer for a sixth form" + assert urns == {100002, 100003} + assert all(s["metric_key"] == "attainment_8_score" for s in result) + + +def test_is_secondary_phase_agrees_with_the_phase_groups_it_selects_from(): + """The two must not drift: whatever this calls secondary decides which + PHASE_GROUPS bucket the candidates come from.""" + for phase in ("Secondary", "Middle deemed secondary", "16 plus"): + assert is_secondary_phase(phase) is True, phase + for phase in ("Primary", "Middle deemed primary", "Nursery", "", None): + assert is_secondary_phase(phase) is False, phase + # In PHASE_GROUPS an all-through school is on both sides, but it renders + # with the primary template, and the metric follows the template. + assert is_secondary_phase("All-through") is False + + +def test_chips_state_only_what_the_tier_earned(): + frame = _frame( + _row(100001, "Subject", phase="Secondary", gender="Mixed", + religious_denomination="None", admissions_policy="Non-selective"), + _row(100002, "Full match", phase="Secondary", gender="Mixed", + religious_denomination="None", admissions_policy="Non-selective", latitude=_at(0.5)), + _row(100003, "Faith differs", phase="Secondary", gender="Mixed", + religious_denomination="Church of England", admissions_policy="Non-selective", latitude=_at(0.6)), + ) + by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)} + assert by_urn[100002]["shared"] == ["Mixed", "Non-selective", "No religious character"] + assert by_urn[100003]["shared"] == ["Mixed", "Non-selective"] + + +def test_tier_three_chip_is_the_plain_phase(): + frame = _frame( + _row(100001, "Subject", gender="Boys"), + _row(100002, "A", gender="Mixed", latitude=_at(0.5)), + _row(100003, "B", gender="Mixed", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert all(s["shared"] == ["Primary school"] for s in result) + + +def test_metric_follows_the_template_not_the_neighbour(): + frame = _frame( + _row(100001, "Subject", phase="Secondary", attainment_8_score=50.0), + _row(100002, "A", phase="Secondary", attainment_8_score=52.8, latitude=_at(0.5)), + _row(100003, "B", phase="Secondary", attainment_8_score=np.nan, latitude=_at(0.6)), + ) + by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)} + assert by_urn[100002]["metric_key"] == "attainment_8_score" + assert by_urn[100002]["metric_value"] == 52.8 + assert by_urn[100002]["metric_year"] == 202425 + assert by_urn[100003]["metric_value"] is None + + +def test_values_are_json_safe_native_types(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + for school in select_similar(frame, 100001, is_secondary=False): + assert isinstance(school["urn"], int) + assert isinstance(school["distance_miles"], float) + assert not isinstance(school["metric_value"], np.generic) + + +# --------------------------------------------------------------------------- +# The endpoint +# --------------------------------------------------------------------------- + +import pytest +from fastapi.testclient import TestClient + + +def _endpoint_frame(): + return _frame( + _row(100001, "Subject Primary"), + _row(100002, "Neighbour A", latitude=_at(0.5)), + _row(100003, "Neighbour B", latitude=_at(0.6)), + ) + + +@pytest.fixture() +def client(monkeypatch): + from backend import app as app_module + + monkeypatch.setattr(app_module, "load_latest_school_data", _endpoint_frame) + monkeypatch.setattr(app_module, "load_school_data", _endpoint_frame) + monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {}) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_detail_payload_carries_similar_schools(client): + resp = client.get("/api/schools/100001") + assert resp.status_code == 200, resp.text + similar = resp.json()["similar_schools"] + assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"] + assert similar[0]["metric_key"] == "rwm_expected_pct" + + +def test_a_failure_in_selection_does_not_break_the_page(client, monkeypatch): + from backend import app as app_module + + def _explode(*args, **kwargs): + raise ValueError("selection blew up") + + monkeypatch.setattr(app_module, "select_similar", _explode) + resp = client.get("/api/schools/100001") + assert resp.status_code == 200, resp.text + assert resp.json()["similar_schools"] == [] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a94087d..e5ca4cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,12 @@ There is no SWR dependency. Leaflet maps are loaded through dynamic wrappers; Chart.js renders performance and comparison charts. `components/school/` contains detail sections, with section decisions and data -preparation in `lib/schoolSections.ts`. `lib/types.ts` contains manually maintained +preparation in `lib/schoolSections.ts`. The similar-schools section is selected +in `backend/similar_schools.py` — hard filters that never relax (phase, +provision, selectivity, gender) and soft preferences that do (religious +character, then gender exactness) — and served on `/api/schools/{urn}`. Its +rules are presentation logic, deliberately kept out of `marts.*` so they can be +tuned by deploy rather than by pipeline run. `lib/types.ts` contains manually maintained API types. `payload-types.ts` and the Payload import map are generated artifacts. ## Publication and caching today diff --git a/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md b/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md new file mode 100644 index 0000000..6eb0929 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md @@ -0,0 +1,1844 @@ +# Similar Schools Nearby 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:** Add a "Similar schools nearby" section to the school detail page, +showing up to six crawlable links to nearby schools of the same phase and a +comparable intake — three at a time in a carousel — each addable to the +comparison basket. + +**Architecture:** A pure backend function ranks candidates out of the cached +latest-year DataFrame using hard filters (never relaxed) and tiered soft +preferences, and its result rides in the existing `/api/schools/{urn}` payload. +The frontend renders it as a server component inside `SchoolDetailShell`, with a +single client island for the add-to-compare button. + +**Tech Stack:** FastAPI + pandas/numpy (backend), Next.js App Router + React +server components + CSS modules (frontend), pytest, Jest + Testing Library, +Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md` + +## Global Constraints + +- **Branch:** `feat/similar-schools-nearby`, already created from `origin/main`. + Never push to `main`; open a PR. Do not trigger the production promotion + workflow. +- **Do not start a local server** to check the application. Use the unit tests + in this plan. +- **Maximum 6 cards, 3 visible, minimum 2.** Fewer than 2 qualifying schools + renders no section and no nav item. Six is a cap, not a quota. +- **Tiers relax to reach three, never to fill six.** Descend the tiers until the + set reaches 3; take up to 6 from the tiers used; never open the next tier just + to fill remaining slots. +- **Every card is in the initial HTML.** The arrows scroll an overflowing list; + they never mount or unmount a card. A card behind an arrow must still be a + crawlable `` in the server-rendered markup. +- **Arrow edge tests use an 8px tolerance, never `=== 0`.** The scroller's 2px + padding is the first snap position, so a row at rest reports `scrollLeft` of 2. +- **[MOBILE.md](../../../MOBILE.md) is binding.** Design at 360px first and + verify at 360 / 390 / 430px before the PR. Its checks: zero horizontal + overflow (`document.documentElement.scrollWidth - innerWidth === 0`), every + interactive element ≥44×44px, no visible text under 11px. +- **Below 640px the arrows are not rendered.** One card at 86% width, and the + right-edge scroll-fade mask MOBILE.md documents carries the affordance. At + 360px two arrow buttons take 96px from a 328px card and crush the lede into + four lines, for a control swiping already provides. +- **Tier radii, in miles:** tier 1 = 3.0, tier 2 = 5.0, tier 3 = 10.0. +- **Hard filters never relax:** self, non-open status, missing coordinates, + different phase group, special↔mainstream, selective↔non-selective, + Boys↔Girls. +- **Every new `.module.css` must use tokens only.** No hex values, no `rgb()`, + no named colours. `__tests__/components/darkThemeSafety.test.ts` scans every + stylesheet under `components/` and fails on hardcoded colour. +- **Exact GIAS display strings** (from `backend/gias_codes.py`): + `admissions_policy` is one of `"Not applicable"`, `"Selective"`, + `"Non-selective"`, `""`; `religious_denomination` includes `"Does not apply"`, + `"None"`, `"Church of England"`, `"Roman Catholic"` and others; `gender` is + `"Mixed"`, `"Boys"` or `"Girls"`. +- **Copy rule:** the lede says "with a similar intake" only when no card is + tier 3. A missing metric renders the exact string `Not published`. +- **No "how these schools are chosen" disclosure.** One caption line only: + distances are straight-line, not road distance. +- **Past six, surplus schools are dropped silently.** No "show more" and no + count; `NearbyPlaces` below already leads to the full lists. +- **The neighbour's metric never carries a valence colour.** No + `--status-above` / `--status-below` anywhere in this feature. +- **Backend tests:** + ```sh + python3.11 -m venv /tmp/schoolcompare-backend-venv + /tmp/schoolcompare-backend-venv/bin/python -m pip install -r requirements.txt pytest 'httpx<0.28' pyyaml + /tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q + ``` +- **Frontend tests:** from `nextjs-app/`, `npm test -- --runInBand` and + `npm run typecheck`. There is no `npm run lint`. + +## File Structure + +| File | Responsibility | +|---|---| +| `backend/similar_schools.py` *(new)* | Pure selection logic: hard filters, tiers, chips. No I/O, no FastAPI. | +| `backend/app.py` *(modify)* | Thin `_similar_schools_payload(urn)` wrapper + one key on the detail response. | +| `backend/tests/test_similar_schools.py` *(new)* | Unit tests for the pure module and one payload integration test. | +| `nextjs-app/lib/types.ts` *(modify)* | `SimilarSchool` type + optional key on the detail response type. | +| `nextjs-app/components/school/SimilarSchoolsSection.tsx` *(new)* | Server component: gates, lede, disclosure, cards, CTA. | +| `nextjs-app/components/school/SimilarSchools.module.css` *(new)* | Section styles, tokens only. | +| `nextjs-app/components/school/AddToCompareButton.tsx` *(new)* | Client island: the per-card basket toggle. | +| `nextjs-app/components/school/SimilarSchoolsCompareBar.tsx` *(new)* | Client island: the selection count and the CTA into `/compare`. | +| `nextjs-app/components/school/SimilarSchoolsCarousel.tsx` *(new)* | Client island: the scroller ref, the arrows and their disabled state. | +| `nextjs-app/lib/schoolSections.ts` *(modify)* | `similar` nav item in both builders. | +| `nextjs-app/components/school/PrimarySchoolSections.tsx` *(modify)* | Render the section last. | +| `nextjs-app/components/school/SecondarySchoolSections.tsx` *(modify)* | Render the section last. | +| `nextjs-app/app/(frontend)/school/[slug]/page.tsx` *(modify)* | Pass `similar_schools` through. | +| `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx` *(new)* | Render gates, lede wording, chips, "Not published". | +| `e2e/tests/journeys.spec.ts` *(modify)* | Journey covering the section and the compare hand-off. | + +Three client islands rather than one, because they need different things: the +button needs a single school, the bar needs the whole selection, and the +carousel needs a DOM ref and nothing else. Keeping them apart means a card never +re-renders when the count changes — which is also what stops the row jumping +back to the start when someone ticks the fifth school. + +The selection logic lives in its own module rather than in `app.py` because +`app.py` is already ~1700 lines, and because a pure function over a DataFrame is +testable without a TestClient, a database or a monkeypatch. + +--- + +### Task 1: Selection logic + +**Files:** +- Create: `backend/similar_schools.py` +- Test: `backend/tests/test_similar_schools.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]`. + Each dict has keys `urn` (int), `school_name` (str), `distance_miles` (float), + `school_type` (str | None), `age_range` (str | None), `shared` (list[str]), + `tier` (int), `metric_value` (float | None), `metric_key` (str), + `metric_year` (int | None). At most 6 entries. Returns `[]` when fewer than 2 + qualify. + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_similar_schools.py`: + +```python +"""Selection rules for the "similar schools nearby" section. + +The hard filters encode claims the section is not allowed to make — that a +selective school is an alternative to a non-selective one, that a special +school is comparable to a mainstream one, or that a Girls school is an option +for a Boys school's reader. They never relax. The soft preferences describe +how close the intake is, and they do — but only far enough to reach a usable +set, never far enough to fill the last of the six slots. +""" + +import numpy as np +import pandas as pd + +from backend.similar_schools import select_similar + +# Roughly 0.7 miles apart in latitude at this longitude. +BASE_LAT, BASE_LON = 51.5000, -0.1000 + + +def _row(urn, name, **overrides): + base = { + "urn": urn, + "school_name": name, + "local_authority": "Testshire", + "school_type": "Community school", + "phase": "Primary", + "age_range": "4-11", + "status": "Open", + "gender": "Mixed", + "religious_denomination": "None", + "admissions_policy": "Not applicable", + "latitude": BASE_LAT, + "longitude": BASE_LON, + "year": 202425, + "rwm_expected_pct": 70.0, + "attainment_8_score": np.nan, + } + base.update(overrides) + return base + + +def _frame(*rows): + return pd.DataFrame(list(rows)) + + +def _at(miles): + """A latitude `miles` north of BASE_LAT.""" + return BASE_LAT + miles / 69.0 + + +def test_returns_nearest_same_phase_schools(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Near", latitude=_at(0.5)), + _row(100003, "Mid", latitude=_at(1.0)), + _row(100004, "Far", latitude=_at(2.0)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert [s["urn"] for s in result] == [100002, 100003, 100004] + assert result[0]["distance_miles"] == 0.5 + + +def test_excludes_the_subject_school(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + assert 100001 not in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + + +def test_selective_never_meets_non_selective(): + frame = _frame( + _row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"), + _row(100002, "Comp A", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.5)), + _row(100003, "Comp B", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.6)), + ) + assert select_similar(frame, 100001, is_secondary=True) == [] + + reverse = select_similar(frame, 100002, is_secondary=True) + assert 100001 not in {s["urn"] for s in reverse} + + +def test_special_schools_match_only_each_other(): + frame = _frame( + _row(100001, "Special", school_type="Community special school"), + _row(100002, "Mainstream A", latitude=_at(0.5)), + _row(100003, "Mainstream B", latitude=_at(0.6)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + assert select_similar(frame, 100002, is_secondary=False) == [] + + +def test_boys_never_meets_girls(): + frame = _frame( + _row(100001, "Boys School", gender="Boys"), + _row(100002, "Girls School", gender="Girls", latitude=_at(0.5)), + _row(100003, "Mixed School", gender="Mixed", latitude=_at(0.6)), + _row(100004, "Another Mixed", gender="Mixed", latitude=_at(0.7)), + ) + urns = {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + assert 100002 not in urns + assert urns == {100003, 100004} + + +def test_closed_schools_and_missing_coordinates_are_dropped(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Closed", status="Closed", latitude=_at(0.5)), + _row(100003, "No coords", latitude=np.nan, longitude=np.nan), + _row(100004, "Good A", latitude=_at(0.6)), + _row(100005, "Good B", latitude=_at(0.7)), + ) + assert {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} == {100004, 100005} + + +def test_tiers_relax_faith_before_gender(): + frame = _frame( + _row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"), + # Tier 1: same gender and same faith. + _row(100002, "Tier one", gender="Boys", religious_denomination="Roman Catholic", latitude=_at(2.0)), + # Tier 2: same gender, different faith — closer, but a weaker match. + _row(100003, "Tier two", gender="Boys", religious_denomination="None", latitude=_at(0.5)), + # Tier 3: mixed gender, different faith. + _row(100004, "Tier three", gender="Mixed", religious_denomination="None", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + tier_by_urn = {s["urn"]: s["tier"] for s in result} + assert tier_by_urn == {100002: 1, 100003: 2, 100004: 3} + # Selected by tier, displayed by distance. + assert [s["urn"] for s in result] == [100003, 100004, 100002] + + +def test_caps_at_six_taking_the_nearest(): + frame = _frame( + _row(100001, "Subject"), + *[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)], + ) + result = select_similar(frame, 100001, is_secondary=False) + assert len(result) == 6 + # The seventh-nearest is the one dropped, not an arbitrary one. + assert 100016 not in {s["urn"] for s in result} + + +def test_tiers_stop_once_enough_are_found(): + """Four tier-1 matches are a usable set, so tier 2 is never opened — even + though it holds a school that is closer than any of them.""" + frame = _frame( + _row(100001, "Subject", religious_denomination="Roman Catholic"), + _row(100002, "RC one", religious_denomination="Roman Catholic", latitude=_at(0.5)), + _row(100003, "RC two", religious_denomination="Roman Catholic", latitude=_at(0.6)), + _row(100004, "RC three", religious_denomination="Roman Catholic", latitude=_at(0.7)), + _row(100005, "RC four", religious_denomination="Roman Catholic", latitude=_at(0.8)), + # Closer than every one of them, but only a tier-2 match. + _row(100006, "Secular and nearer", religious_denomination="None", latitude=_at(0.2)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert 100006 not in {s["urn"] for s in result} + assert len(result) == 4 + assert all(s["tier"] == 1 for s in result) + + +def test_a_school_is_never_taken_twice(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert len(result) == len({s["urn"] for s in result}) + + +def test_fewer_than_two_matches_returns_empty(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "Only neighbour", latitude=_at(0.5)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + + +def test_beyond_the_widest_radius_is_not_offered(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(11.0)), + _row(100003, "B", latitude=_at(12.0)), + ) + assert select_similar(frame, 100001, is_secondary=False) == [] + + +def test_all_through_is_offered_on_both_phase_sides(): + frame = _frame( + _row(100001, "Primary subject", phase="Primary"), + _row(100002, "All through", phase="All-through", latitude=_at(0.5)), + _row(100003, "Primary peer", phase="Primary", latitude=_at(0.6)), + ) + assert 100002 in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + + secondary = _frame( + _row(100010, "Secondary subject", phase="Secondary"), + _row(100002, "All through", phase="All-through", latitude=_at(0.5)), + _row(100011, "Secondary peer", phase="Secondary", latitude=_at(0.6)), + ) + assert 100002 in {s["urn"] for s in select_similar(secondary, 100010, is_secondary=True)} + + +def test_chips_state_only_what_the_tier_earned(): + frame = _frame( + _row(100001, "Subject", phase="Secondary", gender="Mixed", + religious_denomination="None", admissions_policy="Non-selective"), + _row(100002, "Full match", phase="Secondary", gender="Mixed", + religious_denomination="None", admissions_policy="Non-selective", latitude=_at(0.5)), + _row(100003, "Faith differs", phase="Secondary", gender="Mixed", + religious_denomination="Church of England", admissions_policy="Non-selective", latitude=_at(0.6)), + ) + by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)} + assert by_urn[100002]["shared"] == ["Mixed", "Non-selective", "No religious character"] + assert by_urn[100003]["shared"] == ["Mixed", "Non-selective"] + + +def test_tier_three_chip_is_the_plain_phase(): + frame = _frame( + _row(100001, "Subject", gender="Boys"), + _row(100002, "A", gender="Mixed", latitude=_at(0.5)), + _row(100003, "B", gender="Mixed", latitude=_at(0.6)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert all(s["shared"] == ["Primary school"] for s in result) + + +def test_metric_follows_the_template_not_the_neighbour(): + frame = _frame( + _row(100001, "Subject", phase="Secondary", attainment_8_score=50.0), + _row(100002, "A", phase="Secondary", attainment_8_score=52.8, latitude=_at(0.5)), + _row(100003, "B", phase="Secondary", attainment_8_score=np.nan, latitude=_at(0.6)), + ) + by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)} + assert by_urn[100002]["metric_key"] == "attainment_8_score" + assert by_urn[100002]["metric_value"] == 52.8 + assert by_urn[100002]["metric_year"] == 202425 + assert by_urn[100003]["metric_value"] is None + + +def test_values_are_json_safe_native_types(): + frame = _frame( + _row(100001, "Subject"), + _row(100002, "A", latitude=_at(0.5)), + _row(100003, "B", latitude=_at(0.6)), + ) + for school in select_similar(frame, 100001, is_secondary=False): + assert isinstance(school["urn"], int) + assert isinstance(school["distance_miles"], float) + assert not isinstance(school["metric_value"], np.generic) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q +``` +Expected: collection error, `ModuleNotFoundError: No module named 'backend.similar_schools'`. + +- [ ] **Step 3: Move PHASE_GROUPS out of app.py first** + +`PHASE_GROUPS` lives in `backend/app.py:51-55`, and importing `app` from +`similar_schools` while `app` imports `similar_schools` is a cycle. Move the +constant to `backend/schemas.py`, which `app.py` already imports and which holds +the other shared column and display constants. + +In `backend/schemas.py`, add near `SCHOOL_COLUMNS`: + +```python +# Phase groups for search, place pages and similar-school matching. All-through +# schools belong to both sides, which is why this is a set per phase rather +# than a single phase string comparison. +PHASE_GROUPS: dict[str, set[str]] = { + "primary": {"primary", "middle deemed primary", "all-through"}, + "secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"}, + "all-through": {"all-through"}, +} +``` + +In `backend/app.py`, delete the `PHASE_GROUPS` definition at lines 51-55 and add +`PHASE_GROUPS` to the existing `from .schemas import (...)` list. Leave the call +sites at `app.py:762` and `app.py:1402` untouched — the name resolves the same. + +- [ ] **Step 4: Write the implementation** + +Create `backend/similar_schools.py`: + +```python +"""Which nearby schools a detail page may offer as alternatives. + +Two kinds of rule, and they are not interchangeable. + +HARD FILTERS encode claims the section is not allowed to make. A selective +school is not an alternative to a non-selective one, a special school is not +comparable to a mainstream one, and a Girls school is not an option for a Boys +school's reader. These never relax, at any distance, even where that means the +section does not render at all. + +SOFT PREFERENCES describe how closely an intake resembles this school's. They +relax in tiers, and every card reports the tier that actually took it so the +page can say what is shared rather than implying more. + +Pure functions over a DataFrame: no I/O, no FastAPI, no database. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pandas as pd + +from .schemas import PHASE_GROUPS + +# A cap, not a quota: the section shows everything that qualified at the tiers +# it used, up to this many. Three fit the row; the rest are behind the arrows. +MAX_SCHOOLS = 6 +# Tiers stop relaxing once this many have been found. Without it, a cap of six +# would reliably drag in tier-3 schools ten miles away to fill a row that three +# good matches had already earned. +ENOUGH = 3 +MINIMUM = 2 + +# (tier, radius in miles). Faith relaxes before gender: a faith mismatch +# changes the character of a school, while a gender mismatch can mean the +# school is not available to this reader's child at all. +TIERS: tuple[tuple[int, float], ...] = ((1, 3.0), (2, 5.0), (3, 10.0)) + +EARTH_RADIUS_MILES = 3958.8 + +_SPECIAL = re.compile(r"\bspecial\b|pupil referral|alternative provision", re.I) + +# Values that mean "this school has no religious character". +_NO_FAITH = {"", "none", "does not apply", "not applicable"} + + +def is_special_provision(school_type: str | None) -> bool: + """Mirror of isSpecialSchool() in nextjs-app/lib/utils.ts. + + Special schools carry a mainstream phase, so phase alone cannot identify + them. The two implementations must agree: a school the frontend treats as + special for benchmarking but this treats as mainstream would be dropped + from its own England comparison and then offered as a peer to a mainstream + school on the next page along. + """ + return bool(_SPECIAL.search(school_type or "")) + + +def is_selective(admissions_policy: str | None) -> bool: + """Strictly selective. Unknown counts as non-selective, which is the safe + direction: it can only ever exclude a pairing, never invent one.""" + return (admissions_policy or "").strip().lower() == "selective" + + +def faith_key(denomination: str | None) -> str: + value = (denomination or "").strip().lower() + return "" if value in _NO_FAITH else value + + +def faith_label(denomination: str | None) -> str: + return denomination.strip() if faith_key(denomination) else "No religious character" + + +def genders_compatible(a: str | None, b: str | None) -> bool: + single = {"boys", "girls"} + left, right = (a or "").strip().lower(), (b or "").strip().lower() + return not (left in single and right in single and left != right) + + +def phase_label(phase: str | None) -> str: + text = (phase or "").strip() + if not text: + return "School" + if text.lower() == "all-through": + return "All-through school" + return f"{text.capitalize()} school" + + +def _phase_group(is_secondary: bool) -> set[str]: + return PHASE_GROUPS["secondary" if is_secondary else "primary"] + + +def _haversine_miles(lat1: float, lon1: float, lat2, lon2): + """Vectorised, matching the postcode search in app.py.""" + lat1_r, lon1_r = np.radians(lat1), np.radians(lon1) + lat2_r, lon2_r = np.radians(lat2.astype(float)), np.radians(lon2.astype(float)) + dlat, dlon = lat2_r - lat1_r, lon2_r - lon1_r + a = np.sin(dlat / 2) ** 2 + np.cos(lat1_r) * np.cos(lat2_r) * np.sin(dlon / 2) ** 2 + return 2 * EARTH_RADIUS_MILES * np.arcsin(np.sqrt(a)) + + +def _native(value): + """NaN and numpy scalars both reach JSONResponse badly; normalise here so + the caller never has to remember to.""" + if value is None or (isinstance(value, float) and np.isnan(value)): + return None + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, float) and np.isnan(value): + return None + return value + + +def _chips(subject: pd.Series, candidate: pd.Series, tier: int, is_secondary: bool) -> list[str]: + if tier >= 3: + return [phase_label(candidate.get("phase"))] + + chips = [str(subject.get("gender") or "").strip()] + if is_secondary: + policy = (candidate.get("admissions_policy") or "").strip() + if policy and policy.lower() not in {"not applicable", "unknown"}: + chips.append(policy) + if tier == 1: + chips.append(faith_label(candidate.get("religious_denomination"))) + return [chip for chip in chips if chip] + + +def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]: + """Up to MAX_SCHOOLS nearby schools this page may offer, or [] below MINIMUM. + + Selected by tier, displayed by distance: the tier decides which schools + earn a slot, and the render order is then closest-first, because "nearby" + is the promise in the heading. + """ + subject_rows = frame[frame["urn"] == urn] + if subject_rows.empty: + return [] + subject = subject_rows.iloc[0] + + lat, lon = _native(subject.get("latitude")), _native(subject.get("longitude")) + if lat is None or lon is None: + return [] + + metric_key = "attainment_8_score" if is_secondary else "rwm_expected_pct" + + candidates = frame[frame["urn"] != urn].copy() + for column in ("latitude", "longitude"): + candidates = candidates[candidates[column].notna()] + if candidates.empty: + return [] + + # ── Hard filters ──────────────────────────────────────────────────── + allowed_phases = _phase_group(is_secondary) + candidates = candidates[ + candidates["phase"].fillna("").str.lower().isin(allowed_phases) + ] + candidates = candidates[candidates["status"].fillna("").str.lower().str.startswith("open")] + + subject_special = is_special_provision(subject.get("school_type")) + special = candidates["school_type"].apply(is_special_provision) + candidates = candidates[special if subject_special else ~special] + + subject_selective = is_selective(subject.get("admissions_policy")) + selective = candidates["admissions_policy"].apply(is_selective) + candidates = candidates[selective if subject_selective else ~selective] + + subject_gender = subject.get("gender") + candidates = candidates[ + candidates["gender"].apply(lambda g: genders_compatible(subject_gender, g)) + ] + if candidates.empty: + return [] + + candidates["distance_miles"] = _haversine_miles( + lat, lon, candidates["latitude"].values, candidates["longitude"].values + ).round(1) + + # ── Soft preferences, in tiers ────────────────────────────────────── + subject_faith = faith_key(subject.get("religious_denomination")) + subject_gender_key = (subject_gender or "").strip().lower() + same_gender = candidates["gender"].fillna("").str.strip().str.lower() == subject_gender_key + same_faith = candidates["religious_denomination"].apply(faith_key) == subject_faith + + tier_masks = { + 1: same_gender & same_faith, + 2: same_gender, + 3: pd.Series(True, index=candidates.index), + } + + # Descend the tiers only until the set reaches ENOUGH. The tier that gets + # there is the last one opened, and the remaining slots up to MAX_SCHOOLS + # are filled from the tiers already used — never by widening again. + picked: dict[int, tuple[int, pd.Series]] = {} + for tier, radius in TIERS: + within = candidates[tier_masks[tier] & (candidates["distance_miles"] <= radius)] + for _, row in within.sort_values("distance_miles").iterrows(): + candidate_urn = int(row["urn"]) + if candidate_urn in picked: + continue + picked[candidate_urn] = (tier, row) + if len(picked) >= MAX_SCHOOLS: + break + if len(picked) >= ENOUGH: + break + + if len(picked) < MINIMUM: + return [] + + selected = sorted( + picked.values(), key=lambda pair: float(pair[1]["distance_miles"]) + )[:MAX_SCHOOLS] + return [ + { + "urn": int(row["urn"]), + "school_name": str(row.get("school_name") or ""), + "distance_miles": float(row["distance_miles"]), + "school_type": _native(row.get("school_type")), + "age_range": _native(row.get("age_range")), + "shared": _chips(subject, row, tier, is_secondary), + "tier": tier, + "metric_value": _native(row.get(metric_key)), + "metric_key": metric_key, + "metric_year": _native(row.get("year")), + } + for tier, row in selected + ] +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q +``` +Expected: PASS, 16 tests. + +- [ ] **Step 6: Run the whole backend suite for the `PHASE_GROUPS` move** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q +``` +Expected: PASS. Any failure here is the constant move, not the new module. + +- [ ] **Step 7: Commit** + +```bash +git add backend/similar_schools.py backend/tests/test_similar_schools.py backend/schemas.py backend/app.py +git commit -m "feat(api): decide which nearby schools a page may offer + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Serve it on the detail endpoint + +**Files:** +- Modify: `backend/app.py` (detail route at line 910, response dict at ~975) +- Test: `backend/tests/test_similar_schools.py` (append) + +**Interfaces:** +- Consumes: `select_similar(frame, urn, is_secondary)` from Task 1. +- Produces: a `similar_schools` key on the `/api/schools/{urn}` JSON response — + a list of the Task 1 dicts, `[]` when nothing qualifies. Never absent on a + build that has this code. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_similar_schools.py`: + +```python +import pytest +from fastapi.testclient import TestClient + + +def _endpoint_frame(): + return _frame( + _row(100001, "Subject Primary"), + _row(100002, "Neighbour A", latitude=_at(0.5)), + _row(100003, "Neighbour B", latitude=_at(0.6)), + ) + + +@pytest.fixture() +def client(monkeypatch): + from backend import app as app_module + + monkeypatch.setattr(app_module, "load_latest_school_data", _endpoint_frame) + monkeypatch.setattr(app_module, "load_school_data", _endpoint_frame) + monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {}) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_detail_payload_carries_similar_schools(client): + resp = client.get("/api/schools/100001") + assert resp.status_code == 200, resp.text + similar = resp.json()["similar_schools"] + assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"] + assert similar[0]["metric_key"] == "rwm_expected_pct" + + +def test_a_failure_in_selection_does_not_break_the_page(client, monkeypatch): + from backend import app as app_module + + def _explode(*args, **kwargs): + raise ValueError("selection blew up") + + monkeypatch.setattr(app_module, "select_similar", _explode) + resp = client.get("/api/schools/100001") + assert resp.status_code == 200, resp.text + assert resp.json()["similar_schools"] == [] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q -k "detail_payload or failure_in_selection" +``` +Expected: FAIL with `KeyError: 'similar_schools'`. + +- [ ] **Step 3: Write the implementation** + +In `backend/app.py`, add to the imports: + +```python +from .similar_schools import select_similar +``` + +Add this helper beside `_places_payload`: + +```python +def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]: + """Nearby schools this page may offer as alternatives. + + Wrapped: a failure in selection must never 500 a page that is otherwise + complete, which is the posture get_supplementary_data already takes. The + section simply does not render. + """ + try: + phase_text = (phase or "").lower() + # All-through schools render with the primary template, which is what + # decides the metric, so they are not secondary here. + is_secondary = phase_text != "all-through" and "secondary" in phase_text + return select_similar(load_latest_school_data(), int(urn), is_secondary) + except Exception: + logger.exception("similar schools selection failed for urn=%s", urn) + return [] +``` + +In the `/api/schools/{urn}` response dict, immediately after the `"places"` key: + +```python + # Nearby schools of the same phase and a comparable intake. Always + # present on a build with this code; the frontend treats absent and + # empty identically, which is what lets the two images deploy + # independently. + "similar_schools": _similar_schools_payload(urn, latest.get("phase")), +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q +``` +Expected: PASS, whole suite. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app.py backend/tests/test_similar_schools.py +git commit -m "feat(api): serve similar schools on the detail endpoint + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Types and the section component + +**Files:** +- Modify: `nextjs-app/lib/types.ts` +- Create: `nextjs-app/components/school/SimilarSchoolsSection.tsx` +- Create: `nextjs-app/components/school/SimilarSchools.module.css` +- Create: `nextjs-app/components/school/AddToCompareButton.tsx` +- Test: `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx` + +**Interfaces:** +- Consumes: the `similar_schools` payload from Task 2. +- Produces: `SimilarSchool` (exported from `lib/types.ts`) and + ``, + which returns `null` when it must not render. Also + `shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean`, exported + from the same file and consumed by Task 4's nav gating, and + ``. + +- [ ] **Step 1: Write the failing test** + +Create `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx`: + +```tsx +/** + * The section's job is to be honest about what it matched. These tests pin the + * three ways it could lie: rendering below the minimum, claiming a similar + * intake at tier 3, and showing a missing figure as a number. + */ + +import { render, screen } from '@testing-library/react'; + +import { + SimilarSchoolsSection, + shouldRenderSimilar, +} from '@/components/school/SimilarSchoolsSection'; +import type { SimilarSchool } from '@/lib/types'; + +jest.mock('@/components/school/AddToCompareButton', () => ({ + AddToCompareButton: ({ school }: { school: SimilarSchool }) => ( + + ), +})); + +jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({ + SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => ( +
bar for {thisUrn}
+ ), +})); + +function school(overrides: Partial = {}): SimilarSchool { + return { + urn: 100002, + school_name: 'Willow Lane Primary School', + distance_miles: 0.6, + school_type: 'Community school', + age_range: '4-11', + shared: ['Mixed', 'No religious character'], + tier: 1, + metric_value: 74, + metric_key: 'rwm_expected_pct', + metric_year: 202425, + ...overrides, + }; +} + +function renderSection(similar: SimilarSchool[]) { + return render( + , + ); +} + +describe('render gates', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['empty', []], + ['a single school', [school()]], + ])('renders nothing for %s', (_label, value) => { + expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false); + }); + + it('renders for two or more schools', () => { + expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true); + }); + + it('returns null rather than an empty shell below the minimum', () => { + const { container } = renderSection([school()]); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('the claim the lede makes', () => { + it('claims a similar intake when every card is tier 1 or 2', () => { + renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]); + expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument(); + }); + + it('drops the claim when any card is tier 3', () => { + renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]); + expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument(); + }); +}); + +describe('cards', () => { + it('links each school to its canonical slug', () => { + renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]); + const link = screen.getByRole('link', { name: /Willow Lane Primary School/ }); + expect(link).toHaveAttribute('href', '/school/100002-willow-lane-primary-school'); + }); + + it('shows the distance and the shared characteristics', () => { + renderSection([school(), school({ urn: 100003 })]); + expect(screen.getAllByText('0.6 miles away').length).toBeGreaterThan(0); + expect(screen.getAllByText('Mixed').length).toBeGreaterThan(0); + }); + + it('renders a missing figure as "Not published", never as a number', () => { + renderSection([school({ metric_value: null }), school({ urn: 100003 })]); + expect(screen.getByText('Not published')).toBeInTheDocument(); + }); + + it('anchors each figure against this school', () => { + renderSection([school(), school({ urn: 100003 })]); + expect(screen.getAllByText('72% at this school').length).toBe(2); + }); + + it('offers the compare bar once, for this school', () => { + renderSection([school(), school({ urn: 100003 })]); + expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001'); + }); + + it('keeps every card in the DOM, including the ones scrolled out of view', () => { + const six = Array.from({ length: 6 }, (_, n) => + school({ urn: 100002 + n, school_name: `Peer ${n} School` }), + ); + renderSection(six); + expect(screen.getAllByRole('link', { name: /Peer \d School/ })).toHaveLength(6); + }); + + it('offers no arrows when three cards fit the row', () => { + renderSection([school(), school({ urn: 100003 }), school({ urn: 100004 })]); + expect(screen.queryByRole('button', { name: /More schools/ })).not.toBeInTheDocument(); + }); + + it('offers arrows once there is a fourth school', () => { + renderSection(Array.from({ length: 4 }, (_, n) => school({ urn: 100002 + n }))); + expect(screen.getByRole('button', { name: /More schools/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Previous schools/ })).toBeInTheDocument(); + }); + + it('says distances are straight-line, and offers no method panel', () => { + const { container } = renderSection([school(), school({ urn: 100003 })]); + expect(screen.getByText(/straight-line from this school/i)).toBeInTheDocument(); + expect(container.querySelector('details')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run (from `nextjs-app/`): +```sh +npm test -- --runInBand SimilarSchoolsSection +``` +Expected: FAIL, cannot resolve `@/components/school/SimilarSchoolsSection`. + +- [ ] **Step 3: Add the type** + +In `nextjs-app/lib/types.ts`, beside the other detail-payload types: + +```ts +/** A nearby school of the same phase and a comparable intake. + * `tier` is carried explicitly rather than inferred from `shared`, because it + * drives two separate decisions — whether the lede may claim a similar intake, + * and whether a chip renders as a fill or a muted outline. */ +export interface SimilarSchool { + urn: number; + school_name: string; + distance_miles: number; + school_type: string | null; + age_range: string | null; + shared: string[]; + tier: number; + metric_value: number | null; + metric_key: string; + metric_year: number | null; +} +``` + +Add to the school detail response interface (the one `fetchSchoolDetails` +returns), keeping it optional so an older backend image still typechecks: + +```ts + similar_schools?: SimilarSchool[]; +``` + +- [ ] **Step 4: Write the client island** + +Create `nextjs-app/components/school/AddToCompareButton.tsx`: + +```tsx +'use client'; + +/** + * The only client JavaScript in the similar-schools section. + * + * The links are server-rendered, so the section works with JS off; this adds + * the basket interaction on top rather than being what makes the section + * function. + */ + +import { useComparisonContext } from '@/context/ComparisonContext'; +import type { School, SimilarSchool } from '@/lib/types'; +import styles from './SimilarSchools.module.css'; + +export function AddToCompareButton({ school }: { school: SimilarSchool }) { + const { addSchool, removeSchool, selectedSchools } = useComparisonContext(); + const selected = selectedSchools.some((s) => s.urn === school.urn); + + const toggle = () => { + if (selected) { + removeSchool(school.urn); + return; + } + // The basket only needs identity and display fields; the compare page + // fetches everything it renders by URN. + addSchool({ + urn: school.urn, + school_name: school.school_name, + school_type: school.school_type, + age_range: school.age_range, + } as School); + }; + + return ( + + ); +} +``` + +- [ ] **Step 5: Write the compare bar** + +Create `nextjs-app/components/school/SimilarSchoolsCompareBar.tsx`: + +```tsx +'use client'; + +/** + * The selection count and the one decisive action in the section. + * + * The CTA is a real link, not a handler: /compare already parses `urns` from + * the query string, so the hand-off needs no new compare plumbing. It counts + * this school plus whatever the reader ticked, because comparing a shortlist + * without the school they are looking at is not what they asked for. + */ + +import Link from 'next/link'; +import { useComparisonContext } from '@/context/ComparisonContext'; +import type { SimilarSchool } from '@/lib/types'; +import styles from './SimilarSchools.module.css'; + +export function SimilarSchoolsCompareBar({ + thisUrn, + candidates, +}: { + thisUrn: number; + candidates: SimilarSchool[]; +}) { + const { selectedSchools } = useComparisonContext(); + + // Only the schools this section offers, in the order the cards show them — + // the basket may hold schools picked up elsewhere on the site, and this bar + // speaks for this section. + const offered = candidates + .map((c) => c.urn) + .filter((urn) => selectedSchools.some((s) => s.urn === urn)); + + const count = offered.length; + const href = `/compare?urns=${[thisUrn, ...offered].join(',')}`; + + return ( +
+

+ + {count + ? `${count} school${count === 1 ? '' : 's'} selected` + : 'Compare side by side'} + + {count + ? 'This school is included automatically.' + : 'Add a school to compare it with this one.'} +

+ {count ? ( + + Compare {count + 1} schools → + + ) : ( + + Compare → + + )} +
+ ); +} +``` + +- [ ] **Step 6: Write the carousel** + +Create `nextjs-app/components/school/SimilarSchoolsCarousel.tsx`: + +```tsx +'use client'; + +/** + * The scroller and its arrows. + * + * `children` are the server-rendered cards and `header` the server-rendered + * heading and lede: both stay server components, passed through, so this file + * owns a DOM ref and nothing else. That is what keeps all six links in the + * initial HTML — a carousel that mounted cards on click would put four of the + * six beyond a crawler and beyond a reader with no JavaScript. + * + * With JavaScript off this degrades to a horizontally scrollable row, which is + * still usable by touch and trackpad. + */ + +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import styles from './SimilarSchools.module.css'; + +/** Three cards fit the row, so fewer than four has nowhere to scroll to. + * Below 640px the arrows are not rendered at all — see the stylesheet. */ +const VISIBLE = 3; + +/** + * Why a tolerance rather than `=== 0`. + * + * The scroller carries 2px of padding so focus rings are not clipped, and + * scroll-snap treats that padding as the first card's snap position — a row at + * rest reports scrollLeft 2, not 0. Sub-pixel rounding moves it again at other + * zoom levels. An exact test leaves the back arrow live on first paint, + * pointing nowhere. + */ +const EDGE = 8; + +export function SimilarSchoolsCarousel({ + count, + labelledBy, + header, + children, +}: { + count: number; + labelledBy: string; + header: ReactNode; + children: ReactNode; +}) { + const scroller = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + const scrollable = count > VISIBLE; + + const sync = useCallback(() => { + const node = scroller.current; + if (!node) return; + const max = node.scrollWidth - node.clientWidth; + setAtStart(node.scrollLeft <= EDGE); + setAtEnd(node.scrollLeft >= max - EDGE); + }, []); + // `atEnd` is not only the forward arrow's disabled state: below 640px, where + // no arrow is rendered, it is the only thing driving the scroll-fade. + + // Also on mount: the first measurement can only happen once there is layout. + useEffect(sync, [sync]); + + const page = (direction: 1 | -1) => { + const node = scroller.current; + if (!node) return; + // A page is what the reader can see, so the viewport is the step. + node.scrollBy({ left: direction * node.clientWidth, behavior: 'smooth' }); + }; + + return ( + <> +
+ {header} + {scrollable && ( +
+ + +
+ )} +
+ +
    + {children} +
+ + ); +} + +function Chevron({ direction }: { direction: 'prev' | 'next' }) { + return ( + + ); +} +``` + +- [ ] **Step 7: Write the section** + +Create `nextjs-app/components/school/SimilarSchoolsSection.tsx`: + +```tsx +/** + * SimilarSchoolsSection — nearby schools of the same phase and a comparable + * intake. Server component; only AddToCompareButton is client-side. + * + * The section is allowed to say exactly what the backend matched and no more. + * The lede only claims a similar intake when no card came from tier 3, and a + * card's chips list what that school actually shares rather than a match it + * did not earn. + * + * There is deliberately no "how these are chosen" panel: the method is already + * visible in the lede, the chips and the distances. The single caption line is + * not a method note — it is the one thing a card cannot self-correct. + */ + +import Link from 'next/link'; +import type { SimilarSchool } from '@/lib/types'; +import { schoolUrl } from '@/lib/utils'; +import { AddToCompareButton } from './AddToCompareButton'; +import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel'; +import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar'; +import { Section } from './sectionShared'; +import styles from './SimilarSchools.module.css'; + +const MINIMUM = 2; + +export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean { + return (similar?.length ?? 0) >= MINIMUM; +} + +function metricLabel(key: string): string { + return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths'; +} + +function formatMetric(value: number | null, key: string): string { + if (value == null) return 'Not published'; + return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`; +} + +export function SimilarSchoolsSection({ + urn, + schoolName, + phaseNoun, + thisMetricValue, + similar, +}: { + urn: number; + schoolName: string; + phaseNoun: string; + thisMetricValue: number | null; + similar?: SimilarSchool[] | null; +}) { + if (!shouldRenderSimilar(similar)) return null; + const schools = similar as SimilarSchool[]; + + // One card matched on phase alone, so the section may not claim the set + // shares an intake with this school. + const loosest = Math.max(...schools.map((s) => s.tier)); + const metricKey = schools[0].metric_key; + + return ( +
+ +

+ Similar schools nearby +

+

+ {loosest >= 3 + ? `Other ${phaseNoun} schools near ${schoolName}.` + : `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`} +

+ + } + > + {schools.map((school) => ( +
  • +

    {school.distance_miles} miles away

    +

    + + {school.school_name} + +

    +

    + {[school.school_type, school.age_range && `Ages ${school.age_range}`] + .filter(Boolean) + .join(' · ')} +

    +
      + {school.shared.map((label) => ( +
    • = 3 ? styles.chipLoose : styles.chip}> + {label} +
    • + ))} +
    +
    +

    + {formatMetric(school.metric_value, school.metric_key)} +

    +

    {metricLabel(school.metric_key)}

    + {thisMetricValue != null && ( +

    + {formatMetric(thisMetricValue, metricKey)} at this school +

    + )} +
    + +
  • + ))} +
    + + + + {/* The one caveat the cards cannot make on their own: a reader who takes + "0.6 miles away" for the walk has been misled, and nothing else here + corrects that. */} +

    + Distances are straight-line from this school, not road distance. +

    +
    + ); +} +``` + +- [ ] **Step 8: Write the stylesheet** + +Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only — +`darkThemeSafety.test.ts` fails the build on any hardcoded colour: + +```css +.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; } +.lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; } +.caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); } + +.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.arrows { display: flex; gap: 0.5rem; flex: none; } +.arrow { width: 44px; height: 44px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); } +.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); } +.arrow:disabled { opacity: 0.35; cursor: default; } +.arrow svg { width: 17px; height: 17px; } + +/* A scroller, not a paginated view: every card is in the DOM and the arrows + only move the viewport across them. The 2px padding keeps focus rings from + being clipped — and is why the arrows' edge test needs a tolerance. */ +.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; } +.scroller::-webkit-scrollbar { display: none; } +@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } } +/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and + crush the lede into four lines, for a control swiping already provides. They + go, and the documented right-edge fade carries the affordance — lifting at + the end of the travel, where there is nothing more to hint at. */ +@media (max-width: 640px) { + .top { display: block; } + .arrows { display: none; } + .scroller { grid-auto-columns: 86%; mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); } + .scroller[data-at-end="true"] { mask-image: none; } +} + +.school { position: relative; display: flex; flex-direction: column; scroll-snap-align: start; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); } +.school:hover { border-color: var(--border-strong); } + +.distance { margin: 0 0 0.6rem; font-size: 0.75rem; color: var(--text-muted); } +.name { font-family: var(--font-display); font-size: 1rem; line-height: 1.35; margin: 0 0 0.35rem; } +.name a { color: var(--text-primary); text-decoration: none; } +/* The whole card is the link target; the button sits above it on z-index. */ +.name a::after { content: ""; position: absolute; inset: 0; border-radius: 8px; } +.name a:hover { color: var(--brand); text-decoration: underline; } +.meta { margin: 0 0 0.75rem; font-size: 0.78rem; color: var(--text-muted); } + +.shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; } +.chip { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: var(--brand-bg); color: var(--brand); border: 1px solid transparent; } +.chipLoose { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); } + +.metric { margin-top: auto; padding-top: 0.8rem; border-top: 1px solid var(--border); } +/* No valence colour here, deliberately: green and terracotta mean "against the + England average" everywhere else on the site, and colouring a neighbour + against this school would read as ranking the neighbours. */ +.value { font-family: var(--font-display); font-size: 1.6rem; font-weight: 700; letter-spacing: -0.6px; margin: 0; color: var(--text-primary); } +.valueAbsent { font-size: 0.95rem; font-weight: 600; margin: 0; color: var(--text-muted); } +.metricLabel { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--text-secondary); } +.metricRef { margin: 0.1rem 0 0; font-size: 0.75rem; color: var(--text-muted); } + +.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 44px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); } +.add:hover { border-color: var(--brand); background: var(--brand-bg); } +.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; } + +.footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.85rem; margin-top: 1.25rem; padding-top: 1.1rem; border-top: 1px solid var(--border); } +.footer p { margin: 0; font-size: 0.78rem; color: var(--text-muted); } +.footer strong { display: block; font-size: 0.88rem; font-weight: 600; color: var(--text-primary); } +/* Coral is the one decisive action per screen, and in this section this is it. */ +.compare { min-height: 44px; padding: 0 1.25rem; border-radius: 8px; font-size: 0.88rem; font-weight: 600; background: var(--action); color: var(--action-on); border: 1px solid var(--action); text-decoration: none; display: inline-flex; align-items: center; } +.compare:hover { background: var(--action-strong); border-color: var(--action-strong); } +.compare[aria-disabled="true"] { opacity: 0.45; pointer-events: none; } + +.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +``` + +- [ ] **Step 9: Run the tests to verify they pass** + +Run (from `nextjs-app/`): +```sh +npm test -- --runInBand SimilarSchoolsSection darkThemeSafety +npm run typecheck +``` +Expected: PASS on both suites, and typecheck clean. + +Do **not** add a Jest assertion on which arrow is disabled. jsdom has no layout, +so `scrollWidth` and `clientWidth` are both 0 there and the component measures +an empty row — a test written against that passes on a measurement that does not +exist. The arrows' disabled behaviour is covered in Task 5's journey, against a +real engine. + +- [ ] **Step 10: Commit** + +```bash +git add nextjs-app/lib/types.ts nextjs-app/components/school/SimilarSchoolsSection.tsx nextjs-app/components/school/SimilarSchools.module.css nextjs-app/components/school/AddToCompareButton.tsx nextjs-app/components/school/SimilarSchoolsCompareBar.tsx nextjs-app/components/school/SimilarSchoolsCarousel.tsx nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx +git commit -m "feat(web): similar schools section, honest about what it matched + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Wire it into both templates + +**Files:** +- Modify: `nextjs-app/lib/schoolSections.ts` (`buildNavItems` at line 143, `buildSecondaryNavItems`) +- Modify: `nextjs-app/components/school/PrimarySchoolSections.tsx` +- Modify: `nextjs-app/components/school/SecondarySchoolSections.tsx` +- Modify: `nextjs-app/app/(frontend)/school/[slug]/page.tsx` +- Test: `nextjs-app/__tests__/lib/schoolSections.test.ts` (append, or create if absent) + +**Interfaces:** +- Consumes: `shouldRenderSimilar` and `SimilarSchoolsSection` from Task 3. +- Produces: nothing later tasks build on. + +- [ ] **Step 1: Write the failing test** + +Append to the existing `nextjs-app/__tests__/lib/schoolSections.test.ts`, +matching the imports already at the top of that file: + +```ts +import { buildNavItems, buildSecondaryNavItems, computeSchoolFlags } from '@/lib/schoolSections'; + +const navInput = { + ofsted: null, + admissions: null, + admissionDistance: null, + hasLocation: true, + yearlyDataLength: 1, + hasSimilarSchools: true, +}; + +describe('the similar-schools nav item', () => { + it('appears on both templates when the section renders', () => { + const flags = computeSchoolFlags({ + schoolInfo: { phase: 'Primary' } as never, + yearlyData: [], absenceData: [], census: null, + deprivation: null, finance: null, destinations: null, + }); + expect(buildNavItems(flags, navInput).map((i) => i.id)).toContain('similar'); + expect(buildSecondaryNavItems(flags as never, navInput).map((i) => i.id)).toContain('similar'); + }); + + it('is absent when the section does not render', () => { + const flags = computeSchoolFlags({ + schoolInfo: { phase: 'Primary' } as never, + yearlyData: [], absenceData: [], census: null, + deprivation: null, finance: null, destinations: null, + }); + const without = { ...navInput, hasSimilarSchools: false }; + expect(buildNavItems(flags, without).map((i) => i.id)).not.toContain('similar'); + expect(buildSecondaryNavItems(flags as never, without).map((i) => i.id)).not.toContain('similar'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run (from `nextjs-app/`): +```sh +npm test -- --runInBand schoolSections +``` +Expected: FAIL — `'similar'` is not in the nav item ids. + +- [ ] **Step 3: Add the nav item** + +In `nextjs-app/lib/schoolSections.ts`, add `hasSimilarSchools: boolean;` to the +`NavItemsInput` interface, then destructure it in both builders and append, as +the **last** item in each (the section renders last, and the scroll-spy order +must match the DOM order): + +```ts + if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' }); +``` + +- [ ] **Step 4: Render the section in both templates** + +In `PrimarySchoolSections.tsx` and `SecondarySchoolSections.tsx`: add +`similarSchools?: SimilarSchool[]` to the props interface, import +`SimilarSchoolsSection`, and render it as the last child, after every existing +section: + +```tsx + +``` + +In `PrimarySchoolSections.tsx`, derive the two values from what the component +already has: + +```tsx + const phaseNoun = (schoolInfo.phase ?? '').toLowerCase() === 'all-through' + ? 'all-through' + : 'primary'; + const thisMetricValue = flags.latestResults?.rwm_expected_pct ?? null; +``` + +In `SecondarySchoolSections.tsx`: + +```tsx + const phaseNoun = 'secondary'; + const thisMetricValue = flags.latestResults?.attainment_8_score ?? null; +``` + +- [ ] **Step 5: Pass the payload through the page** + +In `nextjs-app/app/(frontend)/school/[slug]/page.tsx`: + +Add `similar_schools` to the destructure of `data` (around line 155): + +```tsx + // Absent on an older API build, exactly like `places` above. + const similarSchools = data.similar_schools ?? []; +``` + +Add to `navInput` (around line 185): + +```tsx + hasSimilarSchools: shouldRenderSimilar(similarSchools), +``` + +importing `shouldRenderSimilar` from +`@/components/school/SimilarSchoolsSection`, and pass +`similarSchools={similarSchools}` to both `` and +``. + +- [ ] **Step 6: Run the full frontend suite** + +Run (from `nextjs-app/`): +```sh +npm run typecheck && npm test -- --runInBand +``` +Expected: PASS on both. Every existing caller of `buildNavItems` / +`buildSecondaryNavItems` now needs `hasSimilarSchools`; a typecheck failure here +is a caller you have not updated yet. + +- [ ] **Step 7: Commit** + +```bash +git add nextjs-app/lib/schoolSections.ts nextjs-app/components/school/PrimarySchoolSections.tsx nextjs-app/components/school/SecondarySchoolSections.tsx "nextjs-app/app/(frontend)/school/[slug]/page.tsx" nextjs-app/__tests__/lib/schoolSections.test.ts +git commit -m "feat(web): render similar schools on both detail templates + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: Journey and documentation + +**Files:** +- Modify: `e2e/tests/journeys.spec.ts` +- Modify: `docs/ARCHITECTURE.md` + +**Interfaces:** +- Consumes: the rendered section from Task 4. +- Produces: nothing. + +- [ ] **Step 1: Write the journey** + +Append to `e2e/tests/journeys.spec.ts`, following the file's existing +convention of asserting stable invariants rather than exact numbers: + +```ts +/** + * Similar schools nearby. + * + * The section is absent by design where fewer than two schools qualify, and the + * arrows are absent where three cards fit, so this walks from a search hit to a + * school page and asserts each part of the contract only where it applies. + * + * Two things here cannot be tested anywhere else: the arrows' disabled state, + * which jsdom cannot measure because it has no layout, and the scroll position + * surviving a selection, which is DOM state rather than React state. + */ +test('similar schools link on to other schools and into compare', async ({ page }) => { + await searchByName(page, 'Primary'); + await schoolLinks(page).first().click(); + await page.waitForURL(/\/school\//); + + const section = page.locator('#similar'); + if ((await section.count()) === 0) { + test.skip(true, 'No qualifying similar schools for this school'); + } + + // Every card is a real link to another school page — including the ones + // behind the arrows, which is the whole reason this is a scroller and not a + // paginated widget. + const links = section.locator('a[href^="/school/"]'); + const linkCount = await links.count(); + expect(linkCount).toBeGreaterThanOrEqual(2); + expect(linkCount).toBeLessThanOrEqual(6); + const href = await links.first().getAttribute('href'); + expect(href).toMatch(/^\/school\/\d{6}-/); + + await expect(section.getByText(/miles away/).first()).toBeVisible(); + + // The carousel, where this school had more than three matches. jsdom cannot + // measure a row, so this is the only place the arrows are really exercised. + const forward = section.getByRole('button', { name: 'More schools' }); + if (await forward.count()) { + const back = section.getByRole('button', { name: 'Previous schools' }); + await expect(back).toBeDisabled(); + + const scroller = section.locator('ul[role="group"]'); + await forward.click(); + await expect.poll( + () => scroller.evaluate((node: HTMLElement) => node.scrollLeft), + ).toBeGreaterThan(8); + await expect(back).toBeEnabled(); + } + + // The compare hand-off, and the row must not jump back to the start when the + // footer re-renders underneath it. + const scroller = section.locator('ul').first(); + const offsetBefore = await scroller.evaluate((node: HTMLElement) => node.scrollLeft); + await section.getByRole('button', { name: /Add to compare/ }).first().click(); + await expect( + section.getByRole('button', { name: /Added to compare/ }).first(), + ).toBeVisible(); + expect( + await scroller.evaluate((node: HTMLElement) => node.scrollLeft), + ).toBe(offsetBefore); +}); +``` + +- [ ] **Step 2: Add the mobile journey** + +Append to `e2e/tests/journeys.spec.ts`, after the journey above: + +```ts +/** + * The section at MOBILE.md's three reference widths. + * + * MOBILE.md asks for exactly this check and records that it was not written + * because "Playwright isn't currently in the project dependency set". That is + * no longer true — this suite is Playwright — so the check exists now, scoped + * to the page this feature touches. + */ +for (const width of [360, 390, 430]) { + test(`similar schools survives a ${width}px viewport`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await searchByName(page, 'Primary'); + await schoolLinks(page).first().click(); + await page.waitForURL(/\/school\//); + + const section = page.locator('#similar'); + if ((await section.count()) === 0) { + test.skip(true, 'No qualifying similar schools for this school'); + } + + // 1. Nothing bleeds past the right edge. + expect( + await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth), + ).toBe(0); + + // 2. No arrows on touch widths — swiping does the job, and they would take + // 96px from a 328px card. + await expect(section.getByRole('button', { name: 'More schools' })).toHaveCount(0); + + // 3. Every tap target in the section clears 44px. A card title's own box is + // shorter, but its hit area is the whole card via ::after. + const failing = await section.evaluate((root: HTMLElement) => + Array.from(root.querySelectorAll('a, button')) + .filter((el) => (el as HTMLElement).offsetParent) + .map((el) => { + const card = el.closest('li'); + const box = el.matches('h3 a') && card + ? card.getBoundingClientRect() + : el.getBoundingClientRect(); + return { text: (el as HTMLElement).innerText.trim().slice(0, 24), w: box.width, h: box.height }; + }) + .filter((o) => o.w < 44 || o.h < 44), + ); + expect(failing).toEqual([]); + }); +} +``` + +- [ ] **Step 3: Verify the three reference widths by hand** + +MOBILE.md requires this before any PR that touches user-visible UI, and it is +the check that caught the arrows crushing the lede at 360px in the first place. + +Do not start the app for this. Open `mockups/similar-schools-nearby.html`, which +carries the same stylesheet rules, and run MOBILE.md's own probes at 360, 390 +and 430px: + +```js +// 1. No horizontal overflow — must be 0 at each width. +document.documentElement.scrollWidth - innerWidth + +// 2. Every interactive element ≥44×44px. A card title reports a short box but +// its hit area is the whole card via ::after, so measure the card for those. +Array.from(document.querySelector('.card').querySelectorAll('a, button')) + .filter((el) => el.offsetParent) + .map((el) => { + const card = el.closest('.school'); + const box = el.matches('h3 a') && card + ? card.getBoundingClientRect() + : el.getBoundingClientRect(); + return { t: el.innerText.trim().slice(0, 24), w: box.width, h: box.height }; + }) + .filter((o) => o.w < 44 || o.h < 44) + +// 3. No arrows below 640px, and the fade present until the end of the travel. +document.querySelector('.arrows')?.offsetParent +getComputedStyle(document.querySelector('.scroller')).maskImage +``` + +Expected: `0` overflow, an empty array of failing targets, no visible arrows, +and a mask that is present at rest and `none` once `data-at-end="true"`. + +- [ ] **Step 4: Note the staging gate** + +Do **not** try to run this journey locally against a dev server. On this +project the E2E gate runs against staging *after* merge, so this journey is not +provable in the PR checks. Verify the PR on the unit suites, and check the +post-merge staging run. + +- [ ] **Step 5: Document the section** + +In `docs/ARCHITECTURE.md`, under "Frontend boundaries", after the sentence about +`components/school/`, add: + +```markdown +The similar-schools section is selected in `backend/similar_schools.py` — hard +filters that never relax (phase, provision, selectivity, gender) and soft +preferences that do (religious character, then gender exactness) — and served on +`/api/schools/{urn}`. Its selection rules are presentation logic, deliberately +kept out of `marts.*` so they can be tuned by deploy rather than by pipeline run. +``` + +- [ ] **Step 6: Run every check before the PR** + +Run: +```sh +/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests pipeline/tests scripts/ci/tests -q +cd nextjs-app && npm run typecheck && npm test -- --runInBand +``` +Expected: PASS on all three. + +- [ ] **Step 7: Commit and open the PR** + +```bash +git add e2e/tests/journeys.spec.ts docs/ARCHITECTURE.md +git commit -m "test(e2e): cover the similar-schools section, compare hand-off and mobile widths + +Co-Authored-By: Claude Opus 5 " +git push -u origin feat/similar-schools-nearby +``` + +Open a PR against `main` with passing checks. Do not push to `main` directly and +do not trigger the promotion workflow. diff --git a/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md new file mode 100644 index 0000000..6f87035 --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md @@ -0,0 +1,417 @@ +# Similar Schools Nearby — Design + +**Date:** 2026-09-21 +**Status:** awaiting review +**Scope:** school detail pages, both phase templates + +## Goal + +Give a school detail page an answer to the question every reader arrives with +after the results tables: *and what else is around here?* + +Today a school page links outward to its place pages through +`components/school/NearbyPlaces.tsx` and nowhere else. It never links to another +school. This section adds that edge — up to six nearby schools of the same phase +and a comparable intake, three at a time in a carousel, each a crawlable link and +each addable to the comparison basket in one click. + +Mockup, with all three tier states live in both themes: + + +Source of the same page in the repo: `mockups/similar-schools-nearby.html`. + +## The constraint that shapes everything + +**A nearby school is not automatically a comparable school.** + +The section's whole value is that a reader treats what it shows as a shortlist. +That makes every card an implicit claim that the school is a realistic +alternative, and there are three ways that claim goes wrong: + +1. A **selective** school beside a non-selective one. Their intakes are + different by construction, so putting their Attainment 8 figures side by side + invites a conclusion the data cannot support. +2. A **special school, PRU or AP** beside a mainstream school. This is the same + error PR #70 fixed for the England benchmark, where Greenmead (URN 101099) + rendered "0% — 62 below England". +3. A **single-sex** school of the opposite sex. Not a weak match — not an option + at all. + +So the design separates two kinds of rule, and never confuses them: + +- **Hard filters** encode the claims above. They are never relaxed, at any + distance, even if that means the section does not render. +- **Soft preferences** describe how closely the intake resembles this school's. + They relax in tiers, and the card's own text always states what survived. + +Everything below follows from that split. + +## Selection algorithm + +A backend helper, `_similar_schools_payload(urn)` in `backend/app.py`, modelled +on the existing `_places_payload(urn)` and operating on the cached +`load_latest_school_data()` frame — one row per URN, already carrying +`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`, +`admissions_policy`, `school_type` and `status`. + +### Hard filters + +| Filter | Rule | +|---|---| +| Self | `urn` is excluded | +| Status | GIAS status must be open | +| Coordinates | both `latitude` and `longitude` present on both schools | +| Phase | same phase group via the existing `PHASE_GROUPS` map | +| Provision | special/PRU/AP match only each other | +| Selectivity | selective matches selective; non-selective matches non-selective | +| Gender | Boys never matches Girls; Mixed is compatible with both | + +`PHASE_GROUPS` is reused rather than re-derived so an all-through school is +offered correctly on both the primary and secondary sides, exactly as it already +behaves in search. + +The provision filter needs a backend counterpart to the frontend's +`isSpecialSchool()` in `nextjs-app/lib/utils.ts:897`, reading the same GIAS +establishment types through `backend/gias_codes.py`. The two must agree: a +school the frontend treats as special for benchmarking but the backend treats as +mainstream for matching would be dropped from its own England comparison and +then offered as a peer to a mainstream school on the next page along. + +**Up to six cards, three visible.** Six is a cap, not a quota: the section shows +every school that qualifies at the tiers it used, up to six. Three fit the row, +and the rest are reached with the carousel arrows. Two is the minimum that +renders at all. + +### Soft preferences, relaxed in tiers + +| Tier | Additionally requires | Radius | +|---|---|---| +| 1 | exact gender equality **and** same religious character | 3 miles | +| 2 | exact gender equality | 5 miles | +| 3 | nothing beyond the hard filters | 10 miles | + +**Tiers relax to reach a usable set, never to fill the last slots.** + +Work down the tiers until the schools found so far reach three. Call the tier +that got there T. The section then shows up to six schools drawn from tiers 1 +to T, nearest first — and does not open tier T+1 merely because six slots are +not yet full. + +Worked through: + +| Qualifying | T | Shown | +|---|---|---| +| 14 at tier 1 | 1 | the 6 nearest tier-1 schools | +| 4 at tier 1 | 1 | all 4 — tier 2 is never opened | +| 2 at tier 1, 7 more at tier 2 | 2 | the 6 nearest of those 9 | +| 2 at tier 1, 1 at tier 2 | 2 | all 3 | +| 2 across all three tiers | 3 | both, since 2 is the minimum | + +Without that stopping rule, a cap of six would reliably drag in tier-3 schools +ten miles away to fill a row that three good matches had already earned. The old +cap of three hid this; six exposes it, which is why the rule is stated rather +than left to the loop. + +**Faith relaxes before gender.** A faith mismatch changes the character of a +school; a gender mismatch can mean the school is not available to the reader's +child at all. Ordering them the other way would fill the section with schools +that cannot be applied to. + +### Two decisions that are easy to get wrong later + +**Selected by tier, displayed by distance.** Tier decides *which* three schools +earn a slot. The rendered order is then distance ascending, because "nearby" is +the promise in the heading and a reader scanning the row reads the first card as +the closest. A tier-2 school at 0.4 miles therefore appears above a tier-1 +school at 2.9 miles, and the chips explain the difference in match quality. + +**Past the sixth school, the rest are dropped without a count.** In inner +London dozens clear tier 1, and a parent there will notice three is not the +neighbourhood — hence six. Beyond that the section does not try to be the list: +`NearbyPlaces` sits directly beneath and already leads to the place pages, which +are built for browsing a full set and which the school page exists to feed. + +**Fewer than two results renders nothing.** Not an empty state, not a single +lonely card, not padding with schools that failed the hard filters. The section +is absent, the nav item is absent, and the page is unchanged from today. A page +with one weak match is better off without the section than with it. + +### Distance + +Straight-line, from the vectorised haversine already used for postcode search at +`backend/app.py:831`, computed over the ~27k-row frame in numpy. Reported to one +decimal place in miles, consistent with the rest of the site. + +Straight-line distance is not road distance and is not measured from the +reader's home. The section says so in its disclosure rather than leaving the +reader to assume otherwise. + +## API + +`/api/schools/{urn}` gains a `similar_schools` array. Each row: + +| Field | Notes | +|---|---| +| `urn` | for the link and the compare basket | +| `school_name` | link text | +| `distance_miles` | one decimal place | +| `school_type` | GIAS type, translated, for the card's meta line | +| `age_range` | for the meta line | +| `shared` | the chip strings the tier actually justifies — see below | +| `tier` | 1, 2 or 3 — drives the lede's wording and the chip styling | +| `metric_value` | the phase-appropriate headline figure, or null | +| `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below | +| `metric_year` | the year the figure is from | + +The metric follows the phase side the school was *matched* on, not the +neighbour's own phase, so a row of cards never mixes two scales. The secondary +side uses `attainment_8_score`; the primary side uses `rwm_expected_pct`. Where +the neighbour has no value for that key, the card reads "Not published" rather +than falling back to the other key. + +Which side a school takes is decided once, in +`similar_schools.is_secondary_phase`, by membership of `PHASE_GROUPS["secondary"]` +minus all-through — never by testing for the substring "secondary", which misses +`16 plus` (GIAS phase 6) and hands a sixth-form college the primary bucket. +All-through is the exception in the other direction: `PHASE_GROUPS` lists it on +both sides, but it takes the primary metric. + +This is usually the same thing as "the template the page renders", but not +always. `computeSchoolFlags` decides the template with that same substring test, +so a `16 plus` school renders `PrimarySchoolSections` while being matched — +correctly — against secondaries. The section therefore takes its lede noun from +the school's own phase rather than from its template, or it would print "Other +primary schools near " above a row of secondaries. + +`tier` is carried explicitly rather than inferred from the contents of +`shared`, because the frontend needs it for two separate decisions — whether the +lede may claim a similar intake, and whether a chip renders as a brand-tinted +fill or a muted outline — and inferring it from chip count would couple those +decisions to the copy. + +Up to six rows of roughly 130 bytes each. It rides in the existing detail payload +rather than a new endpoint because the page already makes exactly one server +fetch for its data, and `/school/[slug]` regenerates at most weekly +(`revalidate = 604800`), so the per-request cost is paid once per school per +week. + +**The key is absent, not null, on a backend that does not have this code.** The +frontend treats absent and empty identically, which is what allowed +`NearbyPlaces` to ship without a lockstep deploy of the two images. + +`shared` is computed on the backend beside the tier that produced it, not +re-derived on the frontend. Deriving it twice is how a card comes to claim a +match the selection did not actually make. + +## Frontend + +### Components + +`components/school/SimilarSchoolsSection.tsx` — a server component wrapped in +the shared `Section` shell from `sectionShared.tsx`. It renders the heading, +the lede, the card grid, the footer CTA and one caption line. Every +card's title is an `
    ` to the school's canonical slug URL via `schoolUrl()`. + +`components/school/AddToCompareButton.tsx` — calls `addSchool` from +`ComparisonProvider` and reports the selection with a `from: 'similar_schools'` +attribution, mirroring `addSchoolFromSearch` in `HomeView.tsx:442`. + +`components/school/SimilarSchoolsCarousel.tsx` — the scroller and its arrows. It +takes the server-rendered cards as `children` and the server-rendered heading and +lede as a `header` prop, so those stay server components while the client +component owns only the ref, the scroll handler and the arrows' disabled state. + +The split matters: the links — the part with SEO value and the part that must +work without JavaScript — are server-rendered into the initial HTML, and only +the basket interaction and the arrows are hydrated. + +### The carousel + +**Every card is in the initial HTML.** The arrows scroll a list; they never swap +a view. Six `` elements are in the markup whether or not anything is +hydrated, which is the whole reason the section exists — a paginated widget that +mounts cards on click would put four of the six links beyond a crawler and +beyond a reader with no JavaScript. + +So the scroller is a plain overflowing `