refactor: rename similar → nearby, so the code says what the section does

The section ranks on distance and is headed "Other schools nearby", but every
identifier still called it "similar" — the exact drift that leaves a later
reader trusting a name over the behaviour.

Mechanical: files, the module, the payload key, the type, the components, the
prop. No behaviour change; the suites are unchanged in count and still green.
Free to do now because #150 has not merged, so the payload key rename needs no
lockstep deploy. Uses of "similar" that are ordinary English — progress
measures compared to similar pupils, and unrelated comments — are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-22 13:06:32 +01:00
1 parent 5c0ccc693d
commit cd6a45bf7d
15 files changed
+102 -102

No files matched your search

+6 -6
View File
@@ -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 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,10 +266,10 @@ def _places_payload(urn: int) -> list[dict]:
return payload return payload
def _similar_schools_payload(urn: int) -> list[dict]: def _nearby_schools_payload(urn: int) -> list[dict]:
"""The nearest eligible schools this page may offer, closest first. """The nearest eligible schools this page may offer, closest first.
Phase and reach are read from the school's own row inside select_similar, 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. 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
@@ -277,12 +277,12 @@ def _similar_schools_payload(urn: int) -> list[dict]:
section simply does not render. section simply does not render.
""" """
try: try:
return select_similar(load_latest_school_data(), int(urn)) return select_nearby(load_latest_school_data(), int(urn))
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 []
@@ -1000,7 +1000,7 @@ async def get_school_details(request: Request, urn: int):
# The nearest eligible schools, closest first. Always present on a # The nearest eligible schools, closest first. Always present on a
# build with this code; the frontend treats absent and empty # build with this code; the frontend treats absent and empty
# identically, which is what lets the two images deploy independently. # identically, which is what lets the two images deploy independently.
"similar_schools": _similar_schools_payload(urn), "nearby_schools": _nearby_schools_payload(urn),
"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"),
@@ -183,7 +183,7 @@ def _shared(subject: pd.Series, candidate: pd.Series, is_secondary: bool) -> lis
return shared return shared
def select_similar(frame: pd.DataFrame, urn: int) -> list[dict]: def select_nearby(frame: pd.DataFrame, urn: int) -> list[dict]:
"""The nearest eligible schools, closest first — at most MAX_SCHOOLS, and """The nearest eligible schools, closest first — at most MAX_SCHOOLS, and
none at all below MINIMUM. none at all below MINIMUM.
+1 -1
View File
@@ -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"},
@@ -15,10 +15,10 @@ 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 ( from backend.nearby_schools import (
is_secondary_phase, is_secondary_phase,
radius_miles, radius_miles,
select_similar, select_nearby,
) )
BASE_LAT, BASE_LON = 51.5000, -0.1000 BASE_LAT, BASE_LON = 51.5000, -0.1000
@@ -66,7 +66,7 @@ def test_returns_nearest_first():
_row(100003, "Near", latitude=_at(0.4)), _row(100003, "Near", latitude=_at(0.4)),
_row(100004, "Far", latitude=_at(1.8)), _row(100004, "Far", latitude=_at(1.8)),
) )
result = select_similar(frame, 100001) result = select_nearby(frame, 100001)
assert [s["urn"] for s in result] == [100003, 100002, 100004] assert [s["urn"] for s in result] == [100003, 100002, 100004]
assert result[0]["distance_miles"] == 0.4 assert result[0]["distance_miles"] == 0.4
@@ -81,7 +81,7 @@ def test_a_faith_match_never_outranks_a_closer_school():
_row(100004, "Sacred Heart RC", religious_denomination="Roman Catholic", latitude=_at(1.2)), _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)), _row(100005, "St Peter's RC", religious_denomination="Roman Catholic", latitude=_at(1.6)),
) )
result = select_similar(frame, 100001) result = select_nearby(frame, 100001)
assert result[0]["urn"] == 100002, "the nearest school leads, whatever its intake" 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) assert [s["distance_miles"] for s in result] == sorted(s["distance_miles"] for s in result)
@@ -98,7 +98,7 @@ def test_the_nearest_eligible_school_is_always_shown():
for n in range(6) for n in range(6)
], ],
) )
assert select_similar(frame, 100001)[0]["urn"] == 100002 assert select_nearby(frame, 100001)[0]["urn"] == 100002
def test_caps_at_six_taking_the_nearest(): def test_caps_at_six_taking_the_nearest():
@@ -106,7 +106,7 @@ def test_caps_at_six_taking_the_nearest():
_row(100001, "Subject"), _row(100001, "Subject"),
*[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)], *[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)],
) )
result = select_similar(frame, 100001) result = select_nearby(frame, 100001)
assert len(result) == 6 assert len(result) == 6
assert 100016 not in {s["urn"] for s in result}, "the seventh-nearest is the one dropped" assert 100016 not in {s["urn"] for s in result}, "the seventh-nearest is the one dropped"
@@ -116,7 +116,7 @@ def test_fewer_than_two_matches_returns_empty():
_row(100001, "Subject"), _row(100001, "Subject"),
_row(100002, "Only neighbour", latitude=_at(0.5)), _row(100002, "Only neighbour", latitude=_at(0.5)),
) )
assert select_similar(frame, 100001) == [] assert select_nearby(frame, 100001) == []
def test_excludes_the_subject_school(): def test_excludes_the_subject_school():
@@ -125,7 +125,7 @@ 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)} assert 100001 not in {s["urn"] for s in select_nearby(frame, 100001)}
def test_a_school_is_never_listed_twice(): def test_a_school_is_never_listed_twice():
@@ -134,7 +134,7 @@ def test_a_school_is_never_listed_twice():
_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)),
) )
result = select_similar(frame, 100001) result = select_nearby(frame, 100001)
assert len(result) == len({s["urn"] for s in result}) assert len(result) == len({s["urn"] for s in result})
@@ -151,7 +151,7 @@ def test_primary_does_not_reach_past_two_miles():
) )
# One inside the cap is below the minimum, so nothing renders at all — # One inside the cap is below the minimum, so nothing renders at all —
# a primary with nothing within two miles has no nearby schools. # a primary with nothing within two miles has no nearby schools.
assert select_similar(frame, 100001) == [] assert select_nearby(frame, 100001) == []
def test_secondary_reaches_further_than_primary(): def test_secondary_reaches_further_than_primary():
@@ -160,7 +160,7 @@ def test_secondary_reaches_further_than_primary():
_row(100002, "A", phase="Secondary", latitude=_at(3.0)), _row(100002, "A", phase="Secondary", latitude=_at(3.0)),
_row(100003, "B", phase="Secondary", latitude=_at(5.5)), _row(100003, "B", phase="Secondary", latitude=_at(5.5)),
) )
assert {s["urn"] for s in select_similar(frame, 100001)} == {100002, 100003} assert {s["urn"] for s in select_nearby(frame, 100001)} == {100002, 100003}
def test_the_cap_follows_the_phase(): def test_the_cap_follows_the_phase():
@@ -183,8 +183,8 @@ def test_selective_never_meets_non_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) == [] assert select_nearby(frame, 100001) == []
assert 100001 not in {s["urn"] for s in select_similar(frame, 100002)} assert 100001 not in {s["urn"] for s in select_nearby(frame, 100002)}
def test_special_schools_match_only_each_other(): def test_special_schools_match_only_each_other():
@@ -193,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) == [] assert select_nearby(frame, 100001) == []
assert select_similar(frame, 100002) == [] assert select_nearby(frame, 100002) == []
def test_boys_never_meets_girls(): def test_boys_never_meets_girls():
@@ -204,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)} 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}
@@ -217,7 +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)} == {100004, 100005} assert {s["urn"] for s in select_nearby(frame, 100001)} == {100004, 100005}
def test_all_through_is_offered_on_both_phase_sides(): def test_all_through_is_offered_on_both_phase_sides():
@@ -226,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)} 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)} 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():
@@ -248,7 +248,7 @@ def test_sixteen_plus_is_matched_against_secondary_not_primary():
_row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6)), _row(100003, "Nearby College", phase="16 plus", latitude=_at(0.6)),
_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) 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}
@@ -278,7 +278,7 @@ def test_shared_lists_only_what_is_actually_shared():
_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)} 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"]
@@ -289,7 +289,7 @@ def test_a_shared_faith_is_named():
_row(100002, "Also RC", religious_denomination="Roman Catholic", latitude=_at(0.4)), _row(100002, "Also RC", religious_denomination="Roman Catholic", latitude=_at(0.4)),
_row(100003, "Secular", religious_denomination="None", latitude=_at(0.5)), _row(100003, "Secular", religious_denomination="None", latitude=_at(0.5)),
) )
by_urn = {s["urn"]: s for s in select_similar(frame, 100001)} by_urn = {s["urn"]: s for s in select_nearby(frame, 100001)}
assert "Roman Catholic" in by_urn[100002]["shared"] assert "Roman Catholic" in by_urn[100002]["shared"]
assert by_urn[100003]["shared"] == ["Mixed"] assert by_urn[100003]["shared"] == ["Mixed"]
@@ -300,7 +300,7 @@ def test_shared_is_empty_when_nothing_is_shared():
_row(100002, "A", gender="Mixed", religious_denomination="None", latitude=_at(0.4)), _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)), _row(100003, "B", gender="Mixed", religious_denomination="Church of England", latitude=_at(0.5)),
) )
assert all(s["shared"] == [] for s in select_similar(frame, 100001)) assert all(s["shared"] == [] for s in select_nearby(frame, 100001))
def test_no_tier_is_reported_because_there_are_no_tiers(): def test_no_tier_is_reported_because_there_are_no_tiers():
@@ -309,7 +309,7 @@ def test_no_tier_is_reported_because_there_are_no_tiers():
_row(100002, "A", latitude=_at(0.4)), _row(100002, "A", latitude=_at(0.4)),
_row(100003, "B", latitude=_at(0.5)), _row(100003, "B", latitude=_at(0.5)),
) )
assert all("tier" not in s for s in select_similar(frame, 100001)) assert all("tier" not in s for s in select_nearby(frame, 100001))
def test_metric_follows_the_phase_side_not_the_neighbour(): def test_metric_follows_the_phase_side_not_the_neighbour():
@@ -318,7 +318,7 @@ def test_metric_follows_the_phase_side_not_the_neighbour():
_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)} 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
@@ -331,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): 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)
@@ -366,7 +366,7 @@ def client(monkeypatch):
def test_detail_payload_carries_nearby_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"
@@ -377,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"] == []
+6 -6
View File
@@ -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
@@ -9,24 +9,24 @@ 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',
@@ -41,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}
/>, />,
); );
} }
@@ -60,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', () => {
@@ -91,12 +91,12 @@ describe('what the section claims', () => {
it('shows no chips at all when nothing is shared, rather than inventing one', () => { it('shows no chips at all when nothing is shared, rather than inventing one', () => {
const { container } = render( const { container } = render(
<SimilarSchoolsSection <NearbySchoolsSection
urn={100001} urn={100001}
schoolName="Meadowbrook Primary School" schoolName="Meadowbrook Primary School"
phase="Primary" phase="Primary"
thisMetricValue={72} thisMetricValue={72}
similar={[school({ shared: [] }), school({ urn: 100003, shared: [] })]} nearby={[school({ shared: [] }), school({ urn: 100003, shared: [] })]}
/>, />,
); );
// The card still carries its distance, name, type and figure — just no // The card still carries its distance, name, type and figure — just no
@@ -124,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();
@@ -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,
hasNearbySchools: 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);
@@ -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,
@@ -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();
@@ -18,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;
} }
/** /**
@@ -63,22 +63,22 @@ 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.
@@ -87,7 +87,7 @@ export function SimilarSchoolsSection({
return ( return (
<Section id="nearby"> <Section id="nearby">
<SimilarSchoolsCarousel <NearbySchoolsCarousel
count={schools.length} count={schools.length}
labelledBy="nearby-schools-heading" labelledBy="nearby-schools-heading"
header={ header={
@@ -137,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>
); );
+2 -2
View File
@@ -354,7 +354,7 @@ export interface SchoolsResponse {
* version ordered by it and buried the school down the road under faith * version ordered by it and buried the school down the road under faith
* matches three times further away. * 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;
@@ -384,7 +384,7 @@ export interface SchoolDetailsResponse {
* 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)