fix: order nearby schools by distance, not by how alike they are #151
No files matched your search
+12
-15
@@ -41,7 +41,7 @@ from .data_loader import get_data_info as get_db_info
|
|||||||
from . import flags
|
from . import flags
|
||||||
from .places import build_place_index, build_place_registry, places_for_urn
|
from .places import build_place_index, build_place_registry, places_for_urn
|
||||||
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, 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 .nearby_schools import select_nearby
|
||||||
from .utils import clean_for_json, convert_to_native
|
from .utils import clean_for_json, convert_to_native
|
||||||
|
|
||||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
@@ -266,25 +266,23 @@ def _places_payload(urn: int) -> list[dict]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]:
|
def _nearby_schools_payload(urn: int) -> list[dict]:
|
||||||
"""Nearby schools this page may offer as alternatives.
|
"""The nearest eligible schools this page may offer, closest first.
|
||||||
|
|
||||||
|
Phase and reach are read from the school's own row inside select_nearby,
|
||||||
|
so nothing here can hand it a phase that disagrees with the data.
|
||||||
|
|
||||||
Wrapped: a failure in selection must never 500 a page that is otherwise
|
Wrapped: a failure in selection must never 500 a page that is otherwise
|
||||||
complete, which is the posture get_supplementary_data already takes. The
|
complete, which is the posture get_supplementary_data already takes. The
|
||||||
section simply does not render.
|
section simply does not render.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Decided in similar_schools, beside the PHASE_GROUPS bucket it selects
|
return select_nearby(load_latest_school_data(), int(urn))
|
||||||
# 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:
|
except Exception:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).exception(
|
logging.getLogger(__name__).exception(
|
||||||
"Similar schools selection failed for urn=%s", urn
|
"Nearby schools selection failed for urn=%s", urn
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -999,11 +997,10 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
# and authority both fall below the publish threshold has nowhere to
|
# and authority both fall below the publish threshold has nowhere to
|
||||||
# point, and the page renders without the module.
|
# point, and the page renders without the module.
|
||||||
"places": _places_payload(urn),
|
"places": _places_payload(urn),
|
||||||
# Nearby schools of the same phase and a comparable intake. Always
|
# The nearest eligible schools, closest first. Always present on a
|
||||||
# present on a build with this code; the frontend treats absent and
|
# build with this code; the frontend treats absent and empty
|
||||||
# empty identically, which is what lets the two images deploy
|
# identically, which is what lets the two images deploy independently.
|
||||||
# independently.
|
"nearby_schools": _nearby_schools_payload(urn),
|
||||||
"similar_schools": _similar_schools_payload(urn, latest.get("phase")),
|
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"""Which nearby schools a detail page may offer as alternatives.
|
"""Which nearby schools a detail page may offer as alternatives.
|
||||||
|
|
||||||
Two kinds of rule, and they are not interchangeable.
|
HARD FILTERS decide eligibility, and 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. They never relax, at any distance, even
|
||||||
|
where that means the section does not render at all.
|
||||||
|
|
||||||
HARD FILTERS encode claims the section is not allowed to make. A selective
|
DISTANCE decides the order, and nothing else does.
|
||||||
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
|
An earlier version ranked by intake similarity first and used distance only as
|
||||||
relax in tiers, and every card reports the tier that actually took it so the
|
a tiebreak. That put a Catholic school 2.9 miles away above the community
|
||||||
page can say what is shared rather than implying more. They relax only far
|
school 0.3 miles down the road, and — because the row filled from the best tier
|
||||||
enough to reach a usable set, never far enough to fill the last of the slots.
|
before widening — filled all six slots with faith matches while omitting every
|
||||||
|
school a parent could actually walk to. For a primary, a school that far is not
|
||||||
|
a weaker option; it is not an option. Distance is a constraint and intake is a
|
||||||
|
preference, and the ranking now says so.
|
||||||
|
|
||||||
|
Similarity survives as `shared`: what a candidate genuinely has in common with
|
||||||
|
this school, reported on its card, so a reader applies their own weighting
|
||||||
|
instead of having ours applied for them.
|
||||||
|
|
||||||
Pure functions over a DataFrame: no I/O, no FastAPI, no database.
|
Pure functions over a DataFrame: no I/O, no FastAPI, no database.
|
||||||
"""
|
"""
|
||||||
@@ -25,19 +32,21 @@ import pandas as pd
|
|||||||
|
|
||||||
from .schemas import PHASE_GROUPS
|
from .schemas import PHASE_GROUPS
|
||||||
|
|
||||||
# A cap, not a quota: the section shows everything that qualified at the tiers
|
# Three fit the row; the rest are behind the carousel arrows.
|
||||||
# it used, up to this many. Three fit the row; the rest are behind the arrows.
|
|
||||||
MAX_SCHOOLS = 6
|
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
|
MINIMUM = 2
|
||||||
|
|
||||||
# (tier, radius in miles). Faith relaxes before gender: a faith mismatch
|
# How far the section will reach, in miles, when nothing closer exists.
|
||||||
# changes the character of a school, while a gender mismatch can mean the
|
#
|
||||||
# school is not available to this reader's child at all.
|
# A sanity bound rather than a target: ordering by distance already handles
|
||||||
TIERS: tuple[tuple[int, float], ...] = ((1, 3.0), (2, 5.0), (3, 10.0))
|
# density, so a school in a dense area fills all six slots inside a mile and
|
||||||
|
# never sees this. It decides one thing — what happens where the area is
|
||||||
|
# sparse — and the answer differs by phase because catchments do. Primary
|
||||||
|
# catchments are routinely under a mile; beyond two, a primary is not a weaker
|
||||||
|
# option but not an option, and no section is the honest answer.
|
||||||
|
PRIMARY_RADIUS_MILES = 2.0
|
||||||
|
SECONDARY_RADIUS_MILES = 6.0
|
||||||
|
POST16_RADIUS_MILES = 10.0
|
||||||
|
|
||||||
EARTH_RADIUS_MILES = 3958.8
|
EARTH_RADIUS_MILES = 3958.8
|
||||||
|
|
||||||
@@ -80,15 +89,6 @@ def genders_compatible(a: str | None, b: str | None) -> bool:
|
|||||||
return not (left in single and right in single and left != right)
|
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:
|
def is_secondary_phase(phase: str | None) -> bool:
|
||||||
"""Whether this phase takes the secondary side: secondary group membership,
|
"""Whether this phase takes the secondary side: secondary group membership,
|
||||||
minus all-through.
|
minus all-through.
|
||||||
@@ -107,6 +107,13 @@ def is_secondary_phase(phase: str | None) -> bool:
|
|||||||
return text != "all-through" and text in PHASE_GROUPS["secondary"]
|
return text != "all-through" and text in PHASE_GROUPS["secondary"]
|
||||||
|
|
||||||
|
|
||||||
|
def radius_miles(phase: str | None) -> float:
|
||||||
|
"""How far this phase's section will reach when nothing closer exists."""
|
||||||
|
if (phase or "").strip().lower() == "16 plus":
|
||||||
|
return POST16_RADIUS_MILES
|
||||||
|
return SECONDARY_RADIUS_MILES if is_secondary_phase(phase) else PRIMARY_RADIUS_MILES
|
||||||
|
|
||||||
|
|
||||||
def _phase_group(is_secondary: bool) -> set[str]:
|
def _phase_group(is_secondary: bool) -> set[str]:
|
||||||
return PHASE_GROUPS["secondary" if is_secondary else "primary"]
|
return PHASE_GROUPS["secondary" if is_secondary else "primary"]
|
||||||
|
|
||||||
@@ -144,26 +151,45 @@ def _mask(series: pd.Series, predicate) -> pd.Series:
|
|||||||
return pd.Series([predicate(value) for value in series], index=series.index, dtype=bool)
|
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]:
|
def _shared(subject: pd.Series, candidate: pd.Series, is_secondary: bool) -> list[str]:
|
||||||
if tier >= 3:
|
"""What this candidate genuinely has in common with the subject.
|
||||||
return [phase_label(candidate.get("phase"))]
|
|
||||||
|
Empty is a real answer, and renders no chips at all. A card claiming a
|
||||||
|
shared characteristic it does not have would be worse than a bare one —
|
||||||
|
and since these no longer affect the order, an empty list costs the school
|
||||||
|
nothing but its place in the row, which distance already decided.
|
||||||
|
"""
|
||||||
|
shared: list[str] = []
|
||||||
|
|
||||||
|
gender = str(subject.get("gender") or "").strip()
|
||||||
|
if gender and str(candidate.get("gender") or "").strip().lower() == gender.lower():
|
||||||
|
shared.append(gender)
|
||||||
|
|
||||||
chips = [str(subject.get("gender") or "").strip()]
|
|
||||||
if is_secondary:
|
if is_secondary:
|
||||||
policy = (candidate.get("admissions_policy") or "").strip()
|
policy = str(candidate.get("admissions_policy") or "").strip()
|
||||||
if policy and policy.lower() not in {"not applicable", "unknown"}:
|
subject_policy = str(subject.get("admissions_policy") or "").strip()
|
||||||
chips.append(policy)
|
if (
|
||||||
if tier == 1:
|
policy
|
||||||
chips.append(faith_label(candidate.get("religious_denomination")))
|
and policy.lower() == subject_policy.lower()
|
||||||
return [chip for chip in chips if chip]
|
and policy.lower() not in {"not applicable", "unknown"}
|
||||||
|
):
|
||||||
|
shared.append(policy)
|
||||||
|
|
||||||
|
if faith_key(candidate.get("religious_denomination")) == faith_key(
|
||||||
|
subject.get("religious_denomination")
|
||||||
|
):
|
||||||
|
shared.append(faith_label(candidate.get("religious_denomination")))
|
||||||
|
|
||||||
|
return shared
|
||||||
|
|
||||||
|
|
||||||
def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]:
|
def select_nearby(frame: pd.DataFrame, urn: int) -> list[dict]:
|
||||||
"""Up to MAX_SCHOOLS nearby schools this page may offer, or [] below MINIMUM.
|
"""The nearest eligible schools, closest first — at most MAX_SCHOOLS, and
|
||||||
|
none at all below MINIMUM.
|
||||||
|
|
||||||
Selected by tier, displayed by distance: the tier decides which schools
|
The phase is read from the subject's own row rather than passed in, so a
|
||||||
earn a slot, and the render order is then closest-first, because "nearby"
|
caller cannot hand this a phase that disagrees with the data it selects
|
||||||
is the promise in the heading.
|
from.
|
||||||
"""
|
"""
|
||||||
subject_rows = frame[frame["urn"] == urn]
|
subject_rows = frame[frame["urn"] == urn]
|
||||||
if subject_rows.empty:
|
if subject_rows.empty:
|
||||||
@@ -174,6 +200,9 @@ def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[di
|
|||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
phase = subject.get("phase")
|
||||||
|
is_secondary = is_secondary_phase(phase)
|
||||||
|
reach = radius_miles(phase)
|
||||||
metric_key = "attainment_8_score" if is_secondary else "rwm_expected_pct"
|
metric_key = "attainment_8_score" if is_secondary else "rwm_expected_pct"
|
||||||
|
|
||||||
candidates = frame[frame["urn"] != urn].copy()
|
candidates = frame[frame["urn"] != urn].copy()
|
||||||
@@ -208,42 +237,12 @@ def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[di
|
|||||||
lat, lon, candidates["latitude"].values, candidates["longitude"].values
|
lat, lon, candidates["latitude"].values, candidates["longitude"].values
|
||||||
).round(1)
|
).round(1)
|
||||||
|
|
||||||
# ── Soft preferences, in tiers ──────────────────────────────────────
|
# ── Nearest first, and nothing else has a say ───────────────────────
|
||||||
subject_faith = faith_key(subject.get("religious_denomination"))
|
within = candidates[candidates["distance_miles"] <= reach]
|
||||||
subject_gender_key = (subject_gender or "").strip().lower()
|
if len(within) < MINIMUM:
|
||||||
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 []
|
return []
|
||||||
|
|
||||||
selected = sorted(
|
selected = within.sort_values(["distance_miles", "urn"]).head(MAX_SCHOOLS)
|
||||||
picked.values(), key=lambda pair: float(pair[1]["distance_miles"])
|
|
||||||
)[:MAX_SCHOOLS]
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"urn": int(row["urn"]),
|
"urn": int(row["urn"]),
|
||||||
@@ -251,11 +250,10 @@ def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[di
|
|||||||
"distance_miles": float(row["distance_miles"]),
|
"distance_miles": float(row["distance_miles"]),
|
||||||
"school_type": _native(row.get("school_type")),
|
"school_type": _native(row.get("school_type")),
|
||||||
"age_range": _native(row.get("age_range")),
|
"age_range": _native(row.get("age_range")),
|
||||||
"shared": _chips(subject, row, tier, is_secondary),
|
"shared": _shared(subject, row, is_secondary),
|
||||||
"tier": tier,
|
|
||||||
"metric_value": _native(row.get(metric_key)),
|
"metric_value": _native(row.get(metric_key)),
|
||||||
"metric_key": metric_key,
|
"metric_key": metric_key,
|
||||||
"metric_year": _native(row.get("year")),
|
"metric_year": _native(row.get("year")),
|
||||||
}
|
}
|
||||||
for tier, row in selected
|
for _, row in selected.iterrows()
|
||||||
]
|
]
|
||||||
+1
-1
@@ -536,7 +536,7 @@ RANKING_COLUMNS = [
|
|||||||
# include. All-through schools appear in both primary and secondary results,
|
# 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.
|
# 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
|
# Lives here rather than in app.py because nearby_schools.py needs it too, and
|
||||||
# importing app from there would be a cycle.
|
# importing app from there would be a cycle.
|
||||||
PHASE_GROUPS: dict[str, set[str]] = {
|
PHASE_GROUPS: dict[str, set[str]] = {
|
||||||
"primary": {"primary", "middle deemed primary", "all-through"},
|
"primary": {"primary", "middle deemed primary", "all-through"},
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
"""Selection rules for the "similar schools nearby" section.
|
"""Selection rules for the nearby-schools section.
|
||||||
|
|
||||||
The hard filters encode claims the section is not allowed to make — that a
|
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
|
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
|
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
|
for a Boys school's reader. They decide who is eligible.
|
||||||
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.
|
Distance decides the order, and nothing else does. An earlier version ranked by
|
||||||
|
intake similarity first, which put a Catholic school 2.9 miles away above the
|
||||||
|
community school 0.3 miles down the road — for a primary, a school that far is
|
||||||
|
not a weaker option, it is not an option. Similarity is now reported on the
|
||||||
|
card and never reorders the row.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from backend.similar_schools import is_secondary_phase, select_similar
|
from backend.nearby_schools import (
|
||||||
|
is_secondary_phase,
|
||||||
|
radius_miles,
|
||||||
|
select_nearby,
|
||||||
|
)
|
||||||
|
|
||||||
# Roughly 0.7 miles apart in latitude at this longitude.
|
|
||||||
BASE_LAT, BASE_LON = 51.5000, -0.1000
|
BASE_LAT, BASE_LON = 51.5000, -0.1000
|
||||||
|
|
||||||
|
|
||||||
@@ -48,16 +55,68 @@ def _at(miles):
|
|||||||
return BASE_LAT + miles / 69.0
|
return BASE_LAT + miles / 69.0
|
||||||
|
|
||||||
|
|
||||||
def test_returns_nearest_same_phase_schools():
|
# ---------------------------------------------------------------------------
|
||||||
|
# Order: distance, and only distance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_returns_nearest_first():
|
||||||
frame = _frame(
|
frame = _frame(
|
||||||
_row(100001, "Subject"),
|
_row(100001, "Subject"),
|
||||||
_row(100002, "Near", latitude=_at(0.5)),
|
_row(100002, "Mid", latitude=_at(1.0)),
|
||||||
_row(100003, "Mid", latitude=_at(1.0)),
|
_row(100003, "Near", latitude=_at(0.4)),
|
||||||
_row(100004, "Far", latitude=_at(2.0)),
|
_row(100004, "Far", latitude=_at(1.8)),
|
||||||
)
|
)
|
||||||
result = select_similar(frame, 100001, is_secondary=False)
|
result = select_nearby(frame, 100001)
|
||||||
assert [s["urn"] for s in result] == [100002, 100003, 100004]
|
assert [s["urn"] for s in result] == [100003, 100002, 100004]
|
||||||
assert result[0]["distance_miles"] == 0.5
|
assert result[0]["distance_miles"] == 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_faith_match_never_outranks_a_closer_school():
|
||||||
|
"""The reported defect. A Catholic primary surrounded by Catholic primaries
|
||||||
|
showed six of them and omitted the community school down the road."""
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "St Jude's RC Primary", religious_denomination="Roman Catholic"),
|
||||||
|
_row(100002, "Elm Grove Primary", religious_denomination="None", latitude=_at(0.3)),
|
||||||
|
_row(100003, "Holy Cross RC", religious_denomination="Roman Catholic", latitude=_at(0.8)),
|
||||||
|
_row(100004, "Sacred Heart RC", religious_denomination="Roman Catholic", latitude=_at(1.2)),
|
||||||
|
_row(100005, "St Peter's RC", religious_denomination="Roman Catholic", latitude=_at(1.6)),
|
||||||
|
)
|
||||||
|
result = select_nearby(frame, 100001)
|
||||||
|
assert result[0]["urn"] == 100002, "the nearest school leads, whatever its intake"
|
||||||
|
assert [s["distance_miles"] for s in result] == sorted(s["distance_miles"] for s in result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_nearest_eligible_school_is_always_shown():
|
||||||
|
"""Whatever else changes, a section titled "nearby" cannot omit the nearest
|
||||||
|
school while listing one four times further away."""
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"),
|
||||||
|
_row(100002, "Nearest", gender="Mixed", religious_denomination="None", latitude=_at(0.2)),
|
||||||
|
*[
|
||||||
|
_row(100010 + n, f"Match {n}", gender="Boys",
|
||||||
|
religious_denomination="Roman Catholic", latitude=_at(0.9 + n * 0.1))
|
||||||
|
for n in range(6)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert select_nearby(frame, 100001)[0]["urn"] == 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_nearby(frame, 100001)
|
||||||
|
assert len(result) == 6
|
||||||
|
assert 100016 not in {s["urn"] for s in result}, "the seventh-nearest is the one dropped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fewer_than_two_matches_returns_empty():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject"),
|
||||||
|
_row(100002, "Only neighbour", latitude=_at(0.5)),
|
||||||
|
)
|
||||||
|
assert select_nearby(frame, 100001) == []
|
||||||
|
|
||||||
|
|
||||||
def test_excludes_the_subject_school():
|
def test_excludes_the_subject_school():
|
||||||
@@ -66,19 +125,66 @@ def test_excludes_the_subject_school():
|
|||||||
_row(100002, "A", latitude=_at(0.5)),
|
_row(100002, "A", latitude=_at(0.5)),
|
||||||
_row(100003, "B", latitude=_at(0.6)),
|
_row(100003, "B", latitude=_at(0.6)),
|
||||||
)
|
)
|
||||||
assert 100001 not in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)}
|
assert 100001 not in {s["urn"] for s in select_nearby(frame, 100001)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_school_is_never_listed_twice():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject"),
|
||||||
|
_row(100002, "A", latitude=_at(0.5)),
|
||||||
|
_row(100003, "B", latitude=_at(0.6)),
|
||||||
|
)
|
||||||
|
result = select_nearby(frame, 100001)
|
||||||
|
assert len(result) == len({s["urn"] for s in result})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reach: a sanity bound, not a target
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_primary_does_not_reach_past_two_miles():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject"),
|
||||||
|
_row(100002, "Just inside", latitude=_at(1.9)),
|
||||||
|
_row(100003, "Just outside", latitude=_at(2.4)),
|
||||||
|
_row(100004, "Miles away", latitude=_at(4.0)),
|
||||||
|
)
|
||||||
|
# One inside the cap is below the minimum, so nothing renders at all —
|
||||||
|
# a primary with nothing within two miles has no nearby schools.
|
||||||
|
assert select_nearby(frame, 100001) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_secondary_reaches_further_than_primary():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject", phase="Secondary"),
|
||||||
|
_row(100002, "A", phase="Secondary", latitude=_at(3.0)),
|
||||||
|
_row(100003, "B", phase="Secondary", latitude=_at(5.5)),
|
||||||
|
)
|
||||||
|
assert {s["urn"] for s in select_nearby(frame, 100001)} == {100002, 100003}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cap_follows_the_phase():
|
||||||
|
assert radius_miles("Primary") == 2.0
|
||||||
|
assert radius_miles("Middle deemed primary") == 2.0
|
||||||
|
assert radius_miles("All-through") == 2.0
|
||||||
|
assert radius_miles("Secondary") == 6.0
|
||||||
|
assert radius_miles("Middle deemed secondary") == 6.0
|
||||||
|
# Post-16 is the phase people travel furthest for.
|
||||||
|
assert radius_miles("16 plus") == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hard filters: eligibility, never order
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_selective_never_meets_non_selective():
|
def test_selective_never_meets_non_selective():
|
||||||
frame = _frame(
|
frame = _frame(
|
||||||
_row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"),
|
_row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"),
|
||||||
_row(100002, "Comp A", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.5)),
|
_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)),
|
_row(100003, "Comp B", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.6)),
|
||||||
)
|
)
|
||||||
assert select_similar(frame, 100001, is_secondary=True) == []
|
assert select_nearby(frame, 100001) == []
|
||||||
|
assert 100001 not in {s["urn"] for s in select_nearby(frame, 100002)}
|
||||||
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():
|
def test_special_schools_match_only_each_other():
|
||||||
@@ -87,8 +193,8 @@ def test_special_schools_match_only_each_other():
|
|||||||
_row(100002, "Mainstream A", latitude=_at(0.5)),
|
_row(100002, "Mainstream A", latitude=_at(0.5)),
|
||||||
_row(100003, "Mainstream B", latitude=_at(0.6)),
|
_row(100003, "Mainstream B", latitude=_at(0.6)),
|
||||||
)
|
)
|
||||||
assert select_similar(frame, 100001, is_secondary=False) == []
|
assert select_nearby(frame, 100001) == []
|
||||||
assert select_similar(frame, 100002, is_secondary=False) == []
|
assert select_nearby(frame, 100002) == []
|
||||||
|
|
||||||
|
|
||||||
def test_boys_never_meets_girls():
|
def test_boys_never_meets_girls():
|
||||||
@@ -98,7 +204,7 @@ def test_boys_never_meets_girls():
|
|||||||
_row(100003, "Mixed School", gender="Mixed", latitude=_at(0.6)),
|
_row(100003, "Mixed School", gender="Mixed", latitude=_at(0.6)),
|
||||||
_row(100004, "Another Mixed", gender="Mixed", latitude=_at(0.7)),
|
_row(100004, "Another Mixed", gender="Mixed", latitude=_at(0.7)),
|
||||||
)
|
)
|
||||||
urns = {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)}
|
urns = {s["urn"] for s in select_nearby(frame, 100001)}
|
||||||
assert 100002 not in urns
|
assert 100002 not in urns
|
||||||
assert urns == {100003, 100004}
|
assert urns == {100003, 100004}
|
||||||
|
|
||||||
@@ -111,80 +217,7 @@ def test_closed_schools_and_missing_coordinates_are_dropped():
|
|||||||
_row(100004, "Good A", latitude=_at(0.6)),
|
_row(100004, "Good A", latitude=_at(0.6)),
|
||||||
_row(100005, "Good B", latitude=_at(0.7)),
|
_row(100005, "Good B", latitude=_at(0.7)),
|
||||||
)
|
)
|
||||||
assert {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} == {100004, 100005}
|
assert {s["urn"] for s in select_nearby(frame, 100001)} == {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():
|
def test_all_through_is_offered_on_both_phase_sides():
|
||||||
@@ -193,14 +226,14 @@ def test_all_through_is_offered_on_both_phase_sides():
|
|||||||
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
||||||
_row(100003, "Primary peer", phase="Primary", latitude=_at(0.6)),
|
_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)}
|
assert 100002 in {s["urn"] for s in select_nearby(frame, 100001)}
|
||||||
|
|
||||||
secondary = _frame(
|
secondary = _frame(
|
||||||
_row(100010, "Secondary subject", phase="Secondary"),
|
_row(100010, "Secondary subject", phase="Secondary"),
|
||||||
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
||||||
_row(100011, "Secondary peer", phase="Secondary", latitude=_at(0.6)),
|
_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)}
|
assert 100002 in {s["urn"] for s in select_nearby(secondary, 100010)}
|
||||||
|
|
||||||
|
|
||||||
def test_sixteen_plus_is_matched_against_secondary_not_primary():
|
def test_sixteen_plus_is_matched_against_secondary_not_primary():
|
||||||
@@ -212,11 +245,10 @@ def test_sixteen_plus_is_matched_against_secondary_not_primary():
|
|||||||
_row(100001, "Sixth Form College", phase="16 plus", age_range="16-19"),
|
_row(100001, "Sixth Form College", phase="16 plus", age_range="16-19"),
|
||||||
_row(100002, "Nearby Secondary", phase="Secondary", latitude=_at(0.5),
|
_row(100002, "Nearby Secondary", phase="Secondary", latitude=_at(0.5),
|
||||||
attainment_8_score=52.0),
|
attainment_8_score=52.0),
|
||||||
_row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6),
|
_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)),
|
_row(100004, "Nearby Primary", phase="Primary", latitude=_at(0.1)),
|
||||||
)
|
)
|
||||||
result = select_similar(frame, 100001, is_secondary=is_secondary_phase("16 plus"))
|
result = select_nearby(frame, 100001)
|
||||||
urns = {s["urn"] for s in result}
|
urns = {s["urn"] for s in result}
|
||||||
assert 100004 not in urns, "a primary school is not a peer for a sixth form"
|
assert 100004 not in urns, "a primary school is not a peer for a sixth form"
|
||||||
assert urns == {100002, 100003}
|
assert urns == {100002, 100003}
|
||||||
@@ -224,18 +256,20 @@ def test_sixteen_plus_is_matched_against_secondary_not_primary():
|
|||||||
|
|
||||||
|
|
||||||
def test_is_secondary_phase_agrees_with_the_phase_groups_it_selects_from():
|
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"):
|
for phase in ("Secondary", "Middle deemed secondary", "16 plus"):
|
||||||
assert is_secondary_phase(phase) is True, phase
|
assert is_secondary_phase(phase) is True, phase
|
||||||
for phase in ("Primary", "Middle deemed primary", "Nursery", "", None):
|
for phase in ("Primary", "Middle deemed primary", "Nursery", "", None):
|
||||||
assert is_secondary_phase(phase) is False, phase
|
assert is_secondary_phase(phase) is False, phase
|
||||||
# In PHASE_GROUPS an all-through school is on both sides, but it renders
|
# In PHASE_GROUPS an all-through school is on both sides, but it renders
|
||||||
# with the primary template, and the metric follows the template.
|
# with the primary template, and the metric follows the phase side.
|
||||||
assert is_secondary_phase("All-through") is False
|
assert is_secondary_phase("All-through") is False
|
||||||
|
|
||||||
|
|
||||||
def test_chips_state_only_what_the_tier_earned():
|
# ---------------------------------------------------------------------------
|
||||||
|
# What the card reports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_shared_lists_only_what_is_actually_shared():
|
||||||
frame = _frame(
|
frame = _frame(
|
||||||
_row(100001, "Subject", phase="Secondary", gender="Mixed",
|
_row(100001, "Subject", phase="Secondary", gender="Mixed",
|
||||||
religious_denomination="None", admissions_policy="Non-selective"),
|
religious_denomination="None", admissions_policy="Non-selective"),
|
||||||
@@ -244,28 +278,47 @@ def test_chips_state_only_what_the_tier_earned():
|
|||||||
_row(100003, "Faith differs", phase="Secondary", gender="Mixed",
|
_row(100003, "Faith differs", phase="Secondary", gender="Mixed",
|
||||||
religious_denomination="Church of England", admissions_policy="Non-selective", latitude=_at(0.6)),
|
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)}
|
by_urn = {s["urn"]: s for s in select_nearby(frame, 100001)}
|
||||||
assert by_urn[100002]["shared"] == ["Mixed", "Non-selective", "No religious character"]
|
assert by_urn[100002]["shared"] == ["Mixed", "Non-selective", "No religious character"]
|
||||||
assert by_urn[100003]["shared"] == ["Mixed", "Non-selective"]
|
assert by_urn[100003]["shared"] == ["Mixed", "Non-selective"]
|
||||||
|
|
||||||
|
|
||||||
def test_tier_three_chip_is_the_plain_phase():
|
def test_a_shared_faith_is_named():
|
||||||
frame = _frame(
|
frame = _frame(
|
||||||
_row(100001, "Subject", gender="Boys"),
|
_row(100001, "Subject", religious_denomination="Roman Catholic"),
|
||||||
_row(100002, "A", gender="Mixed", latitude=_at(0.5)),
|
_row(100002, "Also RC", religious_denomination="Roman Catholic", latitude=_at(0.4)),
|
||||||
_row(100003, "B", gender="Mixed", latitude=_at(0.6)),
|
_row(100003, "Secular", religious_denomination="None", latitude=_at(0.5)),
|
||||||
)
|
)
|
||||||
result = select_similar(frame, 100001, is_secondary=False)
|
by_urn = {s["urn"]: s for s in select_nearby(frame, 100001)}
|
||||||
assert all(s["shared"] == ["Primary school"] for s in result)
|
assert "Roman Catholic" in by_urn[100002]["shared"]
|
||||||
|
assert by_urn[100003]["shared"] == ["Mixed"]
|
||||||
|
|
||||||
|
|
||||||
def test_metric_follows_the_template_not_the_neighbour():
|
def test_shared_is_empty_when_nothing_is_shared():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"),
|
||||||
|
_row(100002, "A", gender="Mixed", religious_denomination="None", latitude=_at(0.4)),
|
||||||
|
_row(100003, "B", gender="Mixed", religious_denomination="Church of England", latitude=_at(0.5)),
|
||||||
|
)
|
||||||
|
assert all(s["shared"] == [] for s in select_nearby(frame, 100001))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tier_is_reported_because_there_are_no_tiers():
|
||||||
|
frame = _frame(
|
||||||
|
_row(100001, "Subject"),
|
||||||
|
_row(100002, "A", latitude=_at(0.4)),
|
||||||
|
_row(100003, "B", latitude=_at(0.5)),
|
||||||
|
)
|
||||||
|
assert all("tier" not in s for s in select_nearby(frame, 100001))
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_follows_the_phase_side_not_the_neighbour():
|
||||||
frame = _frame(
|
frame = _frame(
|
||||||
_row(100001, "Subject", phase="Secondary", attainment_8_score=50.0),
|
_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(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)),
|
_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)}
|
by_urn = {s["urn"]: s for s in select_nearby(frame, 100001)}
|
||||||
assert by_urn[100002]["metric_key"] == "attainment_8_score"
|
assert by_urn[100002]["metric_key"] == "attainment_8_score"
|
||||||
assert by_urn[100002]["metric_value"] == 52.8
|
assert by_urn[100002]["metric_value"] == 52.8
|
||||||
assert by_urn[100002]["metric_year"] == 202425
|
assert by_urn[100002]["metric_year"] == 202425
|
||||||
@@ -278,7 +331,7 @@ def test_values_are_json_safe_native_types():
|
|||||||
_row(100002, "A", latitude=_at(0.5)),
|
_row(100002, "A", latitude=_at(0.5)),
|
||||||
_row(100003, "B", latitude=_at(0.6)),
|
_row(100003, "B", latitude=_at(0.6)),
|
||||||
)
|
)
|
||||||
for school in select_similar(frame, 100001, is_secondary=False):
|
for school in select_nearby(frame, 100001):
|
||||||
assert isinstance(school["urn"], int)
|
assert isinstance(school["urn"], int)
|
||||||
assert isinstance(school["distance_miles"], float)
|
assert isinstance(school["distance_miles"], float)
|
||||||
assert not isinstance(school["metric_value"], np.generic)
|
assert not isinstance(school["metric_value"], np.generic)
|
||||||
@@ -310,10 +363,10 @@ def client(monkeypatch):
|
|||||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
def test_detail_payload_carries_similar_schools(client):
|
def test_detail_payload_carries_nearby_schools(client):
|
||||||
resp = client.get("/api/schools/100001")
|
resp = client.get("/api/schools/100001")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
similar = resp.json()["similar_schools"]
|
similar = resp.json()["nearby_schools"]
|
||||||
assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"]
|
assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"]
|
||||||
assert similar[0]["metric_key"] == "rwm_expected_pct"
|
assert similar[0]["metric_key"] == "rwm_expected_pct"
|
||||||
|
|
||||||
@@ -324,7 +377,7 @@ def test_a_failure_in_selection_does_not_break_the_page(client, monkeypatch):
|
|||||||
def _explode(*args, **kwargs):
|
def _explode(*args, **kwargs):
|
||||||
raise ValueError("selection blew up")
|
raise ValueError("selection blew up")
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, "select_similar", _explode)
|
monkeypatch.setattr(app_module, "select_nearby", _explode)
|
||||||
resp = client.get("/api/schools/100001")
|
resp = client.get("/api/schools/100001")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["similar_schools"] == []
|
assert resp.json()["nearby_schools"] == []
|
||||||
@@ -73,12 +73,12 @@ There is no SWR dependency. Leaflet maps are loaded through dynamic wrappers;
|
|||||||
Chart.js renders performance and comparison charts.
|
Chart.js renders performance and comparison charts.
|
||||||
|
|
||||||
`components/school/` contains detail sections, with section decisions and data
|
`components/school/` contains detail sections, with section decisions and data
|
||||||
preparation in `lib/schoolSections.ts`. The similar-schools section is selected
|
preparation in `lib/schoolSections.ts`. The nearby-schools section is selected in
|
||||||
in `backend/similar_schools.py` — hard filters that never relax (phase,
|
`backend/nearby_schools.py` — hard filters decide eligibility (phase, provision,
|
||||||
provision, selectivity, gender) and soft preferences that do (religious
|
selectivity, gender) and distance alone decides the order, capped per phase —
|
||||||
character, then gender exactness) — and served on `/api/schools/{urn}`. Its
|
and served on `/api/schools/{urn}`. Its rules are presentation logic,
|
||||||
rules are presentation logic, deliberately kept out of `marts.*` so they can be
|
deliberately kept out of `marts.*` so they can be tuned by deploy rather than by
|
||||||
tuned by deploy rather than by pipeline run. `lib/types.ts` contains manually maintained
|
pipeline run. `lib/types.ts` contains manually maintained
|
||||||
API types. `payload-types.ts` and the Payload import map are generated artifacts.
|
API types. `payload-types.ts` and the Payload import map are generated artifacts.
|
||||||
|
|
||||||
## Publication and caching today
|
## Publication and caching today
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# Similar Schools Nearby — Design
|
# Other Schools Nearby — Design
|
||||||
|
|
||||||
**Date:** 2026-09-21
|
**Date:** 2026-09-21, revised 2026-09-22
|
||||||
**Status:** awaiting review
|
**Status:** revised after staging review
|
||||||
**Scope:** school detail pages, both phase templates
|
**Scope:** school detail pages, both phase templates
|
||||||
|
|
||||||
|
> **Revision, 2026-09-22.** The first build ranked by intake similarity and used
|
||||||
|
> distance as a tiebreak. On staging a Catholic primary showed six Catholic
|
||||||
|
> primaries, none of them close enough to be a real option, and omitted the
|
||||||
|
> community school down the road. Distance now decides the order and nothing
|
||||||
|
> else does; the tier system is gone. The reasoning is kept below rather than
|
||||||
|
> quietly overwritten, because the mistake is the instructive part.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Give a school detail page an answer to the question every reader arrives with
|
Give a school detail page an answer to the question every reader arrives with
|
||||||
@@ -15,7 +22,8 @@ school. This section adds that edge — up to six nearby schools of the same pha
|
|||||||
and a comparable intake, three at a time in a carousel, each a crawlable link and
|
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.
|
each addable to the comparison basket in one click.
|
||||||
|
|
||||||
Mockup, with all three tier states live in both themes:
|
Mockup, in both themes (drawn against the original tiered design, so its ledes
|
||||||
|
and chip fallbacks are one revision behind the copy specified below):
|
||||||
<https://claude.ai/artifact/168KdUMcfkUeGWW2FGjuec>
|
<https://claude.ai/artifact/168KdUMcfkUeGWW2FGjuec>
|
||||||
|
|
||||||
Source of the same page in the repo: `mockups/similar-schools-nearby.html`.
|
Source of the same page in the repo: `mockups/similar-schools-nearby.html`.
|
||||||
@@ -37,19 +45,23 @@ alternative, and there are three ways that claim goes wrong:
|
|||||||
3. A **single-sex** school of the opposite sex. Not a weak match — not an option
|
3. A **single-sex** school of the opposite sex. Not a weak match — not an option
|
||||||
at all.
|
at all.
|
||||||
|
|
||||||
So the design separates two kinds of rule, and never confuses them:
|
So the design separates two kinds of fact, and never confuses them:
|
||||||
|
|
||||||
- **Hard filters** encode the claims above. They are never relaxed, at any
|
- **Hard filters** encode the claims above. They decide eligibility, and are
|
||||||
distance, even if that means the section does not render.
|
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.
|
- **Shared characteristics** — gender, religious character, selectivity —
|
||||||
They relax in tiers, and the card's own text always states what survived.
|
describe how closely an intake resembles this school's. They are *reported on
|
||||||
|
the card and never ranked on*, so the reader weighs them rather than having
|
||||||
|
them weighed for them.
|
||||||
|
|
||||||
Everything below follows from that split.
|
Everything below follows from that split. The revision at the top of this
|
||||||
|
document is what happens when the second kind is treated as the first.
|
||||||
|
|
||||||
## Selection algorithm
|
## Selection algorithm
|
||||||
|
|
||||||
A backend helper, `_similar_schools_payload(urn)` in `backend/app.py`, modelled
|
A backend helper, `_nearby_schools_payload(urn)` in `backend/app.py`, modelled
|
||||||
on the existing `_places_payload(urn)` and operating on the cached
|
on the existing `_places_payload(urn)` and delegating to
|
||||||
|
`backend/nearby_schools.select_nearby(frame, urn)`, which operates on the cached
|
||||||
`load_latest_school_data()` frame — one row per URN, already carrying
|
`load_latest_school_data()` frame — one row per URN, already carrying
|
||||||
`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`,
|
`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`,
|
||||||
`admissions_policy`, `school_type` and `status`.
|
`admissions_policy`, `school_type` and `status`.
|
||||||
@@ -77,64 +89,63 @@ 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
|
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.
|
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
|
**Up to six cards, three visible.** Three fit the row; the rest are reached with
|
||||||
every school that qualifies at the tiers it used, up to six. Three fit the row,
|
the carousel arrows. Two is the minimum that renders at all.
|
||||||
and the rest are reached with the carousel arrows. Two is the minimum that
|
|
||||||
renders at all.
|
|
||||||
|
|
||||||
### Soft preferences, relaxed in tiers
|
### Order: distance, and nothing else
|
||||||
|
|
||||||
| Tier | Additionally requires | Radius |
|
The nearest eligible schools, closest first. Similarity does not enter the
|
||||||
|---|---|---|
|
ranking at any point.
|
||||||
| 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.**
|
**Why not, having built it the other way first.** The original design ranked by
|
||||||
|
tiers — same gender and faith within 3 miles, then same gender within 5, then
|
||||||
|
anything within 10 — and used distance only to order the result. Two things
|
||||||
|
followed, and both showed up on the first Catholic primary anyone looked at:
|
||||||
|
|
||||||
Work down the tiers until the schools found so far reach three. Call the tier
|
- A faith match at 2.9 miles outranked a community school at 0.3 miles. For a
|
||||||
that got there T. The section then shows up to six schools drawn from tiers 1
|
primary, whose catchment is routinely under a mile, the far school is not a
|
||||||
to T, nearest first — and does not open tier T+1 merely because six slots are
|
weaker option; it is not an option.
|
||||||
not yet full.
|
- Because the row filled from the best tier before widening, three Catholic
|
||||||
|
schools within 3 miles were enough to fill all six slots with Catholic
|
||||||
|
schools. The stopping rule that produced this had been added to prevent the
|
||||||
|
*opposite* failure — padding a row with weak distant matches — and made this
|
||||||
|
one certain.
|
||||||
|
|
||||||
Worked through:
|
The premise was backwards. **Distance is a constraint and intake is a
|
||||||
|
preference.** A parent cannot act on a school outside their reach however well
|
||||||
|
it matches, and they are perfectly capable of noticing a shared denomination
|
||||||
|
for themselves if we show it to them. So similarity moved from the ranking to
|
||||||
|
the card: `shared` reports what a school genuinely has in common, and the reader
|
||||||
|
applies their own weighting.
|
||||||
|
|
||||||
| Qualifying | T | Shown |
|
The hard filters above were always where the defensibility lived. They are
|
||||||
|---|---|---|
|
untouched.
|
||||||
| 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
|
### Reach: a sanity bound, not a target
|
||||||
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
|
| Phase | Reach |
|
||||||
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
|
| Primary, middle deemed primary, all-through | 2 miles |
|
||||||
that cannot be applied to.
|
| Secondary, middle deemed secondary | 6 miles |
|
||||||
|
| 16 plus | 10 miles |
|
||||||
|
|
||||||
### Two decisions that are easy to get wrong later
|
Ordering by distance already handles density — a school in inner London fills
|
||||||
|
all six slots inside a mile and never approaches the cap. The cap decides one
|
||||||
|
thing: what happens where the area is sparse. It differs by phase because
|
||||||
|
catchments do, and because people travel furthest for post-16.
|
||||||
|
|
||||||
**Selected by tier, displayed by distance.** Tier decides *which* three schools
|
**A primary with nothing inside two miles renders no section**, and that is the
|
||||||
earn a slot. The rendered order is then distance ascending, because "nearby" is
|
intended answer rather than a gap. The alternative is a section headed "nearby"
|
||||||
the promise in the heading and a reader scanning the row reads the first card as
|
listing a school four miles from a five-year-old.
|
||||||
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
|
**Past the sixth school, the rest are dropped without a count.** The section
|
||||||
London dozens clear tier 1, and a parent there will notice three is not the
|
does not try to be the list: `NearbyPlaces` sits directly beneath and already
|
||||||
neighbourhood — hence six. Beyond that the section does not try to be the list:
|
leads to the place pages, which are built for browsing a full set and which the
|
||||||
`NearbyPlaces` sits directly beneath and already leads to the place pages, which
|
school page exists to feed.
|
||||||
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
|
**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
|
lonely card. The section is absent, the nav item is absent, and the page is
|
||||||
is absent, the nav item is absent, and the page is unchanged from today. A page
|
unchanged from before it existed.
|
||||||
with one weak match is better off without the section than with it.
|
|
||||||
|
|
||||||
### Distance
|
### Distance
|
||||||
|
|
||||||
@@ -157,14 +168,13 @@ reader to assume otherwise.
|
|||||||
| `distance_miles` | one decimal place |
|
| `distance_miles` | one decimal place |
|
||||||
| `school_type` | GIAS type, translated, for the card's meta line |
|
| `school_type` | GIAS type, translated, for the card's meta line |
|
||||||
| `age_range` | for the meta line |
|
| `age_range` | for the meta line |
|
||||||
| `shared` | the chip strings the tier actually justifies — see below |
|
| `shared` | what this school genuinely shares with the subject; may be empty |
|
||||||
| `tier` | 1, 2 or 3 — drives the lede's wording and the chip styling |
|
|
||||||
| `metric_value` | the phase-appropriate headline figure, or null |
|
| `metric_value` | the phase-appropriate headline figure, or null |
|
||||||
| `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below |
|
| `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below |
|
||||||
| `metric_year` | the year the figure is from |
|
| `metric_year` | the year the figure is from |
|
||||||
|
|
||||||
The metric follows the phase side the school was *matched* on, not the
|
The metric follows the subject school's phase side, not the neighbour's own
|
||||||
neighbour's own phase, so a row of cards never mixes two scales. The secondary
|
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
|
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
|
the neighbour has no value for that key, the card reads "Not published" rather
|
||||||
than falling back to the other key.
|
than falling back to the other key.
|
||||||
@@ -183,12 +193,6 @@ correctly — against secondaries. The section therefore takes its lede noun fro
|
|||||||
the school's own phase rather than from its template, or it would print "Other
|
the school's own phase rather than from its template, or it would print "Other
|
||||||
primary schools near <sixth form college>" above a row of secondaries.
|
primary schools near <sixth form college>" 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
|
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
|
rather than a new endpoint because the page already makes exactly one server
|
||||||
fetch for its data, and `/school/[slug]` regenerates at most weekly
|
fetch for its data, and `/school/[slug]` regenerates at most weekly
|
||||||
@@ -199,9 +203,11 @@ week.
|
|||||||
frontend treats absent and empty identically, which is what allowed
|
frontend treats absent and empty identically, which is what allowed
|
||||||
`NearbyPlaces` to ship without a lockstep deploy of the two images.
|
`NearbyPlaces` to ship without a lockstep deploy of the two images.
|
||||||
|
|
||||||
`shared` is computed on the backend beside the tier that produced it, not
|
`shared` is computed on the backend, beside the data it is derived from, not
|
||||||
re-derived on the frontend. Deriving it twice is how a card comes to claim a
|
re-derived on the frontend. Deriving it twice is how a card comes to claim
|
||||||
match the selection did not actually make.
|
something the selection never established. An empty list is a real answer and
|
||||||
|
renders no chips: a bare card costs a school nothing but the likeness it does
|
||||||
|
not have, since the order was already settled by distance.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
@@ -308,16 +314,21 @@ shows.
|
|||||||
|
|
||||||
## Copy, and what the section is allowed to claim
|
## Copy, and what the section is allowed to claim
|
||||||
|
|
||||||
**The lede tracks the deepest tier shown.** At tiers 1–2 it reads "Other primary
|
**The lede never claims an intake.** It reads "Other primary schools near X." —
|
||||||
schools near X, with a similar intake." Where any card came from tier 3 it drops
|
one sentence, no variants. The earlier version varied the wording by tier, which
|
||||||
"with a similar intake", because for at least one of the cards that is not what
|
only existed to soften a claim the section should not have been making.
|
||||||
was matched. Six cards make this more likely to fire than three did, which is
|
|
||||||
correct: a wider net is exactly when the claim needs dropping.
|
|
||||||
|
|
||||||
**Chips state only what is shared.** A tier-2 card carries fewer chips rather
|
**The heading is "Other schools nearby", not "Similar schools nearby".** The
|
||||||
than a chip it has not earned; a tier-3 card falls back to the plain phase name,
|
hard filters do guarantee a comparable set — same phase, same selectivity,
|
||||||
styled as a muted outline rather than a brand-tinted fill so the difference is
|
mainstream never beside special — but nothing ranks on likeness, so the heading
|
||||||
visible at a glance.
|
does not say it does. The nav item reads "Nearby schools" and the section id is
|
||||||
|
`nearby`.
|
||||||
|
|
||||||
|
**Chips state only what is shared, and may be absent entirely.** A card with
|
||||||
|
nothing in common renders no chip row rather than falling back to a filler.
|
||||||
|
Since chips no longer affect the order, an empty one costs that school nothing
|
||||||
|
except a claim it cannot support — and a Catholic parent scanning the row still
|
||||||
|
spots "Roman Catholic" on the card that carries it, and weighs it themselves.
|
||||||
|
|
||||||
**The neighbour's metric carries no valence colour.** Green and terracotta are
|
**The neighbour's metric carries no valence colour.** Green and terracotta are
|
||||||
reserved site-wide for comparison against the England average. Colouring a
|
reserved site-wide for comparison against the England average. Colouring a
|
||||||
@@ -364,9 +375,11 @@ synthetic frame rather than live marts:
|
|||||||
- a special school returns only special schools; a mainstream school returns none
|
- a special school returns only special schools; a mainstream school returns none
|
||||||
- a Boys school never returns a Girls school; Mixed matches both
|
- a Boys school never returns a Girls school; Mixed matches both
|
||||||
- closed schools and schools without coordinates are never returned
|
- closed schools and schools without coordinates are never returned
|
||||||
- tier relaxation fills in order, and a school taken at tier 1 is not repeated
|
- results are ordered by distance ascending, always
|
||||||
- tiers stop relaxing once three are found: four tier-1 matches never open tier 2
|
- a faith match never outranks a closer school (the staging defect, pinned)
|
||||||
|
- the nearest eligible school is always present
|
||||||
- more than six qualifying schools returns the six nearest
|
- more than six qualifying schools returns the six nearest
|
||||||
|
- reach is capped per phase, and a primary beyond two miles returns `[]`
|
||||||
- an all-through school is offered on both phase sides
|
- an all-through school is offered on both phase sides
|
||||||
- a `16 plus` school is matched against secondaries and colleges, never primaries
|
- a `16 plus` school is matched against secondaries and colleges, never primaries
|
||||||
- `is_secondary_phase` and `PHASE_GROUPS` agree on every GIAS phase value
|
- `is_secondary_phase` and `PHASE_GROUPS` agree on every GIAS phase value
|
||||||
@@ -376,7 +389,8 @@ synthetic frame rather than live marts:
|
|||||||
**Frontend**, in `nextjs-app/__tests__`:
|
**Frontend**, in `nextjs-app/__tests__`:
|
||||||
|
|
||||||
- the section renders nothing for absent, empty and single-row inputs
|
- the section renders nothing for absent, empty and single-row inputs
|
||||||
- the lede drops "with a similar intake" when any card is tier 3
|
- the lede never claims a similar intake
|
||||||
|
- an empty `shared` renders no chips rather than a filler
|
||||||
- a null metric renders "Not published"
|
- a null metric renders "Not published"
|
||||||
- the nav item appears only alongside the section
|
- the nav item appears only alongside the section
|
||||||
- every card is in the DOM, including the ones scrolled out of view
|
- every card is in the DOM, including the ones scrolled out of view
|
||||||
@@ -408,10 +422,11 @@ journeys are confirmed on the post-merge staging run.
|
|||||||
- Autoplay, dots, or an infinite loop on the carousel. It is a short list a
|
- Autoplay, dots, or an infinite loop on the carousel. It is a short list a
|
||||||
reader scans deliberately, not a banner competing for attention, and a row
|
reader scans deliberately, not a banner competing for attention, and a row
|
||||||
that moves on its own is a row that moves while someone is reading it.
|
that moves on its own is a row that moves while someone is reading it.
|
||||||
- Statistical neighbours on deprivation, size or cohort profile. If the tiers
|
- Statistical neighbours on deprivation, size or cohort profile. If plain
|
||||||
prove too coarse, that is the trigger to move this computation into a dbt mart
|
distance proves too blunt, that is the trigger to move this computation into a
|
||||||
— `_similar_schools_payload` is a deliberate seam for exactly that swap.
|
dbt mart — `select_nearby` is a deliberate seam for exactly that swap.
|
||||||
- Precomputing neighbours in `marts.*`. Rejected for now: a new mart is inert
|
- Precomputing neighbours in `marts.*`. Rejected for now: a new mart is inert
|
||||||
until Airflow runs, so the feature would ship dark, and every tuning change to
|
until Airflow runs, so the feature would ship dark, and every tuning change to
|
||||||
the tiers would become a pipeline round-trip instead of a deploy.
|
the rules would become a pipeline round-trip instead of a deploy. The revision
|
||||||
|
at the top of this document is the argument for keeping that loop short.
|
||||||
- Any change to `/api/compare`, the compare page, or the comparison basket.
|
- Any change to `/api/compare`, the compare page, or the comparison basket.
|
||||||
@@ -2736,7 +2736,7 @@ test('the content sitemap lists the about page and is advertised in robots', asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Similar schools nearby.
|
* Other schools nearby.
|
||||||
*
|
*
|
||||||
* The section is absent by design where fewer than two schools qualify, and the
|
* The section is absent by design where fewer than two schools qualify, and the
|
||||||
* arrows are absent where three cards fit, so this asserts each part of the
|
* arrows are absent where three cards fit, so this asserts each part of the
|
||||||
@@ -2746,12 +2746,12 @@ test('the content sitemap lists the about page and is advertised in robots', asy
|
|||||||
* which jsdom cannot measure because it has no layout, and the scroll position
|
* which jsdom cannot measure because it has no layout, and the scroll position
|
||||||
* surviving a selection, which is DOM state rather than React state.
|
* surviving a selection, which is DOM state rather than React state.
|
||||||
*/
|
*/
|
||||||
test('similar schools link on to other schools and into compare', async ({ page }) => {
|
test('nearby schools link on to other schools and into compare', async ({ page }) => {
|
||||||
await searchByName(page, 'Primary');
|
await searchByName(page, 'Primary');
|
||||||
await schoolLinks(page).first().click();
|
await schoolLinks(page).first().click();
|
||||||
await page.waitForURL(/\/school\//);
|
await page.waitForURL(/\/school\//);
|
||||||
|
|
||||||
const section = page.locator('#similar');
|
const section = page.locator('#nearby');
|
||||||
if ((await section.count()) === 0) {
|
if ((await section.count()) === 0) {
|
||||||
test.skip(true, 'No qualifying similar schools for this school');
|
test.skip(true, 'No qualifying similar schools for this school');
|
||||||
}
|
}
|
||||||
@@ -2793,7 +2793,7 @@ test('similar schools link on to other schools and into compare', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The section at MOBILE.md's three reference widths.
|
* The nearby-schools section at MOBILE.md's three reference widths.
|
||||||
*
|
*
|
||||||
* MOBILE.md asks for exactly this check and records that it was not written
|
* 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
|
* because "Playwright isn't currently in the project dependency set". That is
|
||||||
@@ -2801,13 +2801,13 @@ test('similar schools link on to other schools and into compare', async ({ page
|
|||||||
* to the page this feature touches.
|
* to the page this feature touches.
|
||||||
*/
|
*/
|
||||||
for (const width of [360, 390, 430]) {
|
for (const width of [360, 390, 430]) {
|
||||||
test(`similar schools survives a ${width}px viewport`, async ({ page }) => {
|
test(`nearby schools survives a ${width}px viewport`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width, height: 800 });
|
await page.setViewportSize({ width, height: 800 });
|
||||||
await searchByName(page, 'Primary');
|
await searchByName(page, 'Primary');
|
||||||
await schoolLinks(page).first().click();
|
await schoolLinks(page).first().click();
|
||||||
await page.waitForURL(/\/school\//);
|
await page.waitForURL(/\/school\//);
|
||||||
|
|
||||||
const section = page.locator('#similar');
|
const section = page.locator('#nearby');
|
||||||
if ((await section.count()) === 0) {
|
if ((await section.count()) === 0) {
|
||||||
test.skip(true, 'No qualifying similar schools for this school');
|
test.skip(true, 'No qualifying similar schools for this school');
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-27
@@ -1,32 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* The section's job is to be honest about what it matched. These tests pin the
|
* The section's job is to be honest about what it is showing. These tests pin
|
||||||
* ways it could lie: rendering below the minimum, claiming a similar intake at
|
* the ways it could mislead: rendering below the minimum, claiming a likeness
|
||||||
* tier 3, showing a missing figure as a number, or hiding a card behind an
|
* it does not rank on, showing a missing figure as a number, or hiding a card
|
||||||
* arrow where a crawler cannot reach it.
|
* behind an arrow where a crawler cannot reach it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
nearbyNoun,
|
nearbyNoun,
|
||||||
SimilarSchoolsSection,
|
NearbySchoolsSection,
|
||||||
shouldRenderSimilar,
|
shouldRenderNearby,
|
||||||
} from '@/components/school/SimilarSchoolsSection';
|
} from '@/components/school/NearbySchoolsSection';
|
||||||
import type { SimilarSchool } from '@/lib/types';
|
import type { NearbySchool } from '@/lib/types';
|
||||||
|
|
||||||
jest.mock('@/components/school/AddToCompareButton', () => ({
|
jest.mock('@/components/school/AddToCompareButton', () => ({
|
||||||
AddToCompareButton: ({ school }: { school: SimilarSchool }) => (
|
AddToCompareButton: ({ school }: { school: NearbySchool }) => (
|
||||||
<button type="button">Add {school.school_name} to compare</button>
|
<button type="button">Add {school.school_name} to compare</button>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
|
jest.mock('@/components/school/NearbySchoolsCompareBar', () => ({
|
||||||
SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
|
NearbySchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
|
||||||
<div data-testid="compare-bar">bar for {thisUrn}</div>
|
<div data-testid="compare-bar">bar for {thisUrn}</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
|
function school(overrides: Partial<NearbySchool> = {}): NearbySchool {
|
||||||
return {
|
return {
|
||||||
urn: 100002,
|
urn: 100002,
|
||||||
school_name: 'Willow Lane Primary School',
|
school_name: 'Willow Lane Primary School',
|
||||||
@@ -34,7 +34,6 @@ function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
|
|||||||
school_type: 'Community school',
|
school_type: 'Community school',
|
||||||
age_range: '4-11',
|
age_range: '4-11',
|
||||||
shared: ['Mixed', 'No religious character'],
|
shared: ['Mixed', 'No religious character'],
|
||||||
tier: 1,
|
|
||||||
metric_value: 74,
|
metric_value: 74,
|
||||||
metric_key: 'rwm_expected_pct',
|
metric_key: 'rwm_expected_pct',
|
||||||
metric_year: 202425,
|
metric_year: 202425,
|
||||||
@@ -42,14 +41,14 @@ function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSection(similar: SimilarSchool[]) {
|
function renderSection(nearby: NearbySchool[]) {
|
||||||
return render(
|
return render(
|
||||||
<SimilarSchoolsSection
|
<NearbySchoolsSection
|
||||||
urn={100001}
|
urn={100001}
|
||||||
schoolName="Meadowbrook Primary School"
|
schoolName="Meadowbrook Primary School"
|
||||||
phase="Primary"
|
phase="Primary"
|
||||||
thisMetricValue={72}
|
thisMetricValue={72}
|
||||||
similar={similar}
|
nearby={nearby}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,11 +60,11 @@ describe('render gates', () => {
|
|||||||
['empty', []],
|
['empty', []],
|
||||||
['a single school', [school()]],
|
['a single school', [school()]],
|
||||||
])('renders nothing for %s', (_label, value) => {
|
])('renders nothing for %s', (_label, value) => {
|
||||||
expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false);
|
expect(shouldRenderNearby(value as NearbySchool[] | null | undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders for two or more schools', () => {
|
it('renders for two or more schools', () => {
|
||||||
expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true);
|
expect(shouldRenderNearby([school(), school({ urn: 100003 })])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null rather than an empty shell below the minimum', () => {
|
it('returns null rather than an empty shell below the minimum', () => {
|
||||||
@@ -74,15 +73,36 @@ describe('render gates', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the claim the lede makes', () => {
|
describe('what the section claims', () => {
|
||||||
it('claims a similar intake when every card is tier 1 or 2', () => {
|
it('never claims a similar intake, because it does not rank on one', () => {
|
||||||
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]);
|
renderSection([school(), school({ urn: 100003, shared: [] })]);
|
||||||
expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument();
|
expect(screen.queryByText(/similar intake/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the claim when any card is tier 3', () => {
|
it('is headed "Other schools nearby", not "similar"', () => {
|
||||||
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]);
|
renderSection([school(), school({ urn: 100003 })]);
|
||||||
expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Other schools nearby' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows chips for what is shared', () => {
|
||||||
|
renderSection([school({ shared: ['Mixed', 'Roman Catholic'] }), school({ urn: 100003 })]);
|
||||||
|
expect(screen.getAllByText('Roman Catholic').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no chips at all when nothing is shared, rather than inventing one', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<NearbySchoolsSection
|
||||||
|
urn={100001}
|
||||||
|
schoolName="Meadowbrook Primary School"
|
||||||
|
phase="Primary"
|
||||||
|
thisMetricValue={72}
|
||||||
|
nearby={[school({ shared: [] }), school({ urn: 100003, shared: [] })]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// The card still carries its distance, name, type and figure — just no
|
||||||
|
// claim of likeness.
|
||||||
|
expect(container.querySelectorAll('li ul').length).toBe(0);
|
||||||
|
expect(screen.getAllByText(/miles away/).length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,12 +124,12 @@ describe('what the lede calls the set', () => {
|
|||||||
|
|
||||||
it('never calls a sixth form college\'s neighbours primary schools', () => {
|
it('never calls a sixth form college\'s neighbours primary schools', () => {
|
||||||
render(
|
render(
|
||||||
<SimilarSchoolsSection
|
<NearbySchoolsSection
|
||||||
urn={100001}
|
urn={100001}
|
||||||
schoolName="Barnet Sixth Form College"
|
schoolName="Barnet Sixth Form College"
|
||||||
phase="16 plus"
|
phase="16 plus"
|
||||||
thisMetricValue={null}
|
thisMetricValue={null}
|
||||||
similar={[school(), school({ urn: 100003 })]}
|
nearby={[school(), school({ urn: 100003 })]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument();
|
expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument();
|
||||||
@@ -173,7 +173,7 @@ describe('buildSecondaryNavItems', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the similar-schools nav item', () => {
|
describe('the nearby-schools nav item', () => {
|
||||||
const navInput = {
|
const navInput = {
|
||||||
ofsted: null, admissions: null, admissionDistance: null,
|
ofsted: null, admissions: null, admissionDistance: null,
|
||||||
hasLocation: true, yearlyDataLength: 1,
|
hasLocation: true, yearlyDataLength: 1,
|
||||||
@@ -182,29 +182,29 @@ describe('the similar-schools nav item', () => {
|
|||||||
it('appears on both templates when the section renders', () => {
|
it('appears on both templates when the section renders', () => {
|
||||||
const primary = computeSchoolFlags(primaryFixture);
|
const primary = computeSchoolFlags(primaryFixture);
|
||||||
const secondary = computeSecondaryFlags(secondaryFixture);
|
const secondary = computeSecondaryFlags(secondaryFixture);
|
||||||
const input = { ...navInput, hasSimilarSchools: true };
|
const input = { ...navInput, hasNearbySchools: true };
|
||||||
|
|
||||||
expect(buildNavItems(primary, input).map((i) => i.id)).toContain('similar');
|
expect(buildNavItems(primary, input).map((i) => i.id)).toContain('nearby');
|
||||||
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).toContain('similar');
|
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).toContain('nearby');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is absent when the section does not render', () => {
|
it('is absent when the section does not render', () => {
|
||||||
const primary = computeSchoolFlags(primaryFixture);
|
const primary = computeSchoolFlags(primaryFixture);
|
||||||
const secondary = computeSecondaryFlags(secondaryFixture);
|
const secondary = computeSecondaryFlags(secondaryFixture);
|
||||||
const input = { ...navInput, hasSimilarSchools: false };
|
const input = { ...navInput, hasNearbySchools: false };
|
||||||
|
|
||||||
expect(buildNavItems(primary, input).map((i) => i.id)).not.toContain('similar');
|
expect(buildNavItems(primary, input).map((i) => i.id)).not.toContain('nearby');
|
||||||
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).not.toContain('similar');
|
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).not.toContain('nearby');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is absent when nothing says either way', () => {
|
it('is absent when nothing says either way', () => {
|
||||||
const primary = computeSchoolFlags(primaryFixture);
|
const primary = computeSchoolFlags(primaryFixture);
|
||||||
expect(buildNavItems(primary, navInput).map((i) => i.id)).not.toContain('similar');
|
expect(buildNavItems(primary, navInput).map((i) => i.id)).not.toContain('nearby');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('comes last, because the section renders last', () => {
|
it('comes last, because the section renders last', () => {
|
||||||
const primary = computeSchoolFlags(primaryFixture);
|
const primary = computeSchoolFlags(primaryFixture);
|
||||||
const ids = buildNavItems(primary, { ...navInput, hasSimilarSchools: true }).map((i) => i.id);
|
const ids = buildNavItems(primary, { ...navInput, hasNearbySchools: true }).map((i) => i.id);
|
||||||
expect(ids[ids.length - 1]).toBe('similar');
|
expect(ids[ids.length - 1]).toBe('nearby');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,7 @@ import { APIFetchError, fetchSchoolDetails, fetchSchools, fetchNationalAverages
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||||
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
||||||
import { shouldRenderSimilar } from '@/components/school/SimilarSchoolsSection';
|
import { shouldRenderNearby } from '@/components/school/NearbySchoolsSection';
|
||||||
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
|
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
|
||||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||||
@@ -157,7 +157,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
// lockstep deploy of the two images.
|
// lockstep deploy of the two images.
|
||||||
const places: SchoolPlace[] = data.places ?? [];
|
const places: SchoolPlace[] = data.places ?? [];
|
||||||
// Absent on an older API build, exactly like `places` above.
|
// Absent on an older API build, exactly like `places` above.
|
||||||
const similarSchools = data.similar_schools ?? [];
|
const nearbySchools = data.nearby_schools ?? [];
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -189,7 +189,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
admissions: admissions ?? null,
|
admissions: admissions ?? null,
|
||||||
admissionDistance: admission_distance ?? null,
|
admissionDistance: admission_distance ?? null,
|
||||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
||||||
hasSimilarSchools: shouldRenderSimilar(similarSchools),
|
hasNearbySchools: shouldRenderNearby(nearbySchools),
|
||||||
yearlyDataLength: yearly_data.length,
|
yearlyDataLength: yearly_data.length,
|
||||||
};
|
};
|
||||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||||
@@ -266,7 +266,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
destinations={destinations ?? null}
|
destinations={destinations ?? null}
|
||||||
similarSchools={similarSchools}
|
nearbySchools={nearbySchools}
|
||||||
flags={secondaryFlags}
|
flags={secondaryFlags}
|
||||||
/>
|
/>
|
||||||
</SchoolDetailShell>
|
</SchoolDetailShell>
|
||||||
@@ -289,7 +289,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
similarSchools={similarSchools}
|
nearbySchools={nearbySchools}
|
||||||
flags={primaryFlags}
|
flags={primaryFlags}
|
||||||
/>
|
/>
|
||||||
</SchoolDetailShell>
|
</SchoolDetailShell>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import type { School, SimilarSchool } from '@/lib/types';
|
import type { School, NearbySchool } from '@/lib/types';
|
||||||
import styles from './SimilarSchools.module.css';
|
import styles from './NearbySchools.module.css';
|
||||||
|
|
||||||
export function AddToCompareButton({ school }: { school: SimilarSchool }) {
|
export function AddToCompareButton({ school }: { school: NearbySchool }) {
|
||||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||||
const selected = selectedSchools.some((s) => s.urn === school.urn);
|
const selected = selectedSchools.some((s) => s.urn === school.urn);
|
||||||
|
|
||||||
|
|||||||
-1
@@ -39,7 +39,6 @@
|
|||||||
|
|
||||||
.shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; }
|
.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; }
|
.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); }
|
.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
|
/* No valence colour here, deliberately: green and terracotta mean "against the
|
||||||
+2
-2
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import styles from './SimilarSchools.module.css';
|
import styles from './NearbySchools.module.css';
|
||||||
|
|
||||||
/** Three cards fit the row, so fewer than four has nowhere to scroll to.
|
/** 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. */
|
* Below 640px the arrows are not rendered at all — see the stylesheet. */
|
||||||
@@ -31,7 +31,7 @@ const VISIBLE = 3;
|
|||||||
*/
|
*/
|
||||||
const EDGE = 8;
|
const EDGE = 8;
|
||||||
|
|
||||||
export function SimilarSchoolsCarousel({
|
export function NearbySchoolsCarousel({
|
||||||
count,
|
count,
|
||||||
labelledBy,
|
labelledBy,
|
||||||
header,
|
header,
|
||||||
+4
-4
@@ -11,15 +11,15 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import type { SimilarSchool } from '@/lib/types';
|
import type { NearbySchool } from '@/lib/types';
|
||||||
import styles from './SimilarSchools.module.css';
|
import styles from './NearbySchools.module.css';
|
||||||
|
|
||||||
export function SimilarSchoolsCompareBar({
|
export function NearbySchoolsCompareBar({
|
||||||
thisUrn,
|
thisUrn,
|
||||||
candidates,
|
candidates,
|
||||||
}: {
|
}: {
|
||||||
thisUrn: number;
|
thisUrn: number;
|
||||||
candidates: SimilarSchool[];
|
candidates: NearbySchool[];
|
||||||
}) {
|
}) {
|
||||||
const { selectedSchools } = useComparisonContext();
|
const { selectedSchools } = useComparisonContext();
|
||||||
|
|
||||||
+37
-38
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* SimilarSchoolsSection — nearby schools of the same phase and a comparable
|
* NearbySchoolsSection — the nearest eligible schools, closest first. Server
|
||||||
* intake. Server component; only the carousel, the compare bar and the
|
* component; only the carousel, the compare bar and the add-to-compare button
|
||||||
* add-to-compare button are client-side.
|
* are client-side.
|
||||||
*
|
*
|
||||||
* The section is allowed to say exactly what the backend matched and no more.
|
* "Other schools nearby", not "similar" ones: the order is distance and only
|
||||||
* The lede only claims a similar intake when no card came from tier 3, and a
|
* distance. The hard filters upstream still guarantee the set is comparable —
|
||||||
* card's chips list what that school actually shares rather than a match it
|
* same phase, same selectivity, mainstream never beside special — but nothing
|
||||||
* did not earn.
|
* here ranks by how alike two schools are, so the heading does not say it does.
|
||||||
|
*
|
||||||
|
* The chips report what a school shares, and may be absent entirely. That is
|
||||||
|
* information for the reader to weigh, not a verdict this section has already
|
||||||
|
* reached on their behalf.
|
||||||
*
|
*
|
||||||
* There is deliberately no "how these are chosen" panel: the method is already
|
* 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
|
* visible in the lede, the chips and the distances. The single caption line is
|
||||||
@@ -14,18 +18,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { SimilarSchool } from '@/lib/types';
|
import type { NearbySchool } from '@/lib/types';
|
||||||
import { schoolUrl } from '@/lib/utils';
|
import { schoolUrl } from '@/lib/utils';
|
||||||
import { AddToCompareButton } from './AddToCompareButton';
|
import { AddToCompareButton } from './AddToCompareButton';
|
||||||
import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel';
|
import { NearbySchoolsCarousel } from './NearbySchoolsCarousel';
|
||||||
import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar';
|
import { NearbySchoolsCompareBar } from './NearbySchoolsCompareBar';
|
||||||
import { Section } from './sectionShared';
|
import { Section } from './sectionShared';
|
||||||
import styles from './SimilarSchools.module.css';
|
import styles from './NearbySchools.module.css';
|
||||||
|
|
||||||
const MINIMUM = 2;
|
const MINIMUM = 2;
|
||||||
|
|
||||||
export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean {
|
export function shouldRenderNearby(nearby?: NearbySchool[] | null): boolean {
|
||||||
return (similar?.length ?? 0) >= MINIMUM;
|
return (nearby?.length ?? 0) >= MINIMUM;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,44 +63,39 @@ function formatMetric(value: number | null, key: string): string {
|
|||||||
return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
|
return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimilarSchoolsSection({
|
export function NearbySchoolsSection({
|
||||||
urn,
|
urn,
|
||||||
schoolName,
|
schoolName,
|
||||||
phase,
|
phase,
|
||||||
thisMetricValue,
|
thisMetricValue,
|
||||||
similar,
|
nearby,
|
||||||
}: {
|
}: {
|
||||||
urn: number;
|
urn: number;
|
||||||
schoolName: string;
|
schoolName: string;
|
||||||
/** The school's own GIAS phase, not the template it renders with. */
|
/** The school's own GIAS phase, not the template it renders with. */
|
||||||
phase: string | null | undefined;
|
phase: string | null | undefined;
|
||||||
thisMetricValue: number | null;
|
thisMetricValue: number | null;
|
||||||
similar?: SimilarSchool[] | null;
|
nearby?: NearbySchool[] | null;
|
||||||
}) {
|
}) {
|
||||||
if (!shouldRenderSimilar(similar)) return null;
|
if (!shouldRenderNearby(nearby)) return null;
|
||||||
const schools = similar as SimilarSchool[];
|
const schools = nearby as NearbySchool[];
|
||||||
|
|
||||||
// One card matched on phase alone, so the section may not claim the set
|
// One card matched on phase alone, so the section may not claim the set
|
||||||
// shares an intake with this school.
|
// shares an intake with this school.
|
||||||
const loosest = Math.max(...schools.map((s) => s.tier));
|
|
||||||
const metricKey = schools[0].metric_key;
|
const metricKey = schools[0].metric_key;
|
||||||
const noun = nearbyNoun(phase);
|
const noun = nearbyNoun(phase);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="similar">
|
<Section id="nearby">
|
||||||
<SimilarSchoolsCarousel
|
<NearbySchoolsCarousel
|
||||||
count={schools.length}
|
count={schools.length}
|
||||||
labelledBy="similar-schools-heading"
|
labelledBy="nearby-schools-heading"
|
||||||
header={
|
header={
|
||||||
<div>
|
<div>
|
||||||
<h2 id="similar-schools-heading" className={styles.heading}>
|
<h2 id="nearby-schools-heading" className={styles.heading}>
|
||||||
Similar schools nearby
|
Other schools nearby
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.lede}>
|
<p className={styles.lede}>{`Other ${noun} near ${schoolName}.`}</p>
|
||||||
{loosest >= 3
|
|
||||||
? `Other ${noun} near ${schoolName}.`
|
|
||||||
: `Other ${noun} near ${schoolName}, with a similar intake.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -113,13 +112,13 @@ export function SimilarSchoolsSection({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
<ul className={styles.shared}>
|
{school.shared.length > 0 && (
|
||||||
{school.shared.map((label) => (
|
<ul className={styles.shared}>
|
||||||
<li key={label} className={school.tier >= 3 ? styles.chipLoose : styles.chip}>
|
{school.shared.map((label) => (
|
||||||
{label}
|
<li key={label} className={styles.chip}>{label}</li>
|
||||||
</li>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
<div className={styles.metric}>
|
<div className={styles.metric}>
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
@@ -138,9 +137,9 @@ export function SimilarSchoolsSection({
|
|||||||
<AddToCompareButton school={school} />
|
<AddToCompareButton school={school} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</SimilarSchoolsCarousel>
|
</NearbySchoolsCarousel>
|
||||||
|
|
||||||
<SimilarSchoolsCompareBar thisUrn={urn} candidates={schools} />
|
<NearbySchoolsCompareBar thisUrn={urn} candidates={schools} />
|
||||||
|
|
||||||
{/* The one caveat the cards cannot make on their own: a reader who takes
|
{/* 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
|
"0.6 miles away" for the walk has been misled, and nothing else here
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
SimilarSchool,
|
NearbySchool,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
@@ -27,7 +27,7 @@ import { HistorySection } from './HistorySection';
|
|||||||
import { SchoolLifeSection } from './SchoolLifeSection';
|
import { SchoolLifeSection } from './SchoolLifeSection';
|
||||||
import { LocalAreaSection } from './LocalAreaSection';
|
import { LocalAreaSection } from './LocalAreaSection';
|
||||||
import { FinancesSection } from './FinancesSection';
|
import { FinancesSection } from './FinancesSection';
|
||||||
import { SimilarSchoolsSection } from './SimilarSchoolsSection';
|
import { NearbySchoolsSection } from './NearbySchoolsSection';
|
||||||
|
|
||||||
export interface PrimarySchoolSectionsProps {
|
export interface PrimarySchoolSectionsProps {
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
@@ -42,14 +42,14 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
/** Nearby schools of a comparable intake. Absent on an older API build. */
|
/** Nearby schools of a comparable intake. Absent on an older API build. */
|
||||||
similarSchools?: SimilarSchool[];
|
nearbySchools?: NearbySchool[];
|
||||||
flags: SchoolFlags;
|
flags: SchoolFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance,
|
admissions, admissionsHistory, admissionDistance,
|
||||||
deprivation, finance, nationalAvg, similarSchools, flags,
|
deprivation, finance, nationalAvg, nearbySchools, flags,
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -152,12 +152,12 @@ export function PrimarySchoolSections({
|
|||||||
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
|
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
|
||||||
|
|
||||||
{/* Last: it is where the reader goes next, not part of this school. */}
|
{/* Last: it is where the reader goes next, not part of this school. */}
|
||||||
<SimilarSchoolsSection
|
<NearbySchoolsSection
|
||||||
urn={schoolInfo.urn}
|
urn={schoolInfo.urn}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
phase={schoolInfo.phase}
|
phase={schoolInfo.phase}
|
||||||
thisMetricValue={flags.latestResults?.rwm_expected_pct ?? null}
|
thisMetricValue={flags.latestResults?.rwm_expected_pct ?? null}
|
||||||
similar={similarSchools}
|
nearby={nearbySchools}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
SchoolDestinations, SimilarSchool,
|
SchoolDestinations, NearbySchool,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
@@ -27,7 +27,7 @@ import { DistanceSection } from './DistanceSection';
|
|||||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||||
import { WellbeingSection } from './WellbeingSection';
|
import { WellbeingSection } from './WellbeingSection';
|
||||||
import { FinancesSection } from './FinancesSection';
|
import { FinancesSection } from './FinancesSection';
|
||||||
import { SimilarSchoolsSection } from './SimilarSchoolsSection';
|
import { NearbySchoolsSection } from './NearbySchoolsSection';
|
||||||
import styles from './schoolSections.module.css';
|
import styles from './schoolSections.module.css';
|
||||||
|
|
||||||
export interface SecondarySchoolSectionsProps {
|
export interface SecondarySchoolSectionsProps {
|
||||||
@@ -49,14 +49,14 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
destinations: SchoolDestinations | null;
|
destinations: SchoolDestinations | null;
|
||||||
/** Nearby schools of a comparable intake. Absent on an older API build. */
|
/** Nearby schools of a comparable intake. Absent on an older API build. */
|
||||||
similarSchools?: SimilarSchool[];
|
nearbySchools?: NearbySchool[];
|
||||||
flags: SecondaryFlags;
|
flags: SecondaryFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance,
|
admissions, admissionsHistory, admissionDistance,
|
||||||
deprivation, finance, nationalAvg, destinations, similarSchools, flags,
|
deprivation, finance, nationalAvg, destinations, nearbySchools, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -146,12 +146,12 @@ export function SecondarySchoolSections({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Last: it is where the reader goes next, not part of this school. */}
|
{/* Last: it is where the reader goes next, not part of this school. */}
|
||||||
<SimilarSchoolsSection
|
<NearbySchoolsSection
|
||||||
urn={schoolInfo.urn}
|
urn={schoolInfo.urn}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
phase={schoolInfo.phase}
|
phase={schoolInfo.phase}
|
||||||
thisMetricValue={flags.latestResults?.attainment_8_score ?? null}
|
thisMetricValue={flags.latestResults?.attainment_8_score ?? null}
|
||||||
similar={similarSchools}
|
nearby={nearbySchools}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ export interface NavItemsInput {
|
|||||||
* measure a postcode, so the nav must gate on them too or it will link to an
|
* measure a postcode, so the nav must gate on them too or it will link to an
|
||||||
* anchor that was never rendered. */
|
* anchor that was never rendered. */
|
||||||
hasLocation?: boolean;
|
hasLocation?: boolean;
|
||||||
/** Whether the similar-schools section will render. Optional for the same
|
/** Whether the nearby-schools section will render. Optional for the same
|
||||||
* reason hasLocation is: the nav must never link to an anchor that was not
|
* reason hasLocation is: the nav must never link to an anchor that was not
|
||||||
* rendered, and absent has to mean "no section". */
|
* rendered, and absent has to mean "no section". */
|
||||||
hasSimilarSchools?: boolean;
|
hasNearbySchools?: boolean;
|
||||||
yearlyDataLength: number;
|
yearlyDataLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export function buildNavItems(
|
|||||||
flags: SchoolFlags,
|
flags: SchoolFlags,
|
||||||
{
|
{
|
||||||
ofsted, admissions, admissionDistance, hasLocation,
|
ofsted, admissions, admissionDistance, hasLocation,
|
||||||
hasSimilarSchools, yearlyDataLength,
|
hasNearbySchools, yearlyDataLength,
|
||||||
}: NavItemsInput,
|
}: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
@@ -169,7 +169,7 @@ export function buildNavItems(
|
|||||||
if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
// Last, because the section renders last — the scroll-spy reads this order.
|
// Last, because the section renders last — the scroll-spy reads this order.
|
||||||
if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' });
|
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||||||
return navItems;
|
return navItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ export function buildSecondaryNavItems(
|
|||||||
flags: SecondaryFlags,
|
flags: SecondaryFlags,
|
||||||
{
|
{
|
||||||
ofsted, admissions, admissionDistance, hasLocation,
|
ofsted, admissions, admissionDistance, hasLocation,
|
||||||
hasSimilarSchools, yearlyDataLength,
|
hasNearbySchools, yearlyDataLength,
|
||||||
}: NavItemsInput,
|
}: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
@@ -270,6 +270,6 @@ export function buildSecondaryNavItems(
|
|||||||
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
// Last, because the section renders last — the scroll-spy reads this order.
|
// Last, because the section renders last — the scroll-spy reads this order.
|
||||||
if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' });
|
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||||||
return navItems;
|
return navItems;
|
||||||
}
|
}
|
||||||
@@ -347,21 +347,20 @@ export interface SchoolsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A nearby school of the same phase and a comparable intake.
|
* A nearby school, from the nearest-first set the detail page shows.
|
||||||
*
|
*
|
||||||
* `tier` is carried explicitly rather than inferred from `shared`, because it
|
* `shared` is what this school genuinely has in common with the one being
|
||||||
* drives two separate decisions — whether the lede may claim a similar intake,
|
* viewed, and may be empty. It is reported, never ranked on: an earlier
|
||||||
* and whether a chip renders as a fill or a muted outline — and inferring it
|
* version ordered by it and buried the school down the road under faith
|
||||||
* from chip count would couple those decisions to the copy.
|
* matches three times further away.
|
||||||
*/
|
*/
|
||||||
export interface SimilarSchool {
|
export interface NearbySchool {
|
||||||
urn: number;
|
urn: number;
|
||||||
school_name: string;
|
school_name: string;
|
||||||
distance_miles: number;
|
distance_miles: number;
|
||||||
school_type: string | null;
|
school_type: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
shared: string[];
|
shared: string[];
|
||||||
tier: number;
|
|
||||||
metric_value: number | null;
|
metric_value: number | null;
|
||||||
metric_key: string;
|
metric_key: string;
|
||||||
metric_year: number | null;
|
metric_year: number | null;
|
||||||
@@ -379,13 +378,13 @@ export interface SchoolDetailsResponse {
|
|||||||
*/
|
*/
|
||||||
places?: SchoolPlace[];
|
places?: SchoolPlace[];
|
||||||
/**
|
/**
|
||||||
* Up to six nearby schools of a comparable intake, nearest first.
|
* Up to six nearby eligible schools, nearest first.
|
||||||
*
|
*
|
||||||
* Optional for the same reason as `places`: a frontend deployed ahead of the
|
* Optional for the same reason as `places`: a frontend deployed ahead of the
|
||||||
* API that serves this must render without it. Absent and empty mean the
|
* API that serves this must render without it. Absent and empty mean the
|
||||||
* same thing here — no section.
|
* same thing here — no section.
|
||||||
*/
|
*/
|
||||||
similar_schools?: SimilarSchool[];
|
nearby_schools?: NearbySchool[];
|
||||||
yearly_data: SchoolResult[];
|
yearly_data: SchoolResult[];
|
||||||
absence_data: AbsenceData | null;
|
absence_data: AbsenceData | null;
|
||||||
// Supplementary data (null until Kestra populates)
|
// Supplementary data (null until Kestra populates)
|
||||||
|
|||||||
Reference in new issue
Block a user