290 lines
13 KiB
Markdown
290 lines
13 KiB
Markdown
# Similar Schools Nearby — Design
|
||||
|
|
|
|||
|
|
**Date:** 2026-09-21
|
|||
|
|
**Status:** awaiting review
|
|||
|
|
**Scope:** school detail pages, both phase templates
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
Give a school detail page an answer to the question every reader arrives with
|
|||
|
|
after the results tables: *and what else is around here?*
|
|||
|
|
|
|||
|
|
Today a school page links outward to its place pages through
|
|||
|
|
`components/school/NearbyPlaces.tsx` and nowhere else. It never links to another
|
|||
|
|
school. This section adds that edge — three nearby schools of the same phase and
|
|||
|
|
a comparable intake, each a crawlable link and each addable to the comparison
|
|||
|
|
basket in one click.
|
|||
|
|
|
|||
|
|
Mockup, with all three tier states live in both themes:
|
|||
|
|
<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.
|
|||
|
|
|
|||
|
|
So the design separates two kinds of rule, and never confuses them:
|
|||
|
|
|
|||
|
|
- **Hard filters** encode the claims above. They are never relaxed, at any
|
|||
|
|
distance, even if that means the section does not render.
|
|||
|
|
- **Soft preferences** describe how closely the intake resembles this school's.
|
|||
|
|
They relax in tiers, and the card's own text always states what survived.
|
|||
|
|
|
|||
|
|
Everything below follows from that split.
|
|||
|
|
|
|||
|
|
## Selection algorithm
|
|||
|
|
|
|||
|
|
A backend helper, `_similar_schools_payload(urn)` in `backend/app.py`, modelled
|
|||
|
|
on the existing `_places_payload(urn)` and operating on the cached
|
|||
|
|
`load_latest_school_data()` frame — one row per URN, already carrying
|
|||
|
|
`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`,
|
|||
|
|
`admissions_policy`, `school_type` and `status`.
|
|||
|
|
|
|||
|
|
### Hard filters
|
|||
|
|
|
|||
|
|
| Filter | Rule |
|
|||
|
|
|---|---|
|
|||
|
|
| Self | `urn` is excluded |
|
|||
|
|
| Status | GIAS status must be open |
|
|||
|
|
| Coordinates | both `latitude` and `longitude` present on both schools |
|
|||
|
|
| Phase | same phase group via the existing `PHASE_GROUPS` map |
|
|||
|
|
| Provision | special/PRU/AP match only each other |
|
|||
|
|
| Selectivity | selective matches selective; non-selective matches non-selective |
|
|||
|
|
| Gender | Boys never matches Girls; Mixed is compatible with both |
|
|||
|
|
|
|||
|
|
`PHASE_GROUPS` is reused rather than re-derived so an all-through school is
|
|||
|
|
offered correctly on both the primary and secondary sides, exactly as it already
|
|||
|
|
behaves in search.
|
|||
|
|
|
|||
|
|
The provision filter needs a backend counterpart to the frontend's
|
|||
|
|
`isSpecialSchool()` in `nextjs-app/lib/utils.ts:897`, reading the same GIAS
|
|||
|
|
establishment types through `backend/gias_codes.py`. The two must agree: a
|
|||
|
|
school the frontend treats as special for benchmarking but the backend treats as
|
|||
|
|
mainstream for matching would be dropped from its own England comparison and
|
|||
|
|
then offered as a peer to a mainstream school on the next page along.
|
|||
|
|
|
|||
|
|
**Three cards.** The target count is 3, which is what the grid is built for; 2
|
|||
|
|
is the minimum that renders at all.
|
|||
|
|
|
|||
|
|
### Soft preferences, relaxed in tiers
|
|||
|
|
|
|||
|
|
| Tier | Additionally requires | Radius |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 1 | exact gender equality **and** same religious character | 3 miles |
|
|||
|
|
| 2 | exact gender equality | 5 miles |
|
|||
|
|
| 3 | nothing beyond the hard filters | 10 miles |
|
|||
|
|
|
|||
|
|
Candidates are taken from tier 1 first, ordered by distance; if fewer than three
|
|||
|
|
have been found the next tier tops up, and so on. A school already taken cannot
|
|||
|
|
be taken again by a later tier.
|
|||
|
|
|
|||
|
|
**Faith relaxes before gender.** A faith mismatch changes the character of a
|
|||
|
|
school; a gender mismatch can mean the school is not available to the reader's
|
|||
|
|
child at all. Ordering them the other way would fill the section with schools
|
|||
|
|
that cannot be applied to.
|
|||
|
|
|
|||
|
|
### Two decisions that are easy to get wrong later
|
|||
|
|
|
|||
|
|
**Selected by tier, displayed by distance.** Tier decides *which* three schools
|
|||
|
|
earn a slot. The rendered order is then distance ascending, because "nearby" is
|
|||
|
|
the promise in the heading and a reader scanning the row reads the first card as
|
|||
|
|
the closest. A tier-2 school at 0.4 miles therefore appears above a tier-1
|
|||
|
|
school at 2.9 miles, and the chips explain the difference in match quality.
|
|||
|
|
|
|||
|
|
**Fewer than two results renders nothing.** Not an empty state, not a single
|
|||
|
|
lonely card, not padding with schools that failed the hard filters. The section
|
|||
|
|
is absent, the nav item is absent, and the page is unchanged from today. A page
|
|||
|
|
with one weak match is better off without the section than with it.
|
|||
|
|
|
|||
|
|
### Distance
|
|||
|
|
|
|||
|
|
Straight-line, from the vectorised haversine already used for postcode search at
|
|||
|
|
`backend/app.py:831`, computed over the ~27k-row frame in numpy. Reported to one
|
|||
|
|
decimal place in miles, consistent with the rest of the site.
|
|||
|
|
|
|||
|
|
Straight-line distance is not road distance and is not measured from the
|
|||
|
|
reader's home. The section says so in its disclosure rather than leaving the
|
|||
|
|
reader to assume otherwise.
|
|||
|
|
|
|||
|
|
## API
|
|||
|
|
|
|||
|
|
`/api/schools/{urn}` gains a `similar_schools` array. Each row:
|
|||
|
|
|
|||
|
|
| Field | Notes |
|
|||
|
|
|---|---|
|
|||
|
|
| `urn` | for the link and the compare basket |
|
|||
|
|
| `school_name` | link text |
|
|||
|
|
| `distance_miles` | one decimal place |
|
|||
|
|
| `school_type` | GIAS type, translated, for the card's meta line |
|
|||
|
|
| `age_range` | for the meta line |
|
|||
|
|
| `shared` | the chip strings the tier actually justifies — see below |
|
|||
|
|
| `tier` | 1, 2 or 3 — drives the lede's wording and the chip styling |
|
|||
|
|
| `metric_value` | the phase-appropriate headline figure, or null |
|
|||
|
|
| `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below |
|
|||
|
|
| `metric_year` | the year the figure is from |
|
|||
|
|
|
|||
|
|
The metric follows the template the page is rendering, not the neighbour's own
|
|||
|
|
phase, so a row of cards never mixes two scales. Primary and **all-through**
|
|||
|
|
pages use `rwm_expected_pct`, matching `PrimarySchoolSections`, which is the
|
|||
|
|
template all-through schools render with; secondary pages use
|
|||
|
|
`attainment_8_score`. Where the neighbour has no value for that key, the card
|
|||
|
|
reads "Not published" rather than falling back to the other key.
|
|||
|
|
|
|||
|
|
`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.
|
|||
|
|
|
|||
|
|
Three rows of roughly 130 bytes each. It rides in the existing detail payload
|
|||
|
|
rather than a new endpoint because the page already makes exactly one server
|
|||
|
|
fetch for its data, and `/school/[slug]` regenerates at most weekly
|
|||
|
|
(`revalidate = 604800`), so the per-request cost is paid once per school per
|
|||
|
|
week.
|
|||
|
|
|
|||
|
|
**The key is absent, not null, on a backend that does not have this code.** The
|
|||
|
|
frontend treats absent and empty identically, which is what allowed
|
|||
|
|
`NearbyPlaces` to ship without a lockstep deploy of the two images.
|
|||
|
|
|
|||
|
|
`shared` is computed on the backend beside the tier that produced it, not
|
|||
|
|
re-derived on the frontend. Deriving it twice is how a card comes to claim a
|
|||
|
|
match the selection did not actually make.
|
|||
|
|
|
|||
|
|
## Frontend
|
|||
|
|
|
|||
|
|
### Components
|
|||
|
|
|
|||
|
|
`components/school/SimilarSchoolsSection.tsx` — a server component wrapped in
|
|||
|
|
the shared `Section` shell from `sectionShared.tsx`. It renders the heading,
|
|||
|
|
the lede, the `<details>` disclosure, the card grid and the footer CTA. Every
|
|||
|
|
card's title is an `<a>` to the school's canonical slug URL via `schoolUrl()`.
|
|||
|
|
|
|||
|
|
`components/school/AddToCompareButton.tsx` — the only `'use client'` file this
|
|||
|
|
adds, and the only client JavaScript in the section. It calls `addSchool` from
|
|||
|
|
`ComparisonProvider` and reports the selection with a `from: 'similar_schools'`
|
|||
|
|
attribution, mirroring `addSchoolFromSearch` in `HomeView.tsx:442`.
|
|||
|
|
|
|||
|
|
The split matters: the links — the part with SEO value and the part that must
|
|||
|
|
work without JavaScript — are server-rendered into the initial HTML, and only
|
|||
|
|
the basket interaction is hydrated.
|
|||
|
|
|
|||
|
|
### 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
|
|||
|
|
|
|||
|
|
**The lede tracks the tier.** At tiers 1–2 it reads "Other primary schools near
|
|||
|
|
X, with a similar intake." At tier 3 it drops "with a similar intake", because
|
|||
|
|
at tier 3 that is not what was matched.
|
|||
|
|
|
|||
|
|
**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 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.
|
|||
|
|
|
|||
|
|
**The disclosure states the method plainly** — same phase, nearest first,
|
|||
|
|
preferring a similar intake, special schools only ever compared with special
|
|||
|
|
schools, straight-line distance from the school rather than road distance or
|
|||
|
|
distance from the reader's home, and that being listed here is not a
|
|||
|
|
recommendation.
|
|||
|
|
|
|||
|
|
## 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
|
|||
|
|
- tier relaxation fills in order, and a school taken at tier 1 is not repeated
|
|||
|
|
- an all-through school is offered on both phase sides
|
|||
|
|
- 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
|
|||
|
|
- the lede drops "with a similar intake" when any card is tier 3
|
|||
|
|
- a null metric renders "Not published"
|
|||
|
|
- the nav item appears only alongside the section
|
|||
|
|
|
|||
|
|
**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
|
|||
|
|
- add-to-compare reaches `/compare` with the expected `urns`
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
- 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.
|
|||
|
|
- 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.
|
|||
|
|
- Any change to `/api/compare`, the compare page, or the comparison basket.
|