2026-09-22 12:00:21 +01:00
|
|
|
|
# Other Schools Nearby — Design
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**Date:** 2026-09-21, revised 2026-09-22
|
|
|
|
|
|
**Status:** revised after staging review
|
2026-09-21 22:18:38 +01:00
|
|
|
|
**Scope:** school detail pages, both phase templates
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
> **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.
|
|
|
|
|
|
|
2026-09-21 22:18:38 +01:00
|
|
|
|
## Goal
|
|
|
|
|
|
|
|
|
|
|
|
Give a school detail page an answer to the question every reader arrives with
|
|
|
|
|
|
after the results tables: *and what else is around here?*
|
|
|
|
|
|
|
|
|
|
|
|
Today a school page links outward to its place pages through
|
|
|
|
|
|
`components/school/NearbyPlaces.tsx` and nowhere else. It never links to another
|
2026-09-21 22:35:11 +01:00
|
|
|
|
school. This section adds that edge — up to six nearby schools of the same phase
|
|
|
|
|
|
and a comparable intake, three at a time in a carousel, each a crawlable link and
|
|
|
|
|
|
each addable to the comparison basket in one click.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
Mockup, in both themes (drawn against the original tiered design, so its ledes
|
|
|
|
|
|
and chip fallbacks are one revision behind the copy specified below):
|
2026-09-21 22:18:38 +01:00
|
|
|
|
<https://claude.ai/artifact/168KdUMcfkUeGWW2FGjuec>
|
|
|
|
|
|
|
|
|
|
|
|
Source of the same page in the repo: `mockups/similar-schools-nearby.html`.
|
|
|
|
|
|
|
|
|
|
|
|
## The constraint that shapes everything
|
|
|
|
|
|
|
|
|
|
|
|
**A nearby school is not automatically a comparable school.**
|
|
|
|
|
|
|
|
|
|
|
|
The section's whole value is that a reader treats what it shows as a shortlist.
|
|
|
|
|
|
That makes every card an implicit claim that the school is a realistic
|
|
|
|
|
|
alternative, and there are three ways that claim goes wrong:
|
|
|
|
|
|
|
|
|
|
|
|
1. A **selective** school beside a non-selective one. Their intakes are
|
|
|
|
|
|
different by construction, so putting their Attainment 8 figures side by side
|
|
|
|
|
|
invites a conclusion the data cannot support.
|
|
|
|
|
|
2. A **special school, PRU or AP** beside a mainstream school. This is the same
|
|
|
|
|
|
error PR #70 fixed for the England benchmark, where Greenmead (URN 101099)
|
|
|
|
|
|
rendered "0% — 62 below England".
|
|
|
|
|
|
3. A **single-sex** school of the opposite sex. Not a weak match — not an option
|
|
|
|
|
|
at all.
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
So the design separates two kinds of fact, and never confuses them:
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- **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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
## Selection algorithm
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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
|
2026-09-21 22:18:38 +01:00
|
|
|
|
`load_latest_school_data()` frame — one row per URN, already carrying
|
|
|
|
|
|
`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`,
|
|
|
|
|
|
`admissions_policy`, `school_type` and `status`.
|
|
|
|
|
|
|
|
|
|
|
|
### Hard filters
|
|
|
|
|
|
|
|
|
|
|
|
| Filter | Rule |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| Self | `urn` is excluded |
|
|
|
|
|
|
| Status | GIAS status must be open |
|
|
|
|
|
|
| Coordinates | both `latitude` and `longitude` present on both schools |
|
|
|
|
|
|
| Phase | same phase group via the existing `PHASE_GROUPS` map |
|
|
|
|
|
|
| Provision | special/PRU/AP match only each other |
|
|
|
|
|
|
| Selectivity | selective matches selective; non-selective matches non-selective |
|
|
|
|
|
|
| Gender | Boys never matches Girls; Mixed is compatible with both |
|
|
|
|
|
|
|
|
|
|
|
|
`PHASE_GROUPS` is reused rather than re-derived so an all-through school is
|
|
|
|
|
|
offered correctly on both the primary and secondary sides, exactly as it already
|
|
|
|
|
|
behaves in search.
|
|
|
|
|
|
|
|
|
|
|
|
The provision filter needs a backend counterpart to the frontend's
|
|
|
|
|
|
`isSpecialSchool()` in `nextjs-app/lib/utils.ts:897`, reading the same GIAS
|
|
|
|
|
|
establishment types through `backend/gias_codes.py`. The two must agree: a
|
|
|
|
|
|
school the frontend treats as special for benchmarking but the backend treats as
|
|
|
|
|
|
mainstream for matching would be dropped from its own England comparison and
|
|
|
|
|
|
then offered as a peer to a mainstream school on the next page along.
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
### Order: distance, and nothing else
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
The nearest eligible schools, closest first. Similarity does not enter the
|
|
|
|
|
|
ranking at any point.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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:
|
2026-09-21 22:35:11 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- 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.
|
2026-09-21 22:35:11 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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.
|
2026-09-21 22:35:11 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
The hard filters above were always where the defensibility lived. They are
|
|
|
|
|
|
untouched.
|
2026-09-21 22:35:11 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
### Reach: a sanity bound, not a target
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
| Phase | Reach |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| Primary, middle deemed primary, all-through | 2 miles |
|
|
|
|
|
|
| Secondary, middle deemed secondary | 6 miles |
|
|
|
|
|
|
| 16 plus | 10 miles |
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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.
|
2026-09-21 22:29:07 +01:00
|
|
|
|
|
2026-09-21 22:18:38 +01:00
|
|
|
|
**Fewer than two results renders nothing.** Not an empty state, not a single
|
2026-09-22 12:00:21 +01:00
|
|
|
|
lonely card. The section is absent, the nav item is absent, and the page is
|
|
|
|
|
|
unchanged from before it existed.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
### Distance
|
|
|
|
|
|
|
|
|
|
|
|
Straight-line, from the vectorised haversine already used for postcode search at
|
|
|
|
|
|
`backend/app.py:831`, computed over the ~27k-row frame in numpy. Reported to one
|
|
|
|
|
|
decimal place in miles, consistent with the rest of the site.
|
|
|
|
|
|
|
|
|
|
|
|
Straight-line distance is not road distance and is not measured from the
|
|
|
|
|
|
reader's home. The section says so in its disclosure rather than leaving the
|
|
|
|
|
|
reader to assume otherwise.
|
|
|
|
|
|
|
|
|
|
|
|
## API
|
|
|
|
|
|
|
|
|
|
|
|
`/api/schools/{urn}` gains a `similar_schools` array. Each row:
|
|
|
|
|
|
|
|
|
|
|
|
| Field | Notes |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `urn` | for the link and the compare basket |
|
|
|
|
|
|
| `school_name` | link text |
|
|
|
|
|
|
| `distance_miles` | one decimal place |
|
|
|
|
|
|
| `school_type` | GIAS type, translated, for the card's meta line |
|
|
|
|
|
|
| `age_range` | for the meta line |
|
2026-09-22 12:00:21 +01:00
|
|
|
|
| `shared` | what this school genuinely shares with the subject; may be empty |
|
2026-09-21 22:18:38 +01:00
|
|
|
|
| `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 |
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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
|
2026-09-22 06:41:59 +01:00
|
|
|
|
side uses `attainment_8_score`; the primary side uses `rwm_expected_pct`. Where
|
|
|
|
|
|
the neighbour has no value for that key, the card reads "Not published" rather
|
|
|
|
|
|
than falling back to the other key.
|
|
|
|
|
|
|
|
|
|
|
|
Which side a school takes is decided once, in
|
|
|
|
|
|
`similar_schools.is_secondary_phase`, by membership of `PHASE_GROUPS["secondary"]`
|
|
|
|
|
|
minus all-through — never by testing for the substring "secondary", which misses
|
|
|
|
|
|
`16 plus` (GIAS phase 6) and hands a sixth-form college the primary bucket.
|
|
|
|
|
|
All-through is the exception in the other direction: `PHASE_GROUPS` lists it on
|
|
|
|
|
|
both sides, but it takes the primary metric.
|
|
|
|
|
|
|
|
|
|
|
|
This is usually the same thing as "the template the page renders", but not
|
|
|
|
|
|
always. `computeSchoolFlags` decides the template with that same substring test,
|
|
|
|
|
|
so a `16 plus` school renders `PrimarySchoolSections` while being matched —
|
|
|
|
|
|
correctly — against secondaries. The section therefore takes its lede noun from
|
|
|
|
|
|
the school's own phase rather than from its template, or it would print "Other
|
|
|
|
|
|
primary schools near <sixth form college>" above a row of secondaries.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-21 22:35:11 +01:00
|
|
|
|
Up to six rows of roughly 130 bytes each. It rides in the existing detail payload
|
2026-09-21 22:18:38 +01:00
|
|
|
|
rather than a new endpoint because the page already makes exactly one server
|
|
|
|
|
|
fetch for its data, and `/school/[slug]` regenerates at most weekly
|
|
|
|
|
|
(`revalidate = 604800`), so the per-request cost is paid once per school per
|
|
|
|
|
|
week.
|
|
|
|
|
|
|
|
|
|
|
|
**The key is absent, not null, on a backend that does not have this code.** The
|
|
|
|
|
|
frontend treats absent and empty identically, which is what allowed
|
|
|
|
|
|
`NearbyPlaces` to ship without a lockstep deploy of the two images.
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
`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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
## Frontend
|
|
|
|
|
|
|
|
|
|
|
|
### Components
|
|
|
|
|
|
|
|
|
|
|
|
`components/school/SimilarSchoolsSection.tsx` — a server component wrapped in
|
|
|
|
|
|
the shared `Section` shell from `sectionShared.tsx`. It renders the heading,
|
2026-09-21 22:29:07 +01:00
|
|
|
|
the lede, the card grid, the footer CTA and one caption line. Every
|
2026-09-21 22:18:38 +01:00
|
|
|
|
card's title is an `<a>` to the school's canonical slug URL via `schoolUrl()`.
|
|
|
|
|
|
|
2026-09-21 22:35:11 +01:00
|
|
|
|
`components/school/AddToCompareButton.tsx` — calls `addSchool` from
|
2026-09-21 22:18:38 +01:00
|
|
|
|
`ComparisonProvider` and reports the selection with a `from: 'similar_schools'`
|
|
|
|
|
|
attribution, mirroring `addSchoolFromSearch` in `HomeView.tsx:442`.
|
|
|
|
|
|
|
2026-09-21 22:35:11 +01:00
|
|
|
|
`components/school/SimilarSchoolsCarousel.tsx` — the scroller and its arrows. It
|
|
|
|
|
|
takes the server-rendered cards as `children` and the server-rendered heading and
|
|
|
|
|
|
lede as a `header` prop, so those stay server components while the client
|
|
|
|
|
|
component owns only the ref, the scroll handler and the arrows' disabled state.
|
|
|
|
|
|
|
2026-09-21 22:18:38 +01:00
|
|
|
|
The split matters: the links — the part with SEO value and the part that must
|
|
|
|
|
|
work without JavaScript — are server-rendered into the initial HTML, and only
|
2026-09-21 22:35:11 +01:00
|
|
|
|
the basket interaction and the arrows are hydrated.
|
|
|
|
|
|
|
|
|
|
|
|
### The carousel
|
|
|
|
|
|
|
|
|
|
|
|
**Every card is in the initial HTML.** The arrows scroll a list; they never swap
|
|
|
|
|
|
a view. Six `<a>` elements are in the markup whether or not anything is
|
|
|
|
|
|
hydrated, which is the whole reason the section exists — a paginated widget that
|
|
|
|
|
|
mounts cards on click would put four of the six links beyond a crawler and
|
|
|
|
|
|
beyond a reader with no JavaScript.
|
|
|
|
|
|
|
|
|
|
|
|
So the scroller is a plain overflowing `<ul>` with `scroll-snap-type: x
|
|
|
|
|
|
mandatory`, and the arrows call `scrollBy` on it. With no JavaScript it
|
|
|
|
|
|
degrades to a horizontally scrollable row that still works by touch and by
|
2026-09-21 22:37:34 +01:00
|
|
|
|
trackpad. Three cards are visible at desktop width and two below 820px.
|
2026-09-21 22:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
**Arrows appear only when there is somewhere to go** — that is, only when more
|
|
|
|
|
|
than three schools were found. Each disables itself at its own end of the
|
|
|
|
|
|
travel.
|
|
|
|
|
|
|
2026-09-21 22:37:34 +01:00
|
|
|
|
#### Below 640px the arrows go away
|
|
|
|
|
|
|
|
|
|
|
|
This follows [MOBILE.md](../../../MOBILE.md), which makes 360px the design
|
|
|
|
|
|
floor and mobile the primary target at ≥55% of traffic.
|
|
|
|
|
|
|
|
|
|
|
|
Kept in the heading's flex row at 360px, the two arrow buttons take 96px from a
|
|
|
|
|
|
328px card and crush the lede into a four-line column — measured, not guessed.
|
|
|
|
|
|
And swiping already does what they do. So below 640px the header becomes a
|
|
|
|
|
|
single column, the arrows are not rendered, one card shows at 86% width so the
|
|
|
|
|
|
next one peeks, and the affordance is carried by the right-edge scroll-fade that
|
|
|
|
|
|
MOBILE.md documents for exactly this case:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The fade lifts at the end of the travel, where there is nothing left to hint
|
|
|
|
|
|
at. That means the at-end state must be computed whether or not an arrow exists
|
|
|
|
|
|
to consume it — on mobile it drives the mask alone.
|
|
|
|
|
|
|
|
|
|
|
|
**Every interactive element clears 44×44px**, per MOBILE.md's iOS HIG check: the
|
|
|
|
|
|
arrow buttons and the add-to-compare button are both 44px, up from the 40px they
|
|
|
|
|
|
were first drawn at. A card title's own box is shorter than that, but its hit
|
|
|
|
|
|
area is the whole card through the `::after` overlay, so it passes on the target
|
|
|
|
|
|
that actually receives the tap.
|
|
|
|
|
|
|
2026-09-21 22:35:11 +01:00
|
|
|
|
**The edge test needs a tolerance, and this is not fussiness.** The scroller
|
|
|
|
|
|
carries 2px of padding so focus rings are not clipped, and scroll-snap treats
|
|
|
|
|
|
that padding as the first card's snap position: a scroller sitting at its start
|
|
|
|
|
|
reports `scrollLeft` of 2, not 0. Sub-pixel rounding moves it again at other
|
|
|
|
|
|
zoom levels. Testing `scrollLeft === 0` therefore leaves the back arrow live and
|
|
|
|
|
|
pointing nowhere on first paint — confirmed in the mockup before it was fixed.
|
|
|
|
|
|
Both ends compare against an 8px tolerance.
|
|
|
|
|
|
|
|
|
|
|
|
**Selecting a school must not move the row.** Adding to the basket re-renders
|
|
|
|
|
|
the footer; the scroll offset lives in the DOM rather than in React state, so
|
|
|
|
|
|
the carousel must not remount or reset on that render. A reader who ticks the
|
|
|
|
|
|
fifth school and is thrown back to the first has been punished for using the
|
|
|
|
|
|
feature.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
### Placement and navigation
|
|
|
|
|
|
|
|
|
|
|
|
Rendered as the last section **inside** `SchoolDetailShell`, from both
|
|
|
|
|
|
`PrimarySchoolSections` and `SecondarySchoolSections`. Inside, not after, because
|
|
|
|
|
|
the sticky nav's scroll-spy locates sections with `document.getElementById` and
|
|
|
|
|
|
can only reach a section that lives in the shell.
|
|
|
|
|
|
|
|
|
|
|
|
`NearbyPlaces` stays where it is, outside the shell, immediately below. The
|
|
|
|
|
|
resulting order — this school, then similar schools, then the places containing
|
|
|
|
|
|
them — narrows before it widens, which is the order a reader leaves a page in.
|
|
|
|
|
|
|
|
|
|
|
|
`buildNavItems` and `buildSecondaryNavItems` both gain
|
|
|
|
|
|
`{ id: 'similar', label: 'Similar schools' }`, gated on the section rendering.
|
|
|
|
|
|
The id must match the `Section` id or the scroll-spy silently breaks.
|
|
|
|
|
|
|
|
|
|
|
|
### The comparison CTA
|
|
|
|
|
|
|
|
|
|
|
|
A plain `<a href="/compare?urns=…">`, built from this school's URN plus the
|
|
|
|
|
|
selected ones. `/compare` already parses `urns` from the query string
|
|
|
|
|
|
(`app/(frontend)/compare/page.tsx:55`), so this needs no new compare plumbing.
|
|
|
|
|
|
With nothing selected the CTA is disabled; the button also adds to the shared
|
|
|
|
|
|
basket so the site-wide comparison state stays consistent with what the page
|
|
|
|
|
|
shows.
|
|
|
|
|
|
|
|
|
|
|
|
## Copy, and what the section is allowed to claim
|
|
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
2026-09-22 12:00:21 +01:00
|
|
|
|
**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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
**The neighbour's metric carries no valence colour.** Green and terracotta are
|
|
|
|
|
|
reserved site-wide for comparison against the England average. Colouring a
|
|
|
|
|
|
neighbour's figure against this school's would read as ranking the neighbours
|
|
|
|
|
|
against each other, which is precisely the endorsement this section must not
|
|
|
|
|
|
make. The figure sits in neutral ink above a plain "72% at this school"
|
|
|
|
|
|
reference line, and the reader draws their own conclusion.
|
|
|
|
|
|
|
|
|
|
|
|
**A missing figure reads "Not published".** Never 0, never blank, never an
|
|
|
|
|
|
em dash. This follows the same rule the rest of the detail page uses: a school
|
|
|
|
|
|
with no published result has not scored zero.
|
|
|
|
|
|
|
2026-09-21 22:29:07 +01:00
|
|
|
|
**There is no "how these are chosen" disclosure.** The method is visible in what
|
|
|
|
|
|
the section already shows — the phase in the lede, the shared characteristics on
|
|
|
|
|
|
each card, the distance above each name — and a collapsed panel restating it
|
|
|
|
|
|
earns less than the space it costs.
|
|
|
|
|
|
|
|
|
|
|
|
**One caption line survives, and only one:** that distances are straight-line
|
|
|
|
|
|
from the school and not road distance. This is not a method note. A reader who
|
|
|
|
|
|
sees "0.6 miles away" and takes it for the walk has been misled by us, and no
|
|
|
|
|
|
other element on the card corrects that. The remaining notes — that listing is
|
|
|
|
|
|
not a recommendation, that special schools only meet special schools — are
|
|
|
|
|
|
statements the selection rules already keep true without being narrated.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
## Degradation
|
|
|
|
|
|
|
|
|
|
|
|
| Condition | Behaviour |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `similar_schools` absent (older backend image) | no section, no nav item |
|
|
|
|
|
|
| fewer than 2 qualifying schools | no section, no nav item |
|
|
|
|
|
|
| this school has no coordinates | no section |
|
|
|
|
|
|
| the helper raises | returns `[]`; the page renders without the section |
|
|
|
|
|
|
|
|
|
|
|
|
The helper is wrapped so a failure inside it never 500s a page that is otherwise
|
|
|
|
|
|
complete — the posture `get_supplementary_data` already takes for its own
|
|
|
|
|
|
queries.
|
|
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
|
|
**Backend**, in a new `backend/tests/test_similar_schools.py`, against a
|
|
|
|
|
|
synthetic frame rather than live marts:
|
|
|
|
|
|
|
|
|
|
|
|
- a selective school never returns a non-selective one, and vice versa
|
|
|
|
|
|
- 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
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- 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
|
2026-09-21 22:35:11 +01:00
|
|
|
|
- more than six qualifying schools returns the six nearest
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- reach is capped per phase, and a primary beyond two miles returns `[]`
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- an all-through school is offered on both phase sides
|
2026-09-22 06:41:59 +01:00
|
|
|
|
- a `16 plus` school is matched against secondaries and colleges, never primaries
|
|
|
|
|
|
- `is_secondary_phase` and `PHASE_GROUPS` agree on every GIAS phase value
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- fewer than two qualifying schools returns `[]`
|
|
|
|
|
|
- distances match a hand-computed haversine for a known pair
|
|
|
|
|
|
|
|
|
|
|
|
**Frontend**, in `nextjs-app/__tests__`:
|
|
|
|
|
|
|
|
|
|
|
|
- the section renders nothing for absent, empty and single-row inputs
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- the lede never claims a similar intake
|
|
|
|
|
|
- an empty `shared` renders no chips rather than a filler
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- a null metric renders "Not published"
|
|
|
|
|
|
- the nav item appears only alongside the section
|
2026-09-21 22:35:11 +01:00
|
|
|
|
- every card is in the DOM, including the ones scrolled out of view
|
|
|
|
|
|
- arrows render only when more than three schools were found
|
|
|
|
|
|
|
|
|
|
|
|
jsdom has no layout, so `scrollWidth` and `clientWidth` are both 0 there and the
|
|
|
|
|
|
arrows' disabled state cannot be meaningfully asserted in Jest. That behaviour is
|
|
|
|
|
|
covered in the journey instead, against a real engine, rather than by a unit test
|
|
|
|
|
|
that would pass on a measurement that does not exist.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
**E2E**, added to the existing journeys in `e2e/tests` in the same PR, per the
|
|
|
|
|
|
repository's rule on user-facing behaviour:
|
|
|
|
|
|
|
|
|
|
|
|
- the section renders on a known staging URN, with resolving links
|
2026-09-21 22:35:11 +01:00
|
|
|
|
- where arrows are present, the back arrow starts disabled and the forward arrow
|
|
|
|
|
|
moves the row
|
|
|
|
|
|
- selecting a school does not reset the scroll position
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- add-to-compare reaches `/compare` with the expected `urns`
|
2026-09-21 22:37:34 +01:00
|
|
|
|
- at 360, 390 and 430px: no horizontal overflow, every interactive element in the
|
|
|
|
|
|
section clears 44×44px, and no arrows are rendered
|
2026-09-21 22:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
The E2E gate runs after merge on this project, so these journeys are not
|
|
|
|
|
|
provable in the PR checks; the PR is verified on the unit tests, and the
|
|
|
|
|
|
journeys are confirmed on the post-merge staging run.
|
|
|
|
|
|
|
|
|
|
|
|
## Out of scope
|
|
|
|
|
|
|
|
|
|
|
|
- A map of the nearby schools. The section is a list; the page already has a map.
|
2026-09-21 22:35:11 +01:00
|
|
|
|
- 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.
|
2026-09-22 12:00:21 +01:00
|
|
|
|
- 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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- 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
|
2026-09-22 12:00:21 +01:00
|
|
|
|
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.
|
2026-09-21 22:18:38 +01:00
|
|
|
|
- Any change to `/api/compare`, the compare page, or the comparison basket.
|