1485 lines
55 KiB
Markdown
1485 lines
55 KiB
Markdown
# Similar Schools Nearby Implementation Plan
|
|||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Add a "Similar schools nearby" section to the school detail page,
|
||
|
|
showing up to three crawlable links to nearby schools of the same phase and a
|
||
|
|
comparable intake, each addable to the comparison basket.
|
||
|
|
|
||
|
|
**Architecture:** A pure backend function ranks candidates out of the cached
|
||
|
|
latest-year DataFrame using hard filters (never relaxed) and tiered soft
|
||
|
|
preferences, and its result rides in the existing `/api/schools/{urn}` payload.
|
||
|
|
The frontend renders it as a server component inside `SchoolDetailShell`, with a
|
||
|
|
single client island for the add-to-compare button.
|
||
|
|
|
||
|
|
**Tech Stack:** FastAPI + pandas/numpy (backend), Next.js App Router + React
|
||
|
|
server components + CSS modules (frontend), pytest, Jest + Testing Library,
|
||
|
|
Playwright.
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md`
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- **Branch:** `feat/similar-schools-nearby`, already created from `origin/main`.
|
||
|
|
Never push to `main`; open a PR. Do not trigger the production promotion
|
||
|
|
workflow.
|
||
|
|
- **Do not start a local server** to check the application. Use the unit tests
|
||
|
|
in this plan.
|
||
|
|
- **Target 3 cards, minimum 2.** Fewer than 2 qualifying schools renders no
|
||
|
|
section and no nav item.
|
||
|
|
- **Tier radii, in miles:** tier 1 = 3.0, tier 2 = 5.0, tier 3 = 10.0.
|
||
|
|
- **Hard filters never relax:** self, non-open status, missing coordinates,
|
||
|
|
different phase group, special↔mainstream, selective↔non-selective,
|
||
|
|
Boys↔Girls.
|
||
|
|
- **Every new `.module.css` must use tokens only.** No hex values, no `rgb()`,
|
||
|
|
no named colours. `__tests__/components/darkThemeSafety.test.ts` scans every
|
||
|
|
stylesheet under `components/` and fails on hardcoded colour.
|
||
|
|
- **Exact GIAS display strings** (from `backend/gias_codes.py`):
|
||
|
|
`admissions_policy` is one of `"Not applicable"`, `"Selective"`,
|
||
|
|
`"Non-selective"`, `""`; `religious_denomination` includes `"Does not apply"`,
|
||
|
|
`"None"`, `"Church of England"`, `"Roman Catholic"` and others; `gender` is
|
||
|
|
`"Mixed"`, `"Boys"` or `"Girls"`.
|
||
|
|
- **Copy rule:** the lede says "with a similar intake" only when no card is
|
||
|
|
tier 3. A missing metric renders the exact string `Not published`.
|
||
|
|
- **The neighbour's metric never carries a valence colour.** No
|
||
|
|
`--status-above` / `--status-below` anywhere in this feature.
|
||
|
|
- **Backend tests:**
|
||
|
|
```sh
|
||
|
|
python3.11 -m venv /tmp/schoolcompare-backend-venv
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pip install -r requirements.txt pytest 'httpx<0.28' pyyaml
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q
|
||
|
|
```
|
||
|
|
- **Frontend tests:** from `nextjs-app/`, `npm test -- --runInBand` and
|
||
|
|
`npm run typecheck`. There is no `npm run lint`.
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
| File | Responsibility |
|
||
|
|
|---|---|
|
||
|
|
| `backend/similar_schools.py` *(new)* | Pure selection logic: hard filters, tiers, chips. No I/O, no FastAPI. |
|
||
|
|
| `backend/app.py` *(modify)* | Thin `_similar_schools_payload(urn)` wrapper + one key on the detail response. |
|
||
|
|
| `backend/tests/test_similar_schools.py` *(new)* | Unit tests for the pure module and one payload integration test. |
|
||
|
|
| `nextjs-app/lib/types.ts` *(modify)* | `SimilarSchool` type + optional key on the detail response type. |
|
||
|
|
| `nextjs-app/components/school/SimilarSchoolsSection.tsx` *(new)* | Server component: gates, lede, disclosure, cards, CTA. |
|
||
|
|
| `nextjs-app/components/school/SimilarSchools.module.css` *(new)* | Section styles, tokens only. |
|
||
|
|
| `nextjs-app/components/school/AddToCompareButton.tsx` *(new)* | Client island: the per-card basket toggle. |
|
||
|
|
| `nextjs-app/components/school/SimilarSchoolsCompareBar.tsx` *(new)* | Client island: the selection count and the CTA into `/compare`. |
|
||
|
|
| `nextjs-app/lib/schoolSections.ts` *(modify)* | `similar` nav item in both builders. |
|
||
|
|
| `nextjs-app/components/school/PrimarySchoolSections.tsx` *(modify)* | Render the section last. |
|
||
|
|
| `nextjs-app/components/school/SecondarySchoolSections.tsx` *(modify)* | Render the section last. |
|
||
|
|
| `nextjs-app/app/(frontend)/school/[slug]/page.tsx` *(modify)* | Pass `similar_schools` through. |
|
||
|
|
| `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx` *(new)* | Render gates, lede wording, chips, "Not published". |
|
||
|
|
| `e2e/tests/journeys.spec.ts` *(modify)* | Journey covering the section and the compare hand-off. |
|
||
|
|
|
||
|
|
Two client islands rather than one, because they need different things: the
|
||
|
|
button needs a single school, the bar needs the whole selection. Keeping them
|
||
|
|
apart means a card never re-renders when the count changes.
|
||
|
|
|
||
|
|
The selection logic lives in its own module rather than in `app.py` because
|
||
|
|
`app.py` is already ~1700 lines, and because a pure function over a DataFrame is
|
||
|
|
testable without a TestClient, a database or a monkeypatch.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Selection logic
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `backend/similar_schools.py`
|
||
|
|
- Test: `backend/tests/test_similar_schools.py`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: nothing from earlier tasks.
|
||
|
|
- Produces: `select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]`.
|
||
|
|
Each dict has keys `urn` (int), `school_name` (str), `distance_miles` (float),
|
||
|
|
`school_type` (str | None), `age_range` (str | None), `shared` (list[str]),
|
||
|
|
`tier` (int), `metric_value` (float | None), `metric_key` (str),
|
||
|
|
`metric_year` (int | None). Returns `[]` when fewer than 2 qualify.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Create `backend/tests/test_similar_schools.py`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Selection rules for the "similar schools nearby" section.
|
||
|
|
|
||
|
|
The hard filters encode claims the section is not allowed to make — that a
|
||
|
|
selective school is an alternative to a non-selective one, that a special
|
||
|
|
school is comparable to a mainstream one, or that a Girls school is an option
|
||
|
|
for a Boys school's reader. They never relax. The soft preferences describe
|
||
|
|
how close the intake is, and they do.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pandas as pd
|
||
|
|
|
||
|
|
from backend.similar_schools import select_similar
|
||
|
|
|
||
|
|
# Roughly 0.7 miles apart in latitude at this longitude.
|
||
|
|
BASE_LAT, BASE_LON = 51.5000, -0.1000
|
||
|
|
|
||
|
|
|
||
|
|
def _row(urn, name, **overrides):
|
||
|
|
base = {
|
||
|
|
"urn": urn,
|
||
|
|
"school_name": name,
|
||
|
|
"local_authority": "Testshire",
|
||
|
|
"school_type": "Community school",
|
||
|
|
"phase": "Primary",
|
||
|
|
"age_range": "4-11",
|
||
|
|
"status": "Open",
|
||
|
|
"gender": "Mixed",
|
||
|
|
"religious_denomination": "None",
|
||
|
|
"admissions_policy": "Not applicable",
|
||
|
|
"latitude": BASE_LAT,
|
||
|
|
"longitude": BASE_LON,
|
||
|
|
"year": 202425,
|
||
|
|
"rwm_expected_pct": 70.0,
|
||
|
|
"attainment_8_score": np.nan,
|
||
|
|
}
|
||
|
|
base.update(overrides)
|
||
|
|
return base
|
||
|
|
|
||
|
|
|
||
|
|
def _frame(*rows):
|
||
|
|
return pd.DataFrame(list(rows))
|
||
|
|
|
||
|
|
|
||
|
|
def _at(miles):
|
||
|
|
"""A latitude `miles` north of BASE_LAT."""
|
||
|
|
return BASE_LAT + miles / 69.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_returns_nearest_same_phase_schools():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "Near", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Mid", latitude=_at(1.0)),
|
||
|
|
_row(100004, "Far", latitude=_at(2.0)),
|
||
|
|
)
|
||
|
|
result = select_similar(frame, 100001, is_secondary=False)
|
||
|
|
assert [s["urn"] for s in result] == [100002, 100003, 100004]
|
||
|
|
assert result[0]["distance_miles"] == 0.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_excludes_the_subject_school():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "A", latitude=_at(0.5)),
|
||
|
|
_row(100003, "B", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
assert 100001 not in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)}
|
||
|
|
|
||
|
|
|
||
|
|
def test_selective_never_meets_non_selective():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Grammar", phase="Secondary", admissions_policy="Selective"),
|
||
|
|
_row(100002, "Comp A", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Comp B", phase="Secondary", admissions_policy="Non-selective", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
assert select_similar(frame, 100001, is_secondary=True) == []
|
||
|
|
|
||
|
|
reverse = select_similar(frame, 100002, is_secondary=True)
|
||
|
|
assert 100001 not in {s["urn"] for s in reverse}
|
||
|
|
|
||
|
|
|
||
|
|
def test_special_schools_match_only_each_other():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Special", school_type="Community special school"),
|
||
|
|
_row(100002, "Mainstream A", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Mainstream B", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
assert select_similar(frame, 100001, is_secondary=False) == []
|
||
|
|
assert select_similar(frame, 100002, is_secondary=False) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_boys_never_meets_girls():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Boys School", gender="Boys"),
|
||
|
|
_row(100002, "Girls School", gender="Girls", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Mixed School", gender="Mixed", latitude=_at(0.6)),
|
||
|
|
_row(100004, "Another Mixed", gender="Mixed", latitude=_at(0.7)),
|
||
|
|
)
|
||
|
|
urns = {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)}
|
||
|
|
assert 100002 not in urns
|
||
|
|
assert urns == {100003, 100004}
|
||
|
|
|
||
|
|
|
||
|
|
def test_closed_schools_and_missing_coordinates_are_dropped():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "Closed", status="Closed", latitude=_at(0.5)),
|
||
|
|
_row(100003, "No coords", latitude=np.nan, longitude=np.nan),
|
||
|
|
_row(100004, "Good A", latitude=_at(0.6)),
|
||
|
|
_row(100005, "Good B", latitude=_at(0.7)),
|
||
|
|
)
|
||
|
|
assert {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)} == {100004, 100005}
|
||
|
|
|
||
|
|
|
||
|
|
def test_tiers_relax_faith_before_gender():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject", gender="Boys", religious_denomination="Roman Catholic"),
|
||
|
|
# Tier 1: same gender and same faith.
|
||
|
|
_row(100002, "Tier one", gender="Boys", religious_denomination="Roman Catholic", latitude=_at(2.0)),
|
||
|
|
# Tier 2: same gender, different faith — closer, but a weaker match.
|
||
|
|
_row(100003, "Tier two", gender="Boys", religious_denomination="None", latitude=_at(0.5)),
|
||
|
|
# Tier 3: mixed gender, different faith.
|
||
|
|
_row(100004, "Tier three", gender="Mixed", religious_denomination="None", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
result = select_similar(frame, 100001, is_secondary=False)
|
||
|
|
tier_by_urn = {s["urn"]: s["tier"] for s in result}
|
||
|
|
assert tier_by_urn == {100002: 1, 100003: 2, 100004: 3}
|
||
|
|
# Selected by tier, displayed by distance.
|
||
|
|
assert [s["urn"] for s in result] == [100003, 100004, 100002]
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_school_is_never_taken_twice():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "A", latitude=_at(0.5)),
|
||
|
|
_row(100003, "B", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
result = select_similar(frame, 100001, is_secondary=False)
|
||
|
|
assert len(result) == len({s["urn"] for s in result})
|
||
|
|
|
||
|
|
|
||
|
|
def test_fewer_than_two_matches_returns_empty():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "Only neighbour", latitude=_at(0.5)),
|
||
|
|
)
|
||
|
|
assert select_similar(frame, 100001, is_secondary=False) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_beyond_the_widest_radius_is_not_offered():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "A", latitude=_at(11.0)),
|
||
|
|
_row(100003, "B", latitude=_at(12.0)),
|
||
|
|
)
|
||
|
|
assert select_similar(frame, 100001, is_secondary=False) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_all_through_is_offered_on_both_phase_sides():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Primary subject", phase="Primary"),
|
||
|
|
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Primary peer", phase="Primary", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
assert 100002 in {s["urn"] for s in select_similar(frame, 100001, is_secondary=False)}
|
||
|
|
|
||
|
|
secondary = _frame(
|
||
|
|
_row(100010, "Secondary subject", phase="Secondary"),
|
||
|
|
_row(100002, "All through", phase="All-through", latitude=_at(0.5)),
|
||
|
|
_row(100011, "Secondary peer", phase="Secondary", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
assert 100002 in {s["urn"] for s in select_similar(secondary, 100010, is_secondary=True)}
|
||
|
|
|
||
|
|
|
||
|
|
def test_chips_state_only_what_the_tier_earned():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject", phase="Secondary", gender="Mixed",
|
||
|
|
religious_denomination="None", admissions_policy="Non-selective"),
|
||
|
|
_row(100002, "Full match", phase="Secondary", gender="Mixed",
|
||
|
|
religious_denomination="None", admissions_policy="Non-selective", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Faith differs", phase="Secondary", gender="Mixed",
|
||
|
|
religious_denomination="Church of England", admissions_policy="Non-selective", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)}
|
||
|
|
assert by_urn[100002]["shared"] == ["Mixed", "Non-selective", "No religious character"]
|
||
|
|
assert by_urn[100003]["shared"] == ["Mixed", "Non-selective"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_tier_three_chip_is_the_plain_phase():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject", gender="Boys"),
|
||
|
|
_row(100002, "A", gender="Mixed", latitude=_at(0.5)),
|
||
|
|
_row(100003, "B", gender="Mixed", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
result = select_similar(frame, 100001, is_secondary=False)
|
||
|
|
assert all(s["shared"] == ["Primary school"] for s in result)
|
||
|
|
|
||
|
|
|
||
|
|
def test_metric_follows_the_template_not_the_neighbour():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject", phase="Secondary", attainment_8_score=50.0),
|
||
|
|
_row(100002, "A", phase="Secondary", attainment_8_score=52.8, latitude=_at(0.5)),
|
||
|
|
_row(100003, "B", phase="Secondary", attainment_8_score=np.nan, latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
by_urn = {s["urn"]: s for s in select_similar(frame, 100001, is_secondary=True)}
|
||
|
|
assert by_urn[100002]["metric_key"] == "attainment_8_score"
|
||
|
|
assert by_urn[100002]["metric_value"] == 52.8
|
||
|
|
assert by_urn[100002]["metric_year"] == 202425
|
||
|
|
assert by_urn[100003]["metric_value"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_values_are_json_safe_native_types():
|
||
|
|
frame = _frame(
|
||
|
|
_row(100001, "Subject"),
|
||
|
|
_row(100002, "A", latitude=_at(0.5)),
|
||
|
|
_row(100003, "B", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
for school in select_similar(frame, 100001, is_secondary=False):
|
||
|
|
assert isinstance(school["urn"], int)
|
||
|
|
assert isinstance(school["distance_miles"], float)
|
||
|
|
assert not isinstance(school["metric_value"], np.generic)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q
|
||
|
|
```
|
||
|
|
Expected: collection error, `ModuleNotFoundError: No module named 'backend.similar_schools'`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Move PHASE_GROUPS out of app.py first**
|
||
|
|
|
||
|
|
`PHASE_GROUPS` lives in `backend/app.py:51-55`, and importing `app` from
|
||
|
|
`similar_schools` while `app` imports `similar_schools` is a cycle. Move the
|
||
|
|
constant to `backend/schemas.py`, which `app.py` already imports and which holds
|
||
|
|
the other shared column and display constants.
|
||
|
|
|
||
|
|
In `backend/schemas.py`, add near `SCHOOL_COLUMNS`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Phase groups for search, place pages and similar-school matching. All-through
|
||
|
|
# schools belong to both sides, which is why this is a set per phase rather
|
||
|
|
# than a single phase string comparison.
|
||
|
|
PHASE_GROUPS: dict[str, set[str]] = {
|
||
|
|
"primary": {"primary", "middle deemed primary", "all-through"},
|
||
|
|
"secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"},
|
||
|
|
"all-through": {"all-through"},
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
In `backend/app.py`, delete the `PHASE_GROUPS` definition at lines 51-55 and add
|
||
|
|
`PHASE_GROUPS` to the existing `from .schemas import (...)` list. Leave the call
|
||
|
|
sites at `app.py:762` and `app.py:1402` untouched — the name resolves the same.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write the implementation**
|
||
|
|
|
||
|
|
Create `backend/similar_schools.py`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Which nearby schools a detail page may offer as alternatives.
|
||
|
|
|
||
|
|
Two kinds of rule, and they are not interchangeable.
|
||
|
|
|
||
|
|
HARD FILTERS encode claims the section is not allowed to make. A selective
|
||
|
|
school is not an alternative to a non-selective one, a special school is not
|
||
|
|
comparable to a mainstream one, and a Girls school is not an option for a Boys
|
||
|
|
school's reader. These never relax, at any distance, even where that means the
|
||
|
|
section does not render at all.
|
||
|
|
|
||
|
|
SOFT PREFERENCES describe how closely an intake resembles this school's. They
|
||
|
|
relax in tiers, and every card reports the tier that actually took it so the
|
||
|
|
page can say what is shared rather than implying more.
|
||
|
|
|
||
|
|
Pure functions over a DataFrame: no I/O, no FastAPI, no database.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pandas as pd
|
||
|
|
|
||
|
|
from .schemas import PHASE_GROUPS
|
||
|
|
|
||
|
|
TARGET = 3
|
||
|
|
MINIMUM = 2
|
||
|
|
|
||
|
|
# (tier, radius in miles). Faith relaxes before gender: a faith mismatch
|
||
|
|
# changes the character of a school, while a gender mismatch can mean the
|
||
|
|
# school is not available to this reader's child at all.
|
||
|
|
TIERS: tuple[tuple[int, float], ...] = ((1, 3.0), (2, 5.0), (3, 10.0))
|
||
|
|
|
||
|
|
EARTH_RADIUS_MILES = 3958.8
|
||
|
|
|
||
|
|
_SPECIAL = re.compile(r"\bspecial\b|pupil referral|alternative provision", re.I)
|
||
|
|
|
||
|
|
# Values that mean "this school has no religious character".
|
||
|
|
_NO_FAITH = {"", "none", "does not apply", "not applicable"}
|
||
|
|
|
||
|
|
|
||
|
|
def is_special_provision(school_type: str | None) -> bool:
|
||
|
|
"""Mirror of isSpecialSchool() in nextjs-app/lib/utils.ts.
|
||
|
|
|
||
|
|
Special schools carry a mainstream phase, so phase alone cannot identify
|
||
|
|
them. The two implementations must agree: a school the frontend treats as
|
||
|
|
special for benchmarking but this treats as mainstream would be dropped
|
||
|
|
from its own England comparison and then offered as a peer to a mainstream
|
||
|
|
school on the next page along.
|
||
|
|
"""
|
||
|
|
return bool(_SPECIAL.search(school_type or ""))
|
||
|
|
|
||
|
|
|
||
|
|
def is_selective(admissions_policy: str | None) -> bool:
|
||
|
|
"""Strictly selective. Unknown counts as non-selective, which is the safe
|
||
|
|
direction: it can only ever exclude a pairing, never invent one."""
|
||
|
|
return (admissions_policy or "").strip().lower() == "selective"
|
||
|
|
|
||
|
|
|
||
|
|
def faith_key(denomination: str | None) -> str:
|
||
|
|
value = (denomination or "").strip().lower()
|
||
|
|
return "" if value in _NO_FAITH else value
|
||
|
|
|
||
|
|
|
||
|
|
def faith_label(denomination: str | None) -> str:
|
||
|
|
return denomination.strip() if faith_key(denomination) else "No religious character"
|
||
|
|
|
||
|
|
|
||
|
|
def genders_compatible(a: str | None, b: str | None) -> bool:
|
||
|
|
single = {"boys", "girls"}
|
||
|
|
left, right = (a or "").strip().lower(), (b or "").strip().lower()
|
||
|
|
return not (left in single and right in single and left != right)
|
||
|
|
|
||
|
|
|
||
|
|
def phase_label(phase: str | None) -> str:
|
||
|
|
text = (phase or "").strip()
|
||
|
|
if not text:
|
||
|
|
return "School"
|
||
|
|
if text.lower() == "all-through":
|
||
|
|
return "All-through school"
|
||
|
|
return f"{text.capitalize()} school"
|
||
|
|
|
||
|
|
|
||
|
|
def _phase_group(is_secondary: bool) -> set[str]:
|
||
|
|
return PHASE_GROUPS["secondary" if is_secondary else "primary"]
|
||
|
|
|
||
|
|
|
||
|
|
def _haversine_miles(lat1: float, lon1: float, lat2, lon2):
|
||
|
|
"""Vectorised, matching the postcode search in app.py."""
|
||
|
|
lat1_r, lon1_r = np.radians(lat1), np.radians(lon1)
|
||
|
|
lat2_r, lon2_r = np.radians(lat2.astype(float)), np.radians(lon2.astype(float))
|
||
|
|
dlat, dlon = lat2_r - lat1_r, lon2_r - lon1_r
|
||
|
|
a = np.sin(dlat / 2) ** 2 + np.cos(lat1_r) * np.cos(lat2_r) * np.sin(dlon / 2) ** 2
|
||
|
|
return 2 * EARTH_RADIUS_MILES * np.arcsin(np.sqrt(a))
|
||
|
|
|
||
|
|
|
||
|
|
def _native(value):
|
||
|
|
"""NaN and numpy scalars both reach JSONResponse badly; normalise here so
|
||
|
|
the caller never has to remember to."""
|
||
|
|
if value is None or (isinstance(value, float) and np.isnan(value)):
|
||
|
|
return None
|
||
|
|
if isinstance(value, np.generic):
|
||
|
|
value = value.item()
|
||
|
|
if isinstance(value, float) and np.isnan(value):
|
||
|
|
return None
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _chips(subject: pd.Series, candidate: pd.Series, tier: int, is_secondary: bool) -> list[str]:
|
||
|
|
if tier >= 3:
|
||
|
|
return [phase_label(candidate.get("phase"))]
|
||
|
|
|
||
|
|
chips = [str(subject.get("gender") or "").strip()]
|
||
|
|
if is_secondary:
|
||
|
|
policy = (candidate.get("admissions_policy") or "").strip()
|
||
|
|
if policy and policy.lower() not in {"not applicable", "unknown"}:
|
||
|
|
chips.append(policy)
|
||
|
|
if tier == 1:
|
||
|
|
chips.append(faith_label(candidate.get("religious_denomination")))
|
||
|
|
return [chip for chip in chips if chip]
|
||
|
|
|
||
|
|
|
||
|
|
def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]:
|
||
|
|
"""Up to TARGET nearby schools this page may offer, or [] below MINIMUM.
|
||
|
|
|
||
|
|
Selected by tier, displayed by distance: the tier decides which schools
|
||
|
|
earn a slot, and the render order is then closest-first, because "nearby"
|
||
|
|
is the promise in the heading.
|
||
|
|
"""
|
||
|
|
subject_rows = frame[frame["urn"] == urn]
|
||
|
|
if subject_rows.empty:
|
||
|
|
return []
|
||
|
|
subject = subject_rows.iloc[0]
|
||
|
|
|
||
|
|
lat, lon = _native(subject.get("latitude")), _native(subject.get("longitude"))
|
||
|
|
if lat is None or lon is None:
|
||
|
|
return []
|
||
|
|
|
||
|
|
metric_key = "attainment_8_score" if is_secondary else "rwm_expected_pct"
|
||
|
|
|
||
|
|
candidates = frame[frame["urn"] != urn].copy()
|
||
|
|
for column in ("latitude", "longitude"):
|
||
|
|
candidates = candidates[candidates[column].notna()]
|
||
|
|
if candidates.empty:
|
||
|
|
return []
|
||
|
|
|
||
|
|
# ── Hard filters ────────────────────────────────────────────────────
|
||
|
|
allowed_phases = _phase_group(is_secondary)
|
||
|
|
candidates = candidates[
|
||
|
|
candidates["phase"].fillna("").str.lower().isin(allowed_phases)
|
||
|
|
]
|
||
|
|
candidates = candidates[candidates["status"].fillna("").str.lower().str.startswith("open")]
|
||
|
|
|
||
|
|
subject_special = is_special_provision(subject.get("school_type"))
|
||
|
|
special = candidates["school_type"].apply(is_special_provision)
|
||
|
|
candidates = candidates[special if subject_special else ~special]
|
||
|
|
|
||
|
|
subject_selective = is_selective(subject.get("admissions_policy"))
|
||
|
|
selective = candidates["admissions_policy"].apply(is_selective)
|
||
|
|
candidates = candidates[selective if subject_selective else ~selective]
|
||
|
|
|
||
|
|
subject_gender = subject.get("gender")
|
||
|
|
candidates = candidates[
|
||
|
|
candidates["gender"].apply(lambda g: genders_compatible(subject_gender, g))
|
||
|
|
]
|
||
|
|
if candidates.empty:
|
||
|
|
return []
|
||
|
|
|
||
|
|
candidates["distance_miles"] = _haversine_miles(
|
||
|
|
lat, lon, candidates["latitude"].values, candidates["longitude"].values
|
||
|
|
).round(1)
|
||
|
|
|
||
|
|
# ── Soft preferences, in tiers ──────────────────────────────────────
|
||
|
|
subject_faith = faith_key(subject.get("religious_denomination"))
|
||
|
|
subject_gender_key = (subject_gender or "").strip().lower()
|
||
|
|
same_gender = candidates["gender"].fillna("").str.strip().str.lower() == subject_gender_key
|
||
|
|
same_faith = candidates["religious_denomination"].apply(faith_key) == subject_faith
|
||
|
|
|
||
|
|
tier_masks = {
|
||
|
|
1: same_gender & same_faith,
|
||
|
|
2: same_gender,
|
||
|
|
3: pd.Series(True, index=candidates.index),
|
||
|
|
}
|
||
|
|
|
||
|
|
picked: dict[int, tuple[int, pd.Series]] = {}
|
||
|
|
for tier, radius in TIERS:
|
||
|
|
if len(picked) >= TARGET:
|
||
|
|
break
|
||
|
|
within = candidates[tier_masks[tier] & (candidates["distance_miles"] <= radius)]
|
||
|
|
for _, row in within.sort_values("distance_miles").iterrows():
|
||
|
|
candidate_urn = int(row["urn"])
|
||
|
|
if candidate_urn in picked:
|
||
|
|
continue
|
||
|
|
picked[candidate_urn] = (tier, row)
|
||
|
|
if len(picked) >= TARGET:
|
||
|
|
break
|
||
|
|
|
||
|
|
if len(picked) < MINIMUM:
|
||
|
|
return []
|
||
|
|
|
||
|
|
selected = sorted(picked.values(), key=lambda pair: float(pair[1]["distance_miles"]))
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"urn": int(row["urn"]),
|
||
|
|
"school_name": str(row.get("school_name") or ""),
|
||
|
|
"distance_miles": float(row["distance_miles"]),
|
||
|
|
"school_type": _native(row.get("school_type")),
|
||
|
|
"age_range": _native(row.get("age_range")),
|
||
|
|
"shared": _chips(subject, row, tier, is_secondary),
|
||
|
|
"tier": tier,
|
||
|
|
"metric_value": _native(row.get(metric_key)),
|
||
|
|
"metric_key": metric_key,
|
||
|
|
"metric_year": _native(row.get("year")),
|
||
|
|
}
|
||
|
|
for tier, row in selected
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q
|
||
|
|
```
|
||
|
|
Expected: PASS, 14 tests.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run the whole backend suite for the `PHASE_GROUPS` move**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q
|
||
|
|
```
|
||
|
|
Expected: PASS. Any failure here is the constant move, not the new module.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add backend/similar_schools.py backend/tests/test_similar_schools.py backend/schemas.py backend/app.py
|
||
|
|
git commit -m "feat(api): decide which nearby schools a page may offer
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Serve it on the detail endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `backend/app.py` (detail route at line 910, response dict at ~975)
|
||
|
|
- Test: `backend/tests/test_similar_schools.py` (append)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `select_similar(frame, urn, is_secondary)` from Task 1.
|
||
|
|
- Produces: a `similar_schools` key on the `/api/schools/{urn}` JSON response —
|
||
|
|
a list of the Task 1 dicts, `[]` when nothing qualifies. Never absent on a
|
||
|
|
build that has this code.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Append to `backend/tests/test_similar_schools.py`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
|
||
|
|
def _endpoint_frame():
|
||
|
|
return _frame(
|
||
|
|
_row(100001, "Subject Primary"),
|
||
|
|
_row(100002, "Neighbour A", latitude=_at(0.5)),
|
||
|
|
_row(100003, "Neighbour B", latitude=_at(0.6)),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def client(monkeypatch):
|
||
|
|
from backend import app as app_module
|
||
|
|
|
||
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _endpoint_frame)
|
||
|
|
monkeypatch.setattr(app_module, "load_school_data", _endpoint_frame)
|
||
|
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||
|
|
|
||
|
|
|
||
|
|
def test_detail_payload_carries_similar_schools(client):
|
||
|
|
resp = client.get("/api/schools/100001")
|
||
|
|
assert resp.status_code == 200, resp.text
|
||
|
|
similar = resp.json()["similar_schools"]
|
||
|
|
assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"]
|
||
|
|
assert similar[0]["metric_key"] == "rwm_expected_pct"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_failure_in_selection_does_not_break_the_page(client, monkeypatch):
|
||
|
|
from backend import app as app_module
|
||
|
|
|
||
|
|
def _explode(*args, **kwargs):
|
||
|
|
raise ValueError("selection blew up")
|
||
|
|
|
||
|
|
monkeypatch.setattr(app_module, "select_similar", _explode)
|
||
|
|
resp = client.get("/api/schools/100001")
|
||
|
|
assert resp.status_code == 200, resp.text
|
||
|
|
assert resp.json()["similar_schools"] == []
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q -k "detail_payload or failure_in_selection"
|
||
|
|
```
|
||
|
|
Expected: FAIL with `KeyError: 'similar_schools'`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write the implementation**
|
||
|
|
|
||
|
|
In `backend/app.py`, add to the imports:
|
||
|
|
|
||
|
|
```python
|
||
|
|
from .similar_schools import select_similar
|
||
|
|
```
|
||
|
|
|
||
|
|
Add this helper beside `_places_payload`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]:
|
||
|
|
"""Nearby schools this page may offer as alternatives.
|
||
|
|
|
||
|
|
Wrapped: a failure in selection must never 500 a page that is otherwise
|
||
|
|
complete, which is the posture get_supplementary_data already takes. The
|
||
|
|
section simply does not render.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
phase_text = (phase or "").lower()
|
||
|
|
# All-through schools render with the primary template, which is what
|
||
|
|
# decides the metric, so they are not secondary here.
|
||
|
|
is_secondary = phase_text != "all-through" and "secondary" in phase_text
|
||
|
|
return select_similar(load_latest_school_data(), int(urn), is_secondary)
|
||
|
|
except Exception:
|
||
|
|
logger.exception("similar schools selection failed for urn=%s", urn)
|
||
|
|
return []
|
||
|
|
```
|
||
|
|
|
||
|
|
In the `/api/schools/{urn}` response dict, immediately after the `"places"` key:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Nearby schools of the same phase and a comparable intake. Always
|
||
|
|
# present on a build with this code; the frontend treats absent and
|
||
|
|
# empty identically, which is what lets the two images deploy
|
||
|
|
# independently.
|
||
|
|
"similar_schools": _similar_schools_payload(urn, latest.get("phase")),
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests -q
|
||
|
|
```
|
||
|
|
Expected: PASS, whole suite.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add backend/app.py backend/tests/test_similar_schools.py
|
||
|
|
git commit -m "feat(api): serve similar schools on the detail endpoint
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Types and the section component
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `nextjs-app/lib/types.ts`
|
||
|
|
- Create: `nextjs-app/components/school/SimilarSchoolsSection.tsx`
|
||
|
|
- Create: `nextjs-app/components/school/SimilarSchools.module.css`
|
||
|
|
- Create: `nextjs-app/components/school/AddToCompareButton.tsx`
|
||
|
|
- Test: `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: the `similar_schools` payload from Task 2.
|
||
|
|
- Produces: `SimilarSchool` (exported from `lib/types.ts`) and
|
||
|
|
`<SimilarSchoolsSection schoolName={string} phaseNoun={string} thisMetricValue={number | null} similar={SimilarSchool[]} />`,
|
||
|
|
which returns `null` when it must not render. Also
|
||
|
|
`shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean`, exported
|
||
|
|
from the same file and consumed by Task 4's nav gating, and
|
||
|
|
`<SimilarSchoolsCompareBar thisUrn={number} candidates={SimilarSchool[]} />`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Create `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
/**
|
||
|
|
* The section's job is to be honest about what it matched. These tests pin the
|
||
|
|
* three ways it could lie: rendering below the minimum, claiming a similar
|
||
|
|
* intake at tier 3, and showing a missing figure as a number.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { render, screen } from '@testing-library/react';
|
||
|
|
|
||
|
|
import {
|
||
|
|
SimilarSchoolsSection,
|
||
|
|
shouldRenderSimilar,
|
||
|
|
} from '@/components/school/SimilarSchoolsSection';
|
||
|
|
import type { SimilarSchool } from '@/lib/types';
|
||
|
|
|
||
|
|
jest.mock('@/components/school/AddToCompareButton', () => ({
|
||
|
|
AddToCompareButton: ({ school }: { school: SimilarSchool }) => (
|
||
|
|
<button type="button">Add {school.school_name} to compare</button>
|
||
|
|
),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
|
||
|
|
SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
|
||
|
|
<div data-testid="compare-bar">bar for {thisUrn}</div>
|
||
|
|
),
|
||
|
|
}));
|
||
|
|
|
||
|
|
function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
|
||
|
|
return {
|
||
|
|
urn: 100002,
|
||
|
|
school_name: 'Willow Lane Primary School',
|
||
|
|
distance_miles: 0.6,
|
||
|
|
school_type: 'Community school',
|
||
|
|
age_range: '4-11',
|
||
|
|
shared: ['Mixed', 'No religious character'],
|
||
|
|
tier: 1,
|
||
|
|
metric_value: 74,
|
||
|
|
metric_key: 'rwm_expected_pct',
|
||
|
|
metric_year: 202425,
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSection(similar: SimilarSchool[]) {
|
||
|
|
return render(
|
||
|
|
<SimilarSchoolsSection
|
||
|
|
urn={100001}
|
||
|
|
schoolName="Meadowbrook Primary School"
|
||
|
|
phaseNoun="primary"
|
||
|
|
thisMetricValue={72}
|
||
|
|
similar={similar}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('render gates', () => {
|
||
|
|
it.each([
|
||
|
|
['undefined', undefined],
|
||
|
|
['null', null],
|
||
|
|
['empty', []],
|
||
|
|
['a single school', [school()]],
|
||
|
|
])('renders nothing for %s', (_label, value) => {
|
||
|
|
expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders for two or more schools', () => {
|
||
|
|
expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns null rather than an empty shell below the minimum', () => {
|
||
|
|
const { container } = renderSection([school()]);
|
||
|
|
expect(container).toBeEmptyDOMElement();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the claim the lede makes', () => {
|
||
|
|
it('claims a similar intake when every card is tier 1 or 2', () => {
|
||
|
|
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]);
|
||
|
|
expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops the claim when any card is tier 3', () => {
|
||
|
|
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]);
|
||
|
|
expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('cards', () => {
|
||
|
|
it('links each school to its canonical slug', () => {
|
||
|
|
renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
|
||
|
|
const link = screen.getByRole('link', { name: /Willow Lane Primary School/ });
|
||
|
|
expect(link).toHaveAttribute('href', '/school/100002-willow-lane-primary-school');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows the distance and the shared characteristics', () => {
|
||
|
|
renderSection([school(), school({ urn: 100003 })]);
|
||
|
|
expect(screen.getAllByText('0.6 miles away').length).toBeGreaterThan(0);
|
||
|
|
expect(screen.getAllByText('Mixed').length).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders a missing figure as "Not published", never as a number', () => {
|
||
|
|
renderSection([school({ metric_value: null }), school({ urn: 100003 })]);
|
||
|
|
expect(screen.getByText('Not published')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('anchors each figure against this school', () => {
|
||
|
|
renderSection([school(), school({ urn: 100003 })]);
|
||
|
|
expect(screen.getAllByText('72% at this school').length).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('offers the compare bar once, for this school', () => {
|
||
|
|
renderSection([school(), school({ urn: 100003 })]);
|
||
|
|
expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
|
|
||
|
|
Run (from `nextjs-app/`):
|
||
|
|
```sh
|
||
|
|
npm test -- --runInBand SimilarSchoolsSection
|
||
|
|
```
|
||
|
|
Expected: FAIL, cannot resolve `@/components/school/SimilarSchoolsSection`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add the type**
|
||
|
|
|
||
|
|
In `nextjs-app/lib/types.ts`, beside the other detail-payload types:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
/** A nearby school of the same phase and a comparable intake.
|
||
|
|
* `tier` is carried explicitly rather than inferred from `shared`, because it
|
||
|
|
* drives two separate decisions — whether the lede may claim a similar intake,
|
||
|
|
* and whether a chip renders as a fill or a muted outline. */
|
||
|
|
export interface SimilarSchool {
|
||
|
|
urn: number;
|
||
|
|
school_name: string;
|
||
|
|
distance_miles: number;
|
||
|
|
school_type: string | null;
|
||
|
|
age_range: string | null;
|
||
|
|
shared: string[];
|
||
|
|
tier: number;
|
||
|
|
metric_value: number | null;
|
||
|
|
metric_key: string;
|
||
|
|
metric_year: number | null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to the school detail response interface (the one `fetchSchoolDetails`
|
||
|
|
returns), keeping it optional so an older backend image still typechecks:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
similar_schools?: SimilarSchool[];
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write the client island**
|
||
|
|
|
||
|
|
Create `nextjs-app/components/school/AddToCompareButton.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The only client JavaScript in the similar-schools section.
|
||
|
|
*
|
||
|
|
* The links are server-rendered, so the section works with JS off; this adds
|
||
|
|
* the basket interaction on top rather than being what makes the section
|
||
|
|
* function.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||
|
|
import type { School, SimilarSchool } from '@/lib/types';
|
||
|
|
import styles from './SimilarSchools.module.css';
|
||
|
|
|
||
|
|
export function AddToCompareButton({ school }: { school: SimilarSchool }) {
|
||
|
|
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||
|
|
const selected = selectedSchools.some((s) => s.urn === school.urn);
|
||
|
|
|
||
|
|
const toggle = () => {
|
||
|
|
if (selected) {
|
||
|
|
removeSchool(school.urn);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// The basket only needs identity and display fields; the compare page
|
||
|
|
// fetches everything it renders by URN.
|
||
|
|
addSchool({
|
||
|
|
urn: school.urn,
|
||
|
|
school_name: school.school_name,
|
||
|
|
school_type: school.school_type,
|
||
|
|
age_range: school.age_range,
|
||
|
|
} as School);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={styles.add}
|
||
|
|
onClick={toggle}
|
||
|
|
aria-pressed={selected}
|
||
|
|
>
|
||
|
|
{selected ? '✓ Added to compare' : '+ Add to compare'}
|
||
|
|
<span className={styles.srOnly}> — {school.school_name}</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Write the compare bar**
|
||
|
|
|
||
|
|
Create `nextjs-app/components/school/SimilarSchoolsCompareBar.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The selection count and the one decisive action in the section.
|
||
|
|
*
|
||
|
|
* The CTA is a real link, not a handler: /compare already parses `urns` from
|
||
|
|
* the query string, so the hand-off needs no new compare plumbing. It counts
|
||
|
|
* this school plus whatever the reader ticked, because comparing a shortlist
|
||
|
|
* without the school they are looking at is not what they asked for.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import Link from 'next/link';
|
||
|
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||
|
|
import type { SimilarSchool } from '@/lib/types';
|
||
|
|
import styles from './SimilarSchools.module.css';
|
||
|
|
|
||
|
|
export function SimilarSchoolsCompareBar({
|
||
|
|
thisUrn,
|
||
|
|
candidates,
|
||
|
|
}: {
|
||
|
|
thisUrn: number;
|
||
|
|
candidates: SimilarSchool[];
|
||
|
|
}) {
|
||
|
|
const { selectedSchools } = useComparisonContext();
|
||
|
|
|
||
|
|
// Only the schools this section offers, in the order the cards show them —
|
||
|
|
// the basket may hold schools picked up elsewhere on the site, and this bar
|
||
|
|
// speaks for this section.
|
||
|
|
const offered = candidates
|
||
|
|
.map((c) => c.urn)
|
||
|
|
.filter((urn) => selectedSchools.some((s) => s.urn === urn));
|
||
|
|
|
||
|
|
const count = offered.length;
|
||
|
|
const href = `/compare?urns=${[thisUrn, ...offered].join(',')}`;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={styles.footer}>
|
||
|
|
<p aria-live="polite">
|
||
|
|
<strong>
|
||
|
|
{count
|
||
|
|
? `${count} school${count === 1 ? '' : 's'} selected`
|
||
|
|
: 'Compare side by side'}
|
||
|
|
</strong>
|
||
|
|
{count
|
||
|
|
? 'This school is included automatically.'
|
||
|
|
: 'Add a school to compare it with this one.'}
|
||
|
|
</p>
|
||
|
|
{count ? (
|
||
|
|
<Link className={styles.compare} href={href}>
|
||
|
|
Compare {count + 1} schools →
|
||
|
|
</Link>
|
||
|
|
) : (
|
||
|
|
<span className={styles.compare} aria-disabled="true">
|
||
|
|
Compare →
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Write the section**
|
||
|
|
|
||
|
|
Create `nextjs-app/components/school/SimilarSchoolsSection.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
/**
|
||
|
|
* SimilarSchoolsSection — nearby schools of the same phase and a comparable
|
||
|
|
* intake. Server component; only AddToCompareButton is client-side.
|
||
|
|
*
|
||
|
|
* The section is allowed to say exactly what the backend matched and no more.
|
||
|
|
* The lede only claims a similar intake when no card came from tier 3, and a
|
||
|
|
* card's chips list what that school actually shares rather than a match it
|
||
|
|
* did not earn.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import Link from 'next/link';
|
||
|
|
import type { SimilarSchool } from '@/lib/types';
|
||
|
|
import { schoolUrl } from '@/lib/utils';
|
||
|
|
import { AddToCompareButton } from './AddToCompareButton';
|
||
|
|
import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar';
|
||
|
|
import { Section } from './sectionShared';
|
||
|
|
import styles from './SimilarSchools.module.css';
|
||
|
|
|
||
|
|
const MINIMUM = 2;
|
||
|
|
|
||
|
|
export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean {
|
||
|
|
return (similar?.length ?? 0) >= MINIMUM;
|
||
|
|
}
|
||
|
|
|
||
|
|
function metricLabel(key: string): string {
|
||
|
|
return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatMetric(value: number | null, key: string): string {
|
||
|
|
if (value == null) return 'Not published';
|
||
|
|
return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SimilarSchoolsSection({
|
||
|
|
urn,
|
||
|
|
schoolName,
|
||
|
|
phaseNoun,
|
||
|
|
thisMetricValue,
|
||
|
|
similar,
|
||
|
|
}: {
|
||
|
|
urn: number;
|
||
|
|
schoolName: string;
|
||
|
|
phaseNoun: string;
|
||
|
|
thisMetricValue: number | null;
|
||
|
|
similar?: SimilarSchool[] | null;
|
||
|
|
}) {
|
||
|
|
if (!shouldRenderSimilar(similar)) return null;
|
||
|
|
const schools = similar as SimilarSchool[];
|
||
|
|
|
||
|
|
// One card matched on phase alone, so the section may not claim the set
|
||
|
|
// shares an intake with this school.
|
||
|
|
const loosest = Math.max(...schools.map((s) => s.tier));
|
||
|
|
const metricKey = schools[0].metric_key;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Section id="similar">
|
||
|
|
<h2 className={styles.heading}>Similar schools nearby</h2>
|
||
|
|
<p className={styles.lede}>
|
||
|
|
{loosest >= 3
|
||
|
|
? `Other ${phaseNoun} schools near ${schoolName}.`
|
||
|
|
: `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<details className={styles.method}>
|
||
|
|
<summary>How these schools are chosen</summary>
|
||
|
|
<div>
|
||
|
|
<p>
|
||
|
|
Schools of the same phase, nearest first, preferring those whose intake
|
||
|
|
resembles this one — gender, religious character, and whether the school
|
||
|
|
selects by ability. Special schools are only ever compared with other
|
||
|
|
special schools.
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
The labels on each card list what that school actually shares with this
|
||
|
|
one. Where nothing close enough was found nearby, the match is loosened
|
||
|
|
and the labels say less.
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
Distances are straight-line from this school, not road distance and not
|
||
|
|
from your home. Being listed here is not a recommendation.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
|
||
|
|
<ul className={styles.grid}>
|
||
|
|
{schools.map((school) => (
|
||
|
|
<li key={school.urn} className={styles.school}>
|
||
|
|
<p className={styles.distance}>{school.distance_miles} miles away</p>
|
||
|
|
<h3 className={styles.name}>
|
||
|
|
<Link href={schoolUrl(school.urn, school.school_name)}>
|
||
|
|
{school.school_name}
|
||
|
|
</Link>
|
||
|
|
</h3>
|
||
|
|
<p className={styles.meta}>
|
||
|
|
{[school.school_type, school.age_range && `Ages ${school.age_range}`]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(' · ')}
|
||
|
|
</p>
|
||
|
|
<ul className={styles.shared}>
|
||
|
|
{school.shared.map((label) => (
|
||
|
|
<li key={label} className={school.tier >= 3 ? styles.chipLoose : styles.chip}>
|
||
|
|
{label}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
<div className={styles.metric}>
|
||
|
|
<p
|
||
|
|
className={
|
||
|
|
school.metric_value == null ? styles.valueAbsent : styles.value
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{formatMetric(school.metric_value, school.metric_key)}
|
||
|
|
</p>
|
||
|
|
<p className={styles.metricLabel}>{metricLabel(school.metric_key)}</p>
|
||
|
|
{thisMetricValue != null && (
|
||
|
|
<p className={styles.metricRef}>
|
||
|
|
{formatMetric(thisMetricValue, metricKey)} at this school
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<AddToCompareButton school={school} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<SimilarSchoolsCompareBar thisUrn={urn} candidates={schools} />
|
||
|
|
</Section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Write the stylesheet**
|
||
|
|
|
||
|
|
Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only —
|
||
|
|
`darkThemeSafety.test.ts` fails the build on any hardcoded colour:
|
||
|
|
|
||
|
|
```css
|
||
|
|
.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; }
|
||
|
|
.lede { margin: 0.5rem 0 0; color: var(--text-secondary); max-width: 64ch; }
|
||
|
|
|
||
|
|
.method { margin: 0.9rem 0 1.4rem; }
|
||
|
|
.method summary { cursor: pointer; color: var(--brand); font-size: 0.85rem; font-weight: 500; width: fit-content; }
|
||
|
|
.method div { margin-top: 0.6rem; padding: 0.9rem 1rem; background: var(--bg-secondary); border-radius: 8px; }
|
||
|
|
.method p { margin: 0 0 0.55rem; font-size: 0.82rem; color: var(--text-secondary); max-width: 70ch; }
|
||
|
|
.method p:last-child { margin-bottom: 0; }
|
||
|
|
|
||
|
|
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; list-style: none; margin: 0; padding: 0; }
|
||
|
|
@media (max-width: 820px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
|
|
@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
|
||
|
|
|
||
|
|
.school { position: relative; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); }
|
||
|
|
.school:hover { border-color: var(--border-strong); }
|
||
|
|
|
||
|
|
.distance { margin: 0 0 0.6rem; font-size: 0.75rem; color: var(--text-muted); }
|
||
|
|
.name { font-family: var(--font-display); font-size: 1rem; line-height: 1.35; margin: 0 0 0.35rem; }
|
||
|
|
.name a { color: var(--text-primary); text-decoration: none; }
|
||
|
|
/* The whole card is the link target; the button sits above it on z-index. */
|
||
|
|
.name a::after { content: ""; position: absolute; inset: 0; border-radius: 8px; }
|
||
|
|
.name a:hover { color: var(--brand); text-decoration: underline; }
|
||
|
|
.meta { margin: 0 0 0.75rem; font-size: 0.78rem; color: var(--text-muted); }
|
||
|
|
|
||
|
|
.shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; }
|
||
|
|
.chip { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: var(--brand-bg); color: var(--brand); border: 1px solid transparent; }
|
||
|
|
.chipLoose { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
||
|
|
|
||
|
|
.metric { margin-top: auto; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||
|
|
/* No valence colour here, deliberately: green and terracotta mean "against the
|
||
|
|
England average" everywhere else on the site, and colouring a neighbour
|
||
|
|
against this school would read as ranking the neighbours. */
|
||
|
|
.value { font-family: var(--font-display); font-size: 1.6rem; font-weight: 700; letter-spacing: -0.6px; margin: 0; color: var(--text-primary); }
|
||
|
|
.valueAbsent { font-size: 0.95rem; font-weight: 600; margin: 0; color: var(--text-muted); }
|
||
|
|
.metricLabel { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--text-secondary); }
|
||
|
|
.metricRef { margin: 0.1rem 0 0; font-size: 0.75rem; color: var(--text-muted); }
|
||
|
|
|
||
|
|
.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 40px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); }
|
||
|
|
.add:hover { border-color: var(--brand); background: var(--brand-bg); }
|
||
|
|
.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; }
|
||
|
|
|
||
|
|
.footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.85rem; margin-top: 1.25rem; padding-top: 1.1rem; border-top: 1px solid var(--border); }
|
||
|
|
.footer p { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
|
||
|
|
.footer strong { display: block; font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
|
||
|
|
/* Coral is the one decisive action per screen, and in this section this is it. */
|
||
|
|
.compare { min-height: 44px; padding: 0 1.25rem; border-radius: 8px; font-size: 0.88rem; font-weight: 600; background: var(--action); color: var(--action-on); border: 1px solid var(--action); text-decoration: none; display: inline-flex; align-items: center; }
|
||
|
|
.compare:hover { background: var(--action-strong); border-color: var(--action-strong); }
|
||
|
|
.compare[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
|
||
|
|
|
||
|
|
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Run the tests to verify they pass**
|
||
|
|
|
||
|
|
Run (from `nextjs-app/`):
|
||
|
|
```sh
|
||
|
|
npm test -- --runInBand SimilarSchoolsSection darkThemeSafety
|
||
|
|
npm run typecheck
|
||
|
|
```
|
||
|
|
Expected: PASS on both suites, and typecheck clean.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add nextjs-app/lib/types.ts nextjs-app/components/school/SimilarSchoolsSection.tsx nextjs-app/components/school/SimilarSchools.module.css nextjs-app/components/school/AddToCompareButton.tsx nextjs-app/components/school/SimilarSchoolsCompareBar.tsx nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx
|
||
|
|
git commit -m "feat(web): similar schools section, honest about what it matched
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Wire it into both templates
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `nextjs-app/lib/schoolSections.ts` (`buildNavItems` at line 143, `buildSecondaryNavItems`)
|
||
|
|
- Modify: `nextjs-app/components/school/PrimarySchoolSections.tsx`
|
||
|
|
- Modify: `nextjs-app/components/school/SecondarySchoolSections.tsx`
|
||
|
|
- Modify: `nextjs-app/app/(frontend)/school/[slug]/page.tsx`
|
||
|
|
- Test: `nextjs-app/__tests__/lib/schoolSections.test.ts` (append, or create if absent)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `shouldRenderSimilar` and `SimilarSchoolsSection` from Task 3.
|
||
|
|
- Produces: nothing later tasks build on.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Append to the existing `nextjs-app/__tests__/lib/schoolSections.test.ts`,
|
||
|
|
matching the imports already at the top of that file:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { buildNavItems, buildSecondaryNavItems, computeSchoolFlags } from '@/lib/schoolSections';
|
||
|
|
|
||
|
|
const navInput = {
|
||
|
|
ofsted: null,
|
||
|
|
admissions: null,
|
||
|
|
admissionDistance: null,
|
||
|
|
hasLocation: true,
|
||
|
|
yearlyDataLength: 1,
|
||
|
|
hasSimilarSchools: true,
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('the similar-schools nav item', () => {
|
||
|
|
it('appears on both templates when the section renders', () => {
|
||
|
|
const flags = computeSchoolFlags({
|
||
|
|
schoolInfo: { phase: 'Primary' } as never,
|
||
|
|
yearlyData: [], absenceData: [], census: null,
|
||
|
|
deprivation: null, finance: null, destinations: null,
|
||
|
|
});
|
||
|
|
expect(buildNavItems(flags, navInput).map((i) => i.id)).toContain('similar');
|
||
|
|
expect(buildSecondaryNavItems(flags as never, navInput).map((i) => i.id)).toContain('similar');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is absent when the section does not render', () => {
|
||
|
|
const flags = computeSchoolFlags({
|
||
|
|
schoolInfo: { phase: 'Primary' } as never,
|
||
|
|
yearlyData: [], absenceData: [], census: null,
|
||
|
|
deprivation: null, finance: null, destinations: null,
|
||
|
|
});
|
||
|
|
const without = { ...navInput, hasSimilarSchools: false };
|
||
|
|
expect(buildNavItems(flags, without).map((i) => i.id)).not.toContain('similar');
|
||
|
|
expect(buildSecondaryNavItems(flags as never, without).map((i) => i.id)).not.toContain('similar');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
|
|
||
|
|
Run (from `nextjs-app/`):
|
||
|
|
```sh
|
||
|
|
npm test -- --runInBand schoolSections
|
||
|
|
```
|
||
|
|
Expected: FAIL — `'similar'` is not in the nav item ids.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add the nav item**
|
||
|
|
|
||
|
|
In `nextjs-app/lib/schoolSections.ts`, add `hasSimilarSchools: boolean;` to the
|
||
|
|
`NavItemsInput` interface, then destructure it in both builders and append, as
|
||
|
|
the **last** item in each (the section renders last, and the scroll-spy order
|
||
|
|
must match the DOM order):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' });
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Render the section in both templates**
|
||
|
|
|
||
|
|
In `PrimarySchoolSections.tsx` and `SecondarySchoolSections.tsx`: add
|
||
|
|
`similarSchools?: SimilarSchool[]` to the props interface, import
|
||
|
|
`SimilarSchoolsSection`, and render it as the last child, after every existing
|
||
|
|
section:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<SimilarSchoolsSection
|
||
|
|
urn={schoolInfo.urn}
|
||
|
|
schoolName={schoolInfo.school_name}
|
||
|
|
phaseNoun={phaseNoun}
|
||
|
|
thisMetricValue={thisMetricValue}
|
||
|
|
similar={similarSchools}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
In `PrimarySchoolSections.tsx`, derive the two values from what the component
|
||
|
|
already has:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
const phaseNoun = (schoolInfo.phase ?? '').toLowerCase() === 'all-through'
|
||
|
|
? 'all-through'
|
||
|
|
: 'primary';
|
||
|
|
const thisMetricValue = flags.latestResults?.rwm_expected_pct ?? null;
|
||
|
|
```
|
||
|
|
|
||
|
|
In `SecondarySchoolSections.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
const phaseNoun = 'secondary';
|
||
|
|
const thisMetricValue = flags.latestResults?.attainment_8_score ?? null;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Pass the payload through the page**
|
||
|
|
|
||
|
|
In `nextjs-app/app/(frontend)/school/[slug]/page.tsx`:
|
||
|
|
|
||
|
|
Add `similar_schools` to the destructure of `data` (around line 155):
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
// Absent on an older API build, exactly like `places` above.
|
||
|
|
const similarSchools = data.similar_schools ?? [];
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to `navInput` (around line 185):
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
hasSimilarSchools: shouldRenderSimilar(similarSchools),
|
||
|
|
```
|
||
|
|
|
||
|
|
importing `shouldRenderSimilar` from
|
||
|
|
`@/components/school/SimilarSchoolsSection`, and pass
|
||
|
|
`similarSchools={similarSchools}` to both `<PrimarySchoolSections>` and
|
||
|
|
`<SecondarySchoolSections>`.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run the full frontend suite**
|
||
|
|
|
||
|
|
Run (from `nextjs-app/`):
|
||
|
|
```sh
|
||
|
|
npm run typecheck && npm test -- --runInBand
|
||
|
|
```
|
||
|
|
Expected: PASS on both. Every existing caller of `buildNavItems` /
|
||
|
|
`buildSecondaryNavItems` now needs `hasSimilarSchools`; a typecheck failure here
|
||
|
|
is a caller you have not updated yet.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add nextjs-app/lib/schoolSections.ts nextjs-app/components/school/PrimarySchoolSections.tsx nextjs-app/components/school/SecondarySchoolSections.tsx "nextjs-app/app/(frontend)/school/[slug]/page.tsx" nextjs-app/__tests__/lib/schoolSections.test.ts
|
||
|
|
git commit -m "feat(web): render similar schools on both detail templates
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Journey and documentation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `e2e/tests/journeys.spec.ts`
|
||
|
|
- Modify: `docs/ARCHITECTURE.md`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: the rendered section from Task 4.
|
||
|
|
- Produces: nothing.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the journey**
|
||
|
|
|
||
|
|
Append to `e2e/tests/journeys.spec.ts`, following the file's existing
|
||
|
|
convention of asserting stable invariants rather than exact numbers:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
/**
|
||
|
|
* Similar schools nearby.
|
||
|
|
*
|
||
|
|
* The section is absent by design where fewer than two schools qualify, so
|
||
|
|
* this walks from a search hit to a school page and asserts the section's
|
||
|
|
* contract only where it renders — and asserts the compare hand-off, which is
|
||
|
|
* the one part that can break silently.
|
||
|
|
*/
|
||
|
|
test('similar schools link on to other schools and into compare', async ({ page }) => {
|
||
|
|
await searchByName(page, 'Primary');
|
||
|
|
await schoolLinks(page).first().click();
|
||
|
|
await page.waitForURL(/\/school\//);
|
||
|
|
|
||
|
|
const section = page.locator('#similar');
|
||
|
|
if ((await section.count()) === 0) {
|
||
|
|
test.skip(true, 'No qualifying similar schools for this school');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every card is a real link to another school page.
|
||
|
|
const links = section.locator('a[href^="/school/"]');
|
||
|
|
expect(await links.count()).toBeGreaterThanOrEqual(2);
|
||
|
|
const href = await links.first().getAttribute('href');
|
||
|
|
expect(href).toMatch(/^\/school\/\d{6}-/);
|
||
|
|
|
||
|
|
// A missing figure says so rather than showing a number.
|
||
|
|
await expect(section.getByText(/miles away/).first()).toBeVisible();
|
||
|
|
|
||
|
|
// The compare hand-off.
|
||
|
|
await section.getByRole('button', { name: /Add to compare/ }).first().click();
|
||
|
|
await expect(
|
||
|
|
section.getByRole('button', { name: /Added to compare/ }).first(),
|
||
|
|
).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Note the staging gate**
|
||
|
|
|
||
|
|
Do **not** try to run this journey locally against a dev server. On this
|
||
|
|
project the E2E gate runs against staging *after* merge, so this journey is not
|
||
|
|
provable in the PR checks. Verify the PR on the unit suites, and check the
|
||
|
|
post-merge staging run.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Document the section**
|
||
|
|
|
||
|
|
In `docs/ARCHITECTURE.md`, under "Frontend boundaries", after the sentence about
|
||
|
|
`components/school/`, add:
|
||
|
|
|
||
|
|
```markdown
|
||
|
|
The similar-schools section is selected in `backend/similar_schools.py` — hard
|
||
|
|
filters that never relax (phase, provision, selectivity, gender) and soft
|
||
|
|
preferences that do (religious character, then gender exactness) — and served on
|
||
|
|
`/api/schools/{urn}`. Its selection rules are presentation logic, deliberately
|
||
|
|
kept out of `marts.*` so they can be tuned by deploy rather than by pipeline run.
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run every check before the PR**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```sh
|
||
|
|
/tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests pipeline/tests scripts/ci/tests -q
|
||
|
|
cd nextjs-app && npm run typecheck && npm test -- --runInBand
|
||
|
|
```
|
||
|
|
Expected: PASS on all three.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit and open the PR**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add e2e/tests/journeys.spec.ts docs/ARCHITECTURE.md
|
||
|
|
git commit -m "test(e2e): cover the similar-schools section and compare hand-off
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
|
|
git push -u origin feat/similar-schools-nearby
|
||
|
|
```
|
||
|
|
|
||
|
|
Open a PR against `main` with passing checks. Do not push to `main` directly and
|
||
|
|
do not trigger the promotion workflow.
|