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 new file mode 100644 index 0000000..a7191ae --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md @@ -0,0 +1,290 @@ +# 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: + + +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 `
` disclosure, the card grid and the footer CTA. Every +card's title is an `` 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 ``, 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. diff --git a/mockups/similar-schools-nearby.html b/mockups/similar-schools-nearby.html new file mode 100644 index 0000000..ed0cdac --- /dev/null +++ b/mockups/similar-schools-nearby.html @@ -0,0 +1,300 @@ + + + + + +Similar Schools Nearby + + + + + + +
+
+
+

Similar schools nearby

+

A new section on the school detail page. Fictional schools and figures; shipped + colour, type and section shell taken from globals.css.

+
+ +
+
+
+ + + +