diff --git a/backend/app.py b/backend/app.py index 23826c2..9cfb87d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -41,7 +41,7 @@ from .data_loader import get_data_info as get_db_info from . import flags from .places import build_place_index, build_place_registry, places_for_urn from .schemas import METRIC_DEFINITIONS, 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 # Values to exclude from filter dropdowns (empty strings, non-applicable labels) @@ -266,25 +266,23 @@ def _places_payload(urn: int) -> list[dict]: return payload -def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]: - """Nearby schools this page may offer as alternatives. +def _nearby_schools_payload(urn: int) -> list[dict]: + """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 complete, which is the posture get_supplementary_data already takes. The section simply does not render. """ try: - # Decided in similar_schools, beside the PHASE_GROUPS bucket it selects - # from, so the two cannot drift. A substring test for "secondary" here - # would miss "16 plus" and hand a sixth-form college the primary bucket. - return select_similar( - load_latest_school_data(), int(urn), is_secondary_phase(phase) - ) + return select_nearby(load_latest_school_data(), int(urn)) except Exception: import logging logging.getLogger(__name__).exception( - "Similar schools selection failed for urn=%s", urn + "Nearby schools selection failed for urn=%s", urn ) 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 # point, and the page renders without the module. "places": _places_payload(urn), - # Nearby schools of the same phase and a comparable intake. Always - # present on a build with this code; the frontend treats absent and - # empty identically, which is what lets the two images deploy - # independently. - "similar_schools": _similar_schools_payload(urn, latest.get("phase")), + # The nearest eligible schools, closest first. Always present on a + # build with this code; the frontend treats absent and empty + # identically, which is what lets the two images deploy independently. + "nearby_schools": _nearby_schools_payload(urn), "yearly_data": clean_for_json(school_data), # Supplementary data (null if not yet populated by Kestra) "ofsted": supplementary.get("ofsted"), diff --git a/backend/similar_schools.py b/backend/nearby_schools.py similarity index 60% rename from backend/similar_schools.py rename to backend/nearby_schools.py index 0e75467..c4f22bd 100644 --- a/backend/similar_schools.py +++ b/backend/nearby_schools.py @@ -1,17 +1,24 @@ """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 -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. +DISTANCE decides the order, and nothing else does. -SOFT PREFERENCES describe how closely an intake resembles this school's. They -relax in tiers, and every card reports the tier that actually took it so the -page can say what is shared rather than implying more. They relax only far -enough to reach a usable set, never far enough to fill the last of the slots. +An earlier version ranked by intake similarity first and used distance only as +a tiebreak. That put a Catholic school 2.9 miles away above the community +school 0.3 miles down the road, and — because the row filled from the best tier +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. """ @@ -25,19 +32,21 @@ import pandas as pd from .schemas import PHASE_GROUPS -# A cap, not a quota: the section shows everything that qualified at the tiers -# it used, up to this many. Three fit the row; the rest are behind the arrows. +# Three fit the row; the rest are behind the carousel arrows. MAX_SCHOOLS = 6 -# Tiers stop relaxing once this many have been found. Without it, a cap of six -# would reliably drag in tier-3 schools ten miles away to fill a row that three -# good matches had already earned. -ENOUGH = 3 MINIMUM = 2 -# (tier, radius in miles). Faith relaxes before gender: a faith mismatch -# changes the character of a school, while a gender mismatch can mean the -# school is not available to this reader's child at all. -TIERS: tuple[tuple[int, float], ...] = ((1, 3.0), (2, 5.0), (3, 10.0)) +# How far the section will reach, in miles, when nothing closer exists. +# +# A sanity bound rather than a target: ordering by distance already handles +# 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 @@ -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) -def phase_label(phase: str | None) -> str: - text = (phase or "").strip() - if not text: - return "School" - if text.lower() == "all-through": - return "All-through school" - return f"{text.capitalize()} school" - - def is_secondary_phase(phase: str | None) -> bool: """Whether this phase takes the secondary side: secondary group membership, minus all-through. @@ -107,6 +107,13 @@ def is_secondary_phase(phase: str | None) -> bool: 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]: 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) -def _chips(subject: pd.Series, candidate: pd.Series, tier: int, is_secondary: bool) -> list[str]: - if tier >= 3: - return [phase_label(candidate.get("phase"))] +def _shared(subject: pd.Series, candidate: pd.Series, is_secondary: bool) -> list[str]: + """What this candidate genuinely has in common with the subject. + + 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: - policy = (candidate.get("admissions_policy") or "").strip() - if policy and policy.lower() not in {"not applicable", "unknown"}: - chips.append(policy) - if tier == 1: - chips.append(faith_label(candidate.get("religious_denomination"))) - return [chip for chip in chips if chip] + policy = str(candidate.get("admissions_policy") or "").strip() + subject_policy = str(subject.get("admissions_policy") or "").strip() + if ( + policy + and policy.lower() == subject_policy.lower() + 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]: - """Up to MAX_SCHOOLS nearby schools this page may offer, or [] below MINIMUM. +def select_nearby(frame: pd.DataFrame, urn: int) -> list[dict]: + """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 - earn a slot, and the render order is then closest-first, because "nearby" - is the promise in the heading. + The phase is read from the subject's own row rather than passed in, so a + caller cannot hand this a phase that disagrees with the data it selects + from. """ subject_rows = frame[frame["urn"] == urn] 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: 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" 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 ).round(1) - # ── Soft preferences, in tiers ────────────────────────────────────── - subject_faith = faith_key(subject.get("religious_denomination")) - subject_gender_key = (subject_gender or "").strip().lower() - same_gender = candidates["gender"].fillna("").str.strip().str.lower() == subject_gender_key - same_faith = _mask( - candidates["religious_denomination"], lambda d: faith_key(d) == subject_faith - ) - - tier_masks = { - 1: same_gender & same_faith, - 2: same_gender, - 3: pd.Series(True, index=candidates.index), - } - - # Descend the tiers only until the set reaches ENOUGH. The tier that gets - # there is the last one opened, and the remaining slots up to MAX_SCHOOLS - # are filled from the tiers already used — never by widening again. - picked: dict[int, tuple[int, pd.Series]] = {} - for tier, radius in TIERS: - within = candidates[tier_masks[tier] & (candidates["distance_miles"] <= radius)] - for _, row in within.sort_values("distance_miles").iterrows(): - candidate_urn = int(row["urn"]) - if candidate_urn in picked: - continue - picked[candidate_urn] = (tier, row) - if len(picked) >= MAX_SCHOOLS: - break - if len(picked) >= ENOUGH: - break - - if len(picked) < MINIMUM: + # ── Nearest first, and nothing else has a say ─────────────────────── + within = candidates[candidates["distance_miles"] <= reach] + if len(within) < MINIMUM: return [] - selected = sorted( - picked.values(), key=lambda pair: float(pair[1]["distance_miles"]) - )[:MAX_SCHOOLS] + selected = within.sort_values(["distance_miles", "urn"]).head(MAX_SCHOOLS) return [ { "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"]), "school_type": _native(row.get("school_type")), "age_range": _native(row.get("age_range")), - "shared": _chips(subject, row, tier, is_secondary), - "tier": tier, + "shared": _shared(subject, row, is_secondary), "metric_value": _native(row.get(metric_key)), "metric_key": metric_key, "metric_year": _native(row.get("year")), } - for tier, row in selected + for _, row in selected.iterrows() ] diff --git a/backend/schemas.py b/backend/schemas.py index 8d49c3c..5dbec05 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -536,7 +536,7 @@ RANKING_COLUMNS = [ # include. All-through schools appear in both primary and secondary results, # which is why this is a set per phase rather than a single string comparison. # -# Lives here rather than in app.py because similar_schools.py needs it too, and +# Lives here rather than in app.py because nearby_schools.py needs it too, and # importing app from there would be a cycle. PHASE_GROUPS: dict[str, set[str]] = { "primary": {"primary", "middle deemed primary", "all-through"}, diff --git a/backend/tests/test_similar_schools.py b/backend/tests/test_nearby_schools.py similarity index 55% rename from backend/tests/test_similar_schools.py rename to backend/tests/test_nearby_schools.py index ad4aca7..a096d9b 100644 --- a/backend/tests/test_similar_schools.py +++ b/backend/tests/test_nearby_schools.py @@ -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 school is comparable to a mainstream one, or that a Girls school is an option -for a Boys school's reader. They never relax. The soft preferences describe -how close the intake is, and they do — but only far enough to reach a usable -set, never far enough to fill the last of the six slots. +for a Boys school's reader. They decide who is eligible. + +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 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 @@ -48,16 +55,68 @@ def _at(miles): 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( _row(100001, "Subject"), - _row(100002, "Near", latitude=_at(0.5)), - _row(100003, "Mid", latitude=_at(1.0)), - _row(100004, "Far", latitude=_at(2.0)), + _row(100002, "Mid", latitude=_at(1.0)), + _row(100003, "Near", latitude=_at(0.4)), + _row(100004, "Far", latitude=_at(1.8)), ) - result = select_similar(frame, 100001, is_secondary=False) - assert [s["urn"] for s in result] == [100002, 100003, 100004] - assert result[0]["distance_miles"] == 0.5 + result = select_nearby(frame, 100001) + assert [s["urn"] for s in result] == [100003, 100002, 100004] + 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(): @@ -66,19 +125,66 @@ def test_excludes_the_subject_school(): _row(100002, "A", latitude=_at(0.5)), _row(100003, "B", latitude=_at(0.6)), ) - assert 100001 not in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} + 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(): frame = _frame( _row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"), _row(100002, "Comp A", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.5)), _row(100003, "Comp B", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.6)), ) - assert select_similar(frame, 100001, is_secondary=True) == [] - - reverse = select_similar(frame, 100002, is_secondary=True) - assert 100001 not in {s["urn"] for s in reverse} + assert select_nearby(frame, 100001) == [] + assert 100001 not in {s["urn"] for s in select_nearby(frame, 100002)} 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(100003, "Mainstream B", latitude=_at(0.6)), ) - assert select_similar(frame, 100001, is_secondary=False) == [] - assert select_similar(frame, 100002, is_secondary=False) == [] + assert select_nearby(frame, 100001) == [] + assert select_nearby(frame, 100002) == [] 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(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 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(100005, "Good B", latitude=_at(0.7)), ) - assert {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} == {100004, 100005} - - -def test_tiers_relax_faith_before_gender(): - frame = _frame( - _row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"), - # Tier 1: same gender and same faith. - _row(100002, "Tier one", gender="Boys", religious_denomination="Roman Catholic", latitude=_at(2.0)), - # Tier 2: same gender, different faith — closer, but a weaker match. - _row(100003, "Tier two", gender="Boys", religious_denomination="None", latitude=_at(0.5)), - # Tier 3: mixed gender, different faith. - _row(100004, "Tier three", gender="Mixed", religious_denomination="None", latitude=_at(0.6)), - ) - result = select_similar(frame, 100001, is_secondary=False) - tier_by_urn = {s["urn"]: s["tier"] for s in result} - assert tier_by_urn == {100002: 1, 100003: 2, 100004: 3} - # Selected by tier, displayed by distance. - assert [s["urn"] for s in result] == [100003, 100004, 100002] - - -def test_caps_at_six_taking_the_nearest(): - frame = _frame( - _row(100001, "Subject"), - *[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)], - ) - result = select_similar(frame, 100001, is_secondary=False) - assert len(result) == 6 - # The seventh-nearest is the one dropped, not an arbitrary one. - assert 100016 not in {s["urn"] for s in result} - - -def test_tiers_stop_once_enough_are_found(): - """Four tier-1 matches are a usable set, so tier 2 is never opened — even - though it holds a school that is closer than any of them.""" - frame = _frame( - _row(100001, "Subject", religious_denomination="Roman Catholic"), - _row(100002, "RC one", religious_denomination="Roman Catholic", latitude=_at(0.5)), - _row(100003, "RC two", religious_denomination="Roman Catholic", latitude=_at(0.6)), - _row(100004, "RC three", religious_denomination="Roman Catholic", latitude=_at(0.7)), - _row(100005, "RC four", religious_denomination="Roman Catholic", latitude=_at(0.8)), - # Closer than every one of them, but only a tier-2 match. - _row(100006, "Secular and nearer", religious_denomination="None", latitude=_at(0.2)), - ) - result = select_similar(frame, 100001, is_secondary=False) - assert 100006 not in {s["urn"] for s in result} - assert len(result) == 4 - assert all(s["tier"] == 1 for s in result) - - -def test_a_school_is_never_taken_twice(): - frame = _frame( - _row(100001, "Subject"), - _row(100002, "A", latitude=_at(0.5)), - _row(100003, "B", latitude=_at(0.6)), - ) - result = select_similar(frame, 100001, is_secondary=False) - assert len(result) == len({s["urn"] for s in result}) - - -def test_fewer_than_two_matches_returns_empty(): - frame = _frame( - _row(100001, "Subject"), - _row(100002, "Only neighbour", latitude=_at(0.5)), - ) - assert select_similar(frame, 100001, is_secondary=False) == [] - - -def test_beyond_the_widest_radius_is_not_offered(): - frame = _frame( - _row(100001, "Subject"), - _row(100002, "A", latitude=_at(11.0)), - _row(100003, "B", latitude=_at(12.0)), - ) - assert select_similar(frame, 100001, is_secondary=False) == [] + assert {s["urn"] for s in select_nearby(frame, 100001)} == {100004, 100005} 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(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( _row(100010, "Secondary subject", phase="Secondary"), _row(100002, "All through", phase="All-through", latitude=_at(0.5)), _row(100011, "Secondary peer", phase="Secondary", latitude=_at(0.6)), ) - assert 100002 in {s["urn"] for s in select_similar(secondary, 100010, is_secondary=True)} + assert 100002 in {s["urn"] for s in select_nearby(secondary, 100010)} 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(100002, "Nearby Secondary", phase="Secondary", latitude=_at(0.5), attainment_8_score=52.0), - _row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6), - attainment_8_score=np.nan), + _row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6)), _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} assert 100004 not in urns, "a primary school is not a peer for a sixth form" 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(): - """The two must not drift: whatever this calls secondary decides which - PHASE_GROUPS bucket the candidates come from.""" for phase in ("Secondary", "Middle deemed secondary", "16 plus"): assert is_secondary_phase(phase) is True, phase for phase in ("Primary", "Middle deemed primary", "Nursery", "", None): assert is_secondary_phase(phase) is False, phase # In PHASE_GROUPS an all-through school is on both sides, but it renders - # with the primary template, and the metric follows the template. + # with the primary template, and the metric follows the phase side. 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( _row(100001, "Subject", phase="Secondary", gender="Mixed", 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", 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[100003]["shared"] == ["Mixed", "Non-selective"] -def test_tier_three_chip_is_the_plain_phase(): +def test_a_shared_faith_is_named(): frame = _frame( - _row(100001, "Subject", gender="Boys"), - _row(100002, "A", gender="Mixed", latitude=_at(0.5)), - _row(100003, "B", gender="Mixed", latitude=_at(0.6)), + _row(100001, "Subject", religious_denomination="Roman Catholic"), + _row(100002, "Also RC", religious_denomination="Roman Catholic", latitude=_at(0.4)), + _row(100003, "Secular", religious_denomination="None", latitude=_at(0.5)), ) - result = select_similar(frame, 100001, is_secondary=False) - assert all(s["shared"] == ["Primary school"] for s in result) + by_urn = {s["urn"]: s for s in select_nearby(frame, 100001)} + 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( _row(100001, "Subject", phase="Secondary", attainment_8_score=50.0), _row(100002, "A", phase="Secondary", attainment_8_score=52.8, latitude=_at(0.5)), _row(100003, "B", phase="Secondary", attainment_8_score=np.nan, latitude=_at(0.6)), ) - by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)} + 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_value"] == 52.8 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(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["distance_miles"], float) assert not isinstance(school["metric_value"], np.generic) @@ -310,10 +363,10 @@ def client(monkeypatch): 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") 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 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): 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") assert resp.status_code == 200, resp.text - assert resp.json()["similar_schools"] == [] + assert resp.json()["nearby_schools"] == [] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5ca4cc..6d72959 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,12 +73,12 @@ There is no SWR dependency. Leaflet maps are loaded through dynamic wrappers; Chart.js renders performance and comparison charts. `components/school/` contains detail sections, with section decisions and data -preparation in `lib/schoolSections.ts`. The similar-schools section is selected -in `backend/similar_schools.py` — hard filters that never relax (phase, -provision, selectivity, gender) and soft preferences that do (religious -character, then gender exactness) — and served on `/api/schools/{urn}`. Its -rules are presentation logic, deliberately kept out of `marts.*` so they can be -tuned by deploy rather than by pipeline run. `lib/types.ts` contains manually maintained +preparation in `lib/schoolSections.ts`. The nearby-schools section is selected in +`backend/nearby_schools.py` — hard filters decide eligibility (phase, provision, +selectivity, gender) and distance alone decides the order, capped per phase — +and served on `/api/schools/{urn}`. Its rules are presentation logic, +deliberately kept out of `marts.*` so they can be tuned by deploy rather than by +pipeline run. `lib/types.ts` contains manually maintained API types. `payload-types.ts` and the Payload import map are generated artifacts. ## Publication and caching today diff --git a/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md index 6f87035..2d5f9cd 100644 --- a/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md +++ b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md @@ -1,9 +1,16 @@ -# Similar Schools Nearby — Design +# Other Schools Nearby — Design -**Date:** 2026-09-21 -**Status:** awaiting review +**Date:** 2026-09-21, revised 2026-09-22 +**Status:** revised after staging review **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 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 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): 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 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 - distance, even if that means the section does not render. -- **Soft preferences** describe how closely the intake resembles this school's. - They relax in tiers, and the card's own text always states what survived. +- **Hard filters** encode the claims above. They decide eligibility, and are + never relaxed at any distance, even if that means the section does not render. +- **Shared characteristics** — gender, religious character, selectivity — + 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 -A backend helper, `_similar_schools_payload(urn)` in `backend/app.py`, modelled -on the existing `_places_payload(urn)` and operating on the cached +A backend helper, `_nearby_schools_payload(urn)` in `backend/app.py`, modelled +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 `latitude`, `longitude`, `phase`, `gender`, `religious_denomination`, `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 then offered as a peer to a mainstream school on the next page along. -**Up to six cards, three visible.** Six is a cap, not a quota: the section shows -every school that qualifies at the tiers it used, up to six. Three fit the row, -and the rest are reached with the carousel arrows. Two is the minimum that -renders at all. +**Up to six cards, three visible.** Three fit the row; 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 | -|---|---|---| -| 1 | exact gender equality **and** same religious character | 3 miles | -| 2 | exact gender equality | 5 miles | -| 3 | nothing beyond the hard filters | 10 miles | +The nearest eligible schools, closest first. Similarity does not enter the +ranking at any point. -**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 -that got there T. The section then shows up to six schools drawn from tiers 1 -to T, nearest first — and does not open tier T+1 merely because six slots are -not yet full. +- A faith match at 2.9 miles outranked a community school at 0.3 miles. For a + primary, whose catchment is routinely under a mile, the far school is not a + weaker option; it is not an option. +- 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 | -|---|---|---| -| 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 | +The hard filters above were always where the defensibility lived. They are +untouched. -Without that stopping rule, a cap of six would reliably drag in tier-3 schools -ten miles away to fill a row that three good matches had already earned. The old -cap of three hid this; six exposes it, which is why the rule is stated rather -than left to the loop. +### Reach: a sanity bound, not a target -**Faith relaxes before gender.** A faith mismatch changes the character of a -school; a gender mismatch can mean the school is not available to the reader's -child at all. Ordering them the other way would fill the section with schools -that cannot be applied to. +| Phase | Reach | +|---|---| +| Primary, middle deemed primary, all-through | 2 miles | +| 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 -earn a slot. The rendered order is then distance ascending, because "nearby" is -the promise in the heading and a reader scanning the row reads the first card as -the closest. A tier-2 school at 0.4 miles therefore appears above a tier-1 -school at 2.9 miles, and the chips explain the difference in match quality. +**A primary with nothing inside two miles renders no section**, and that is the +intended answer rather than a gap. The alternative is a section headed "nearby" +listing a school four miles from a five-year-old. -**Past the sixth school, the rest are dropped without a count.** In inner -London dozens clear tier 1, and a parent there will notice three is not the -neighbourhood — hence six. Beyond that the section does not try to be the list: -`NearbyPlaces` sits directly beneath and already leads to the place pages, which -are built for browsing a full set and which the school page exists to feed. +**Past the sixth school, the rest are dropped without a count.** The section +does not try to be the list: `NearbyPlaces` sits directly beneath and already +leads to the place pages, which are built for browsing a full set and which the +school page exists to feed. **Fewer than two results renders nothing.** Not an empty state, not a single -lonely card, not padding with schools that failed the hard filters. The section -is absent, the nav item is absent, and the page is unchanged from today. A page -with one weak match is better off without the section than with it. +lonely card. The section is absent, the nav item is absent, and the page is +unchanged from before it existed. ### Distance @@ -157,14 +168,13 @@ reader to assume otherwise. | `distance_miles` | one decimal place | | `school_type` | GIAS type, translated, for the card's meta line | | `age_range` | for the meta line | -| `shared` | the chip strings the tier actually justifies — see below | -| `tier` | 1, 2 or 3 — drives the lede's wording and the chip styling | +| `shared` | what this school genuinely shares with the subject; may be empty | | `metric_value` | the phase-appropriate headline figure, or null | | `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below | | `metric_year` | the year the figure is from | -The metric follows the phase side the school was *matched* on, not the -neighbour's own phase, so a row of cards never mixes two scales. The secondary +The metric follows the subject school's phase side, not the neighbour's own +phase, so a row of cards never mixes two scales. The secondary side uses `attainment_8_score`; the primary side uses `rwm_expected_pct`. Where the neighbour has no value for that key, the card reads "Not published" rather than falling back to the other key. @@ -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 primary schools near " above a row of secondaries. -`tier` is carried explicitly rather than inferred from the contents of -`shared`, because the frontend needs it for two separate decisions — whether the -lede may claim a similar intake, and whether a chip renders as a brand-tinted -fill or a muted outline — and inferring it from chip count would couple those -decisions to the copy. - Up to six rows of roughly 130 bytes each. It rides in the existing detail payload rather than a new endpoint because the page already makes exactly one server fetch for its data, and `/school/[slug]` regenerates at most weekly @@ -199,9 +203,11 @@ week. frontend treats absent and empty identically, which is what allowed `NearbyPlaces` to ship without a lockstep deploy of the two images. -`shared` is computed on the backend beside the tier that produced it, not -re-derived on the frontend. Deriving it twice is how a card comes to claim a -match the selection did not actually make. +`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 +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 @@ -308,16 +314,21 @@ shows. ## Copy, and what the section is allowed to claim -**The lede tracks the deepest tier shown.** At tiers 1–2 it reads "Other primary -schools near X, with a similar intake." Where any card came from tier 3 it drops -"with a similar intake", because for at least one of the cards that is not what -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. +**The lede never claims an intake.** It reads "Other primary schools near X." — +one sentence, no variants. The earlier version varied the wording by tier, which +only existed to soften a claim the section should not have been making. -**Chips state only what is shared.** A tier-2 card carries fewer chips rather -than a chip it has not earned; a tier-3 card falls back to the plain phase name, -styled as a muted outline rather than a brand-tinted fill so the difference is -visible at a glance. +**The heading is "Other schools nearby", not "Similar schools nearby".** The +hard filters do guarantee a comparable set — same phase, same selectivity, +mainstream never beside special — but nothing ranks on likeness, so the heading +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 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 Boys school never returns a Girls school; Mixed matches both - closed schools and schools without coordinates are never returned -- tier relaxation fills in order, and a school taken at tier 1 is not repeated -- tiers stop relaxing once three are found: four tier-1 matches never open tier 2 +- results are ordered by distance ascending, always +- 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 +- reach is capped per phase, and a primary beyond two miles returns `[]` - an all-through school is offered on both phase sides - a `16 plus` school is matched against secondaries and colleges, never primaries - `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__`: - 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" - the nav item appears only alongside the section - 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 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. -- Statistical neighbours on deprivation, size or cohort profile. If the tiers - prove too coarse, that is the trigger to move this computation into a dbt mart - — `_similar_schools_payload` is a deliberate seam for exactly that swap. +- Statistical neighbours on deprivation, size or cohort profile. If plain + distance proves too blunt, that is the trigger to move this computation into a + dbt mart — `select_nearby` is a deliberate seam for exactly that swap. - 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 - 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. diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 23ac1bb..4de2c6f 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -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 * 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 * 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 schoolLinks(page).first().click(); await page.waitForURL(/\/school\//); - const section = page.locator('#similar'); + const section = page.locator('#nearby'); if ((await section.count()) === 0) { 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 * 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. */ 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 searchByName(page, 'Primary'); await schoolLinks(page).first().click(); await page.waitForURL(/\/school\//); - const section = page.locator('#similar'); + const section = page.locator('#nearby'); if ((await section.count()) === 0) { test.skip(true, 'No qualifying similar schools for this school'); } diff --git a/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx b/nextjs-app/__tests__/components/NearbySchoolsSection.test.tsx similarity index 66% rename from nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx rename to nextjs-app/__tests__/components/NearbySchoolsSection.test.tsx index 646ad89..0f042d8 100644 --- a/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx +++ b/nextjs-app/__tests__/components/NearbySchoolsSection.test.tsx @@ -1,32 +1,32 @@ /** - * The section's job is to be honest about what it matched. These tests pin the - * ways it could lie: rendering below the minimum, claiming a similar intake at - * tier 3, showing a missing figure as a number, or hiding a card behind an - * arrow where a crawler cannot reach it. + * The section's job is to be honest about what it is showing. These tests pin + * the ways it could mislead: rendering below the minimum, claiming a likeness + * it does not rank on, showing a missing figure as a number, or hiding a card + * behind an arrow where a crawler cannot reach it. */ import { render, screen } from '@testing-library/react'; import { nearbyNoun, - SimilarSchoolsSection, - shouldRenderSimilar, -} from '@/components/school/SimilarSchoolsSection'; -import type { SimilarSchool } from '@/lib/types'; + NearbySchoolsSection, + shouldRenderNearby, +} from '@/components/school/NearbySchoolsSection'; +import type { NearbySchool } from '@/lib/types'; jest.mock('@/components/school/AddToCompareButton', () => ({ - AddToCompareButton: ({ school }: { school: SimilarSchool }) => ( + AddToCompareButton: ({ school }: { school: NearbySchool }) => ( ), })); -jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({ - SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => ( +jest.mock('@/components/school/NearbySchoolsCompareBar', () => ({ + NearbySchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
bar for {thisUrn}
), })); -function school(overrides: Partial = {}): SimilarSchool { +function school(overrides: Partial = {}): NearbySchool { return { urn: 100002, school_name: 'Willow Lane Primary School', @@ -34,7 +34,6 @@ function school(overrides: Partial = {}): SimilarSchool { school_type: 'Community school', age_range: '4-11', shared: ['Mixed', 'No religious character'], - tier: 1, metric_value: 74, metric_key: 'rwm_expected_pct', metric_year: 202425, @@ -42,14 +41,14 @@ function school(overrides: Partial = {}): SimilarSchool { }; } -function renderSection(similar: SimilarSchool[]) { +function renderSection(nearby: NearbySchool[]) { return render( - , ); } @@ -61,11 +60,11 @@ describe('render gates', () => { ['empty', []], ['a single school', [school()]], ])('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', () => { - 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', () => { @@ -74,15 +73,36 @@ describe('render gates', () => { }); }); -describe('the claim the lede makes', () => { - it('claims a similar intake when every card is tier 1 or 2', () => { - renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]); - expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument(); +describe('what the section claims', () => { + it('never claims a similar intake, because it does not rank on one', () => { + renderSection([school(), school({ urn: 100003, shared: [] })]); + expect(screen.queryByText(/similar intake/i)).not.toBeInTheDocument(); }); - it('drops the claim when any card is tier 3', () => { - renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]); - expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument(); + it('is headed "Other schools nearby", not "similar"', () => { + renderSection([school(), school({ urn: 100003 })]); + 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( + , + ); + // 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', () => { render( - , ); expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument(); diff --git a/nextjs-app/__tests__/lib/schoolSections.test.ts b/nextjs-app/__tests__/lib/schoolSections.test.ts index fdd807a..6bff0d3 100644 --- a/nextjs-app/__tests__/lib/schoolSections.test.ts +++ b/nextjs-app/__tests__/lib/schoolSections.test.ts @@ -173,7 +173,7 @@ describe('buildSecondaryNavItems', () => { }); }); -describe('the similar-schools nav item', () => { +describe('the nearby-schools nav item', () => { const navInput = { ofsted: null, admissions: null, admissionDistance: null, hasLocation: true, yearlyDataLength: 1, @@ -182,29 +182,29 @@ describe('the similar-schools nav item', () => { it('appears on both templates when the section renders', () => { const primary = computeSchoolFlags(primaryFixture); 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(buildSecondaryNavItems(secondary, 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('nearby'); }); it('is absent when the section does not render', () => { const primary = computeSchoolFlags(primaryFixture); 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(buildSecondaryNavItems(secondary, 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('nearby'); }); it('is absent when nothing says either way', () => { 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', () => { const primary = computeSchoolFlags(primaryFixture); - const ids = buildNavItems(primary, { ...navInput, hasSimilarSchools: true }).map((i) => i.id); - expect(ids[ids.length - 1]).toBe('similar'); + const ids = buildNavItems(primary, { ...navInput, hasNearbySchools: true }).map((i) => i.id); + expect(ids[ids.length - 1]).toBe('nearby'); }); }); diff --git a/nextjs-app/app/(frontend)/school/[slug]/page.tsx b/nextjs-app/app/(frontend)/school/[slug]/page.tsx index 23b98e8..838ab67 100644 --- a/nextjs-app/app/(frontend)/school/[slug]/page.tsx +++ b/nextjs-app/app/(frontend)/school/[slug]/page.tsx @@ -8,7 +8,7 @@ import { APIFetchError, fetchSchoolDetails, fetchSchools, fetchNationalAverages import { notFound, redirect } from 'next/navigation'; import { SchoolDetailShell } from '@/components/school/SchoolDetailShell'; 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 { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections'; import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections'; @@ -157,7 +157,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { // lockstep deploy of the two images. const places: SchoolPlace[] = data.places ?? []; // 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 const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -189,7 +189,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { admissions: admissions ?? null, admissionDistance: admission_distance ?? null, hasLocation: school_info.latitude != null && school_info.longitude != null, - hasSimilarSchools: shouldRenderSimilar(similarSchools), + hasNearbySchools: shouldRenderNearby(nearbySchools), yearlyDataLength: yearly_data.length, }; const primaryNavItems = buildNavItems(primaryFlags, navInput); @@ -266,7 +266,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { finance={finance ?? null} nationalAvg={nationalAvg} destinations={destinations ?? null} - similarSchools={similarSchools} + nearbySchools={nearbySchools} flags={secondaryFlags} /> @@ -289,7 +289,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} - similarSchools={similarSchools} + nearbySchools={nearbySchools} flags={primaryFlags} /> diff --git a/nextjs-app/components/school/AddToCompareButton.tsx b/nextjs-app/components/school/AddToCompareButton.tsx index b263969..dd519ea 100644 --- a/nextjs-app/components/school/AddToCompareButton.tsx +++ b/nextjs-app/components/school/AddToCompareButton.tsx @@ -9,10 +9,10 @@ */ import { useComparisonContext } from '@/context/ComparisonContext'; -import type { School, SimilarSchool } from '@/lib/types'; -import styles from './SimilarSchools.module.css'; +import type { School, NearbySchool } from '@/lib/types'; +import styles from './NearbySchools.module.css'; -export function AddToCompareButton({ school }: { school: SimilarSchool }) { +export function AddToCompareButton({ school }: { school: NearbySchool }) { const { addSchool, removeSchool, selectedSchools } = useComparisonContext(); const selected = selectedSchools.some((s) => s.urn === school.urn); diff --git a/nextjs-app/components/school/SimilarSchools.module.css b/nextjs-app/components/school/NearbySchools.module.css similarity index 96% rename from nextjs-app/components/school/SimilarSchools.module.css rename to nextjs-app/components/school/NearbySchools.module.css index 2427b7a..82bad54 100644 --- a/nextjs-app/components/school/SimilarSchools.module.css +++ b/nextjs-app/components/school/NearbySchools.module.css @@ -39,7 +39,6 @@ .shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; } .chip { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: var(--brand-bg); color: var(--brand); border: 1px solid transparent; } -.chipLoose { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); } .metric { margin-top: auto; padding-top: 0.8rem; border-top: 1px solid var(--border); } /* No valence colour here, deliberately: green and terracotta mean "against the diff --git a/nextjs-app/components/school/SimilarSchoolsCarousel.tsx b/nextjs-app/components/school/NearbySchoolsCarousel.tsx similarity index 97% rename from nextjs-app/components/school/SimilarSchoolsCarousel.tsx rename to nextjs-app/components/school/NearbySchoolsCarousel.tsx index 8b748a4..ccc59fd 100644 --- a/nextjs-app/components/school/SimilarSchoolsCarousel.tsx +++ b/nextjs-app/components/school/NearbySchoolsCarousel.tsx @@ -14,7 +14,7 @@ */ 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. * Below 640px the arrows are not rendered at all — see the stylesheet. */ @@ -31,7 +31,7 @@ const VISIBLE = 3; */ const EDGE = 8; -export function SimilarSchoolsCarousel({ +export function NearbySchoolsCarousel({ count, labelledBy, header, diff --git a/nextjs-app/components/school/SimilarSchoolsCompareBar.tsx b/nextjs-app/components/school/NearbySchoolsCompareBar.tsx similarity index 90% rename from nextjs-app/components/school/SimilarSchoolsCompareBar.tsx rename to nextjs-app/components/school/NearbySchoolsCompareBar.tsx index 8e0050b..ff99478 100644 --- a/nextjs-app/components/school/SimilarSchoolsCompareBar.tsx +++ b/nextjs-app/components/school/NearbySchoolsCompareBar.tsx @@ -11,15 +11,15 @@ import Link from 'next/link'; import { useComparisonContext } from '@/context/ComparisonContext'; -import type { SimilarSchool } from '@/lib/types'; -import styles from './SimilarSchools.module.css'; +import type { NearbySchool } from '@/lib/types'; +import styles from './NearbySchools.module.css'; -export function SimilarSchoolsCompareBar({ +export function NearbySchoolsCompareBar({ thisUrn, candidates, }: { thisUrn: number; - candidates: SimilarSchool[]; + candidates: NearbySchool[]; }) { const { selectedSchools } = useComparisonContext(); diff --git a/nextjs-app/components/school/SimilarSchoolsSection.tsx b/nextjs-app/components/school/NearbySchoolsSection.tsx similarity index 67% rename from nextjs-app/components/school/SimilarSchoolsSection.tsx rename to nextjs-app/components/school/NearbySchoolsSection.tsx index 76db7e1..07d4bba 100644 --- a/nextjs-app/components/school/SimilarSchoolsSection.tsx +++ b/nextjs-app/components/school/NearbySchoolsSection.tsx @@ -1,12 +1,16 @@ /** - * SimilarSchoolsSection — nearby schools of the same phase and a comparable - * intake. Server component; only the carousel, the compare bar and the - * add-to-compare button are client-side. + * NearbySchoolsSection — the nearest eligible schools, closest first. Server + * component; only the carousel, the compare bar and the add-to-compare button + * are client-side. * - * The section is allowed to say exactly what the backend matched and no more. - * The lede only claims a similar intake when no card came from tier 3, and a - * card's chips list what that school actually shares rather than a match it - * did not earn. + * "Other schools nearby", not "similar" ones: the order is distance and only + * distance. The hard filters upstream still guarantee the set is comparable — + * same phase, same selectivity, mainstream never beside special — but nothing + * 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 * visible in the lede, the chips and the distances. The single caption line is @@ -14,18 +18,18 @@ */ import Link from 'next/link'; -import type { SimilarSchool } from '@/lib/types'; +import type { NearbySchool } from '@/lib/types'; import { schoolUrl } from '@/lib/utils'; import { AddToCompareButton } from './AddToCompareButton'; -import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel'; -import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar'; +import { NearbySchoolsCarousel } from './NearbySchoolsCarousel'; +import { NearbySchoolsCompareBar } from './NearbySchoolsCompareBar'; import { Section } from './sectionShared'; -import styles from './SimilarSchools.module.css'; +import styles from './NearbySchools.module.css'; const MINIMUM = 2; -export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean { - return (similar?.length ?? 0) >= MINIMUM; +export function shouldRenderNearby(nearby?: NearbySchool[] | null): boolean { + 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)}%`; } -export function SimilarSchoolsSection({ +export function NearbySchoolsSection({ urn, schoolName, phase, thisMetricValue, - similar, + nearby, }: { urn: number; schoolName: string; /** The school's own GIAS phase, not the template it renders with. */ phase: string | null | undefined; thisMetricValue: number | null; - similar?: SimilarSchool[] | null; + nearby?: NearbySchool[] | null; }) { - if (!shouldRenderSimilar(similar)) return null; - const schools = similar as SimilarSchool[]; + if (!shouldRenderNearby(nearby)) return null; + const schools = nearby as NearbySchool[]; // One card matched on phase alone, so the section may not claim the set // shares an intake with this school. - const loosest = Math.max(...schools.map((s) => s.tier)); const metricKey = schools[0].metric_key; const noun = nearbyNoun(phase); return ( -
- + -

- Similar schools nearby +

+ Other schools nearby

-

- {loosest >= 3 - ? `Other ${noun} near ${schoolName}.` - : `Other ${noun} near ${schoolName}, with a similar intake.`} -

+

{`Other ${noun} near ${schoolName}.`}

} > @@ -113,13 +112,13 @@ export function SimilarSchoolsSection({ .filter(Boolean) .join(' · ')}

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

))} - + - + {/* 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 diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx index 18bf76c..b7dd319 100644 --- a/nextjs-app/components/school/PrimarySchoolSections.tsx +++ b/nextjs-app/components/school/PrimarySchoolSections.tsx @@ -14,7 +14,7 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, - SimilarSchool, + NearbySchool, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SchoolFlags } from '@/lib/schoolSections'; @@ -27,7 +27,7 @@ import { HistorySection } from './HistorySection'; import { SchoolLifeSection } from './SchoolLifeSection'; import { LocalAreaSection } from './LocalAreaSection'; import { FinancesSection } from './FinancesSection'; -import { SimilarSchoolsSection } from './SimilarSchoolsSection'; +import { NearbySchoolsSection } from './NearbySchoolsSection'; export interface PrimarySchoolSectionsProps { schoolInfo: School; @@ -42,14 +42,14 @@ export interface PrimarySchoolSectionsProps { finance: SchoolFinance | null; nationalAvg: NationalAverages | null; /** Nearby schools of a comparable intake. Absent on an older API build. */ - similarSchools?: SimilarSchool[]; + nearbySchools?: NearbySchool[]; flags: SchoolFlags; } export function PrimarySchoolSections({ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, admissionDistance, - deprivation, finance, nationalAvg, similarSchools, flags, + deprivation, finance, nationalAvg, nearbySchools, flags, }: PrimarySchoolSectionsProps) { const primaryAvg = nationalAvg?.primary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -152,12 +152,12 @@ export function PrimarySchoolSections({ {flags.hasFinance && finance && } {/* Last: it is where the reader goes next, not part of this school. */} - ); diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index a660e1f..697dfaa 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -14,7 +14,7 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, - SchoolDestinations, SimilarSchool, + SchoolDestinations, NearbySchool, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SecondaryFlags } from '@/lib/schoolSections'; @@ -27,7 +27,7 @@ import { DistanceSection } from './DistanceSection'; import { SecondaryHistorySection } from './SecondaryHistorySection'; import { WellbeingSection } from './WellbeingSection'; import { FinancesSection } from './FinancesSection'; -import { SimilarSchoolsSection } from './SimilarSchoolsSection'; +import { NearbySchoolsSection } from './NearbySchoolsSection'; import styles from './schoolSections.module.css'; export interface SecondarySchoolSectionsProps { @@ -49,14 +49,14 @@ export interface SecondarySchoolSectionsProps { nationalAvg: NationalAverages | null; destinations: SchoolDestinations | null; /** Nearby schools of a comparable intake. Absent on an older API build. */ - similarSchools?: SimilarSchool[]; + nearbySchools?: NearbySchool[]; flags: SecondaryFlags; } export function SecondarySchoolSections({ schoolInfo, yearlyData, ofsted, census, admissions, admissionsHistory, admissionDistance, - deprivation, finance, nationalAvg, destinations, similarSchools, flags, + deprivation, finance, nationalAvg, destinations, nearbySchools, flags, }: SecondarySchoolSectionsProps) { const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -146,12 +146,12 @@ export function SecondarySchoolSections({ )} {/* Last: it is where the reader goes next, not part of this school. */} -

); diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts index fedc0be..dc7dc7a 100644 --- a/nextjs-app/lib/schoolSections.ts +++ b/nextjs-app/lib/schoolSections.ts @@ -128,10 +128,10 @@ export interface NavItemsInput { * measure a postcode, so the nav must gate on them too or it will link to an * anchor that was never rendered. */ 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 * rendered, and absent has to mean "no section". */ - hasSimilarSchools?: boolean; + hasNearbySchools?: boolean; yearlyDataLength: number; } @@ -148,7 +148,7 @@ export function buildNavItems( flags: SchoolFlags, { ofsted, admissions, admissionDistance, hasLocation, - hasSimilarSchools, yearlyDataLength, + hasNearbySchools, yearlyDataLength, }: NavItemsInput, ): NavItem[] { const navItems: NavItem[] = []; @@ -169,7 +169,7 @@ export function buildNavItems( if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); // 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; } @@ -250,7 +250,7 @@ export function buildSecondaryNavItems( flags: SecondaryFlags, { ofsted, admissions, admissionDistance, hasLocation, - hasSimilarSchools, yearlyDataLength, + hasNearbySchools, yearlyDataLength, }: NavItemsInput, ): NavItem[] { const navItems: NavItem[] = []; @@ -270,6 +270,6 @@ export function buildSecondaryNavItems( if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); // 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; } diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 581c88a..d2fdc97 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -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 - * drives two separate decisions — whether the lede may claim a similar intake, - * and whether a chip renders as a fill or a muted outline — and inferring it - * from chip count would couple those decisions to the copy. + * `shared` is what this school genuinely has in common with the one being + * viewed, and may be empty. It is reported, never ranked on: an earlier + * version ordered by it and buried the school down the road under faith + * matches three times further away. */ -export interface SimilarSchool { +export interface NearbySchool { urn: number; school_name: string; distance_miles: number; school_type: string | null; age_range: string | null; shared: string[]; - tier: number; metric_value: number | null; metric_key: string; metric_year: number | null; @@ -379,13 +378,13 @@ export interface SchoolDetailsResponse { */ 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 * API that serves this must render without it. Absent and empty mean the * same thing here — no section. */ - similar_schools?: SimilarSchool[]; + nearby_schools?: NearbySchool[]; yearly_data: SchoolResult[]; absence_data: AbsenceData | null; // Supplementary data (null until Kestra populates)