Commit Graph
414 Commits
Author SHA1 Message Date
TudorandClaude Opus 5.5 ef4a2ccccb fix(school): send the school page its admissions policy, and read it exactly
The header's Selective flag read school_info.admissions_policy, which the
detail endpoint never sent, so no school page could flag Selective while
its search row did (staging E2E: The Grammar School at Leeds). The detail
payload now carries it, and a contract test checks it carries every field
the header's flags read.

Sending it would have switched on two older copies of the tag logic #176
fixed in the rows. The Admissions section and the cut-off note both tested
includes('selective'), so every non-selective secondary would have read
"entry is by selective examination". The section also counted "None" as a
faith: Burntwood reads "a faith-based admissions priority (None)" today.
All of them now share isSelective() and hasReligiousCharacter(), which also
treats "Not applicable" as no faith, as the place table already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:43:43 +01:00
TudorandClaude Opus 5.5 41d3f3b971 fix(school): never open a wrapped fact line with a separator
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
At phone widths the fact line wraps, and each dot travelled with the value
after it, so the second line read "· 1,478 pupils". The line now starts
1.125rem left of the column and clips that strip: a value that starts a
line sits flush with the title and its dot is hidden. Measured on staging
at 360, 390 and 430 px: no element past the gutter, no page overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:01:24 +01:00
TudorandClaude Opus 5.5 ad9d3b67f7 feat(search): print the school page's type and flags in search rows
Both rows now take their type label and tags from lib/schoolFacts, so a
parent reads the same words in the list and on the page: "State school"
instead of "Academy converter", "Girls' school" instead of "Girls", the
religious character instead of "Faith priority", and a Nursery class flag
on primaries. Who-can-apply tags are outlined with an inset ring, which
keeps them the size of their neighbours; the Selective tag no longer
borrows the status colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
TudorandClaude Opus 5.5 ca47d08186 feat(school): state the header's facts in one line, and flag what applies
The row of grey chips under the name mixed the council, a GIAS governance
term, the age range and provision, with no labels. Parents could not tell
what "Wandsworth" or "Nursery" meant, and selective, faith and fees were
missing or buried in the details.

The header now prints one fact line (phase pill · ages · type · pupils),
then flags only for what applies, in a fixed order: who can apply or what
it costs (Fee-paying, Selective, Girls'/Boys' school, the religious
character as recorded), then what the school offers (Nursery class, Sixth
form). Flags carry no hue: they are facts, not verdicts. The council joins
the address unless the address already names it. The details keep
headteacher, website, phone, academy trust (hidden when it has the
school's own name) and capacity; county, constituency and religious
character leave the header. The proposed-to-close strip is unchanged.

lib/schoolFacts holds the type label and the flags so the search rows can
print the same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
Tudor e344298440 Merge branch 'fix/search-row-facts' into feat/header-facts-and-flags 2026-10-02 22:51:35 +01:00
TudorandClaude Opus 5.5 5f9caad7f4 fix: count pupils as the whole school, not the GCSE year group
fact_performance's total_pupils is the cohort a year's results were
measured on. For a secondary that is Year 11 alone, and the list API sent
it as the card's "pupils": Burntwood showed 245 in search and 1,462 on its
page. The list and place payloads now carry the register's whole-school
count, and nothing when the register has none. Map popups and the compare
basket read the same field.

The header and the wellbeing section fell back to the same results figure
when the census had no record. They now fall back to the register count.
The shell took yearlyData only for that fallback, so the prop is gone and
the results array no longer ships to the client for the chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:51:31 +01:00
TudorandClaude Opus 5.5 e65c93b68e fix(search): tag only what the register records
The secondary row tested the admissions policy with includes('selective'),
which "Non-selective" passes, so every comprehensive (Burntwood, Graveney)
was tagged Selective. It now needs an exact "Selective".

Both rows excluded only "Does not apply" from the religious character, so a
school recorded as "None" got "Faith priority" (Putney High) or a bare
"None" chip (Abacus Belsize Primary). hasReligiousCharacter() treats both of
the register's no-faith values as no faith.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:50:44 +01:00
TudorandClaude Opus 5.5 e3f21a5bc7 fix(school): address review on the header chip fixes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
- E2E: list with page_size (per_page was ignored), fail clearly if the
  gender filter is ignored, and require nursery_provision on the detail
  payload so the Nursery assertion cannot pass vacuously.
- singleSexLabel ignores case, as hasNurseryClasses does.
- Header test: type withSchool with Partial<School>, and match the
  single-sex labels exactly instead of any span ending in "school".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:16:49 +01:00
TudorandClaude Opus 5.5 dea435a906 fix(school): show Nursery only for nursery classes, and say Girls' school
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
GIAS NurseryProvision is text ("Has Nursery Classes", "No Nursery
Classes", "Not applicable"), but the header and the place table tested it
for truthiness. Every school with a value got a Nursery chip or a "Yes",
including secondaries aged 11-18. hasNurseryClasses() matches the one
value that means a nursery, and the type now says the field is a string.

The single-sex chip appended 's to the plural GIAS gender, giving
"Girls's school". singleSexLabel() gives "Girls' school" / "Boys' school".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:02:32 +01:00
tudor 9b765125ad Merge pull request 'fix(search): desktop Clear all keeps the search' (#173) from fix/desktop-clear-keeps-search into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m8s
Reviewed-on: #173
2026-10-02 15:50:22 +00:00
TudorandClaude Opus 5.5 0450f8ecd6 fix(search): desktop Clear all keeps the search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
The desktop toolbar's Clear went to the bare home URL, dropping the
search along with the filters, so it landed on the home page instead of
the unfiltered results. It now clears every filter and keeps the search,
postcode and distance, as the phone's Clear all already did. It is
offered only once a filter is applied, since with only a search there is
nothing for it to clear, and is labelled Clear all to match the phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:43:41 +01:00
TudorandClaude Opus 5.5 27d83f9bd0 fix(modal): give the close button a 44px touch target
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 12s
The close button was 40px square: a 24px icon in 8px of padding. MOBILE.md
sets 44px as the minimum, and the phone filter sheet journey, which
checks every target in the sheet, has failed on it in the staging E2E
gate since the sheet shipped. Measured on staging with the rule
injected: the button is 44x44, nothing in the sheet is under 44, and
the page does not overflow. Every Modal gets the same button, the
compare page's school search included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:40:45 +01:00
TudorandClaude Opus 5.5 e78ec14e2e feat(search): one state school group, not academy and council-run
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The School type filter split state schools into "academy or free
school" and "council-run". The two were near-halves of one pool (11,186
and 9,316 schools), so choosing one rarely narrowed anything, and the
split did not follow the difference a parent feels most, admissions:
voluntary aided and foundation schools set their own, as academies do.
Faith, which voluntary aided mostly meant, has its own filter.

They are now one group, "State school (free)", leaving five. The old
keys academy and council resolve to state, so a link made with them
keeps working instead of falling through to the raw-label path and
returning nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:51:35 +01:00
TudorandClaude Opus 5.5 49ac96b487 refactor(search): drop the applied-filter row above the results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The row repeated the search, local authority and school type above the
results list, and nothing else: no phase, distance, gender, sixth form,
admissions or faith. The filter bar already shows every one of them, in
its selects and More filters count on desktop and as removable chips on
phones, so the row was a partial second copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:27:53 +01:00
TudorandClaude Opus 5.5 214c80663e docs(spec): describe the API filters as they are built
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The /api/schools section still described filtering on stored
school_type_group and faith_groups columns, which the architecture
correction had already dropped: the filters look up the name columns at
request time. Also trims a trailing blank line from two test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:25:20 +01:00
TudorandClaude Opus 5.5 84caee9f72 fix(search): show applied type and faith by label, in any case
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
The results list's chip printed the school type group's key ("post16")
rather than its label. A URL value that no option spelled the same way
(an old link's raw GIAS type, or a key in another case) left the select
reading "Any school type" while the list stayed filtered, and choosing
"Any" changed nothing. Values now match options ignoring case, as the
API does, and an unlisted one is shown as an option of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:00:54 +01:00
TudorandClaude Opus 5.5 17bfb4a3f7 feat(search): school type groups and a faith filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:54:04 +01:00
TudorandClaude Opus 5.5 68452681f8 fix(search): match primary phases by their words, not exact labels
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The secondary-only filters were hidden for an exact set of lowercased
labels, so a change of case, hyphen or spacing in a GIAS phase label
would have left them showing. Phases are now matched on their words, as
compareGroups already reads "primary".

The E2E journey no longer counts school types: it asserts that choosing
one leaves the same list on offer, whatever the data holds. A test now
pins that a filter with no options is left out, not shown with only its
"any" option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:28:34 +01:00
TudorandClaude Opus 5.5 0cc4f52816 fix(search): offer every filter option, not only those in the results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m17s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m22s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
School type, gender and admissions took their options from the result
set, which the filter had already narrowed: choose "Girls" and only
"Girls" was offered, so switching to "Boys" meant clearing first. They
now offer the full lists from /api/filters, as phase already did. Local
authority stays scoped to the results, so a postcode search offers the
councils nearby rather than all 153.

Whether gender, sixth form and admissions show was also decided by the
results (any secondary school in them). It is now decided by the phase
alone: hidden for Primary, Nursery and Middle deemed primary, shown
otherwise. Choosing one of those phases clears the three filters, which
would otherwise stay applied with no control showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:22:39 +01:00
TudorandClaude Opus 5.5 fa49164143 fix(search): keep the filter sheet usable while a change lands
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The sheet's controls were disabled while a filter change navigated, and
a control disabled under focus drops it to <body>, out of the dialog.
They now stay enabled, with aria-busy on the sheet instead.

The disabling had also been covering a race: updateURL built from
useSearchParams, which only catches up once a navigation lands, so a
second change made before then undid the first. It now builds on the
URL the navigation in flight is heading to.

The sheet also closes if the screen widens past phone width while it is
open, so its selects and the desktop row's are never both showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:40:29 +01:00
TudorandClaude Opus 5.5 cf3c773f86 feat(search): filter on phones through one sheet
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
On phones the results toolbar's filters were a sideways-scrolling row
led by "More filters", so phase showed only in part and school type not
at all, and the panel "More filters" opened held neither of them.

Phones now get a single Filters button beside the folded search summary,
counting every applied filter. It opens a bottom sheet with every filter:
distance as five segments, then phase, school type, local authority and
the secondary-only filters. Changes apply at once, as on desktop, so the
footer's "Show N schools" only closes the sheet. Applied filters show as
removable chips on a second line, which appears only when something is
applied. Desktop and tablet are unchanged.

Modal gains dialog semantics, a pinned footer and focus handling, and
moves above the pinned toolbar, the floating List/Map button and the
comparison toast, which its old z-index sat beneath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:31:53 +01:00
TudorandClaude Opus 5.5 c077c27720 fix(map): draw the map card's View button like the list's
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
leaflet.css colours every link in the map `.leaflet-container a`
(#0078A8, specificity 0,1,1), which beat .btn-tertiary (0,1,0), so the
card's View button came out in Leaflet's link blue in both themes. Pull
it back onto --text-secondary next to the other Leaflet overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:10:14 +01:00
TudorandClaude Opus 5.5 355a5a841c feat(search): offer a quarter-mile distance
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
Adds "Within 0.25 miles" to the Distance control; 0.5 stays the default.
The radius shown in the results heading and drawn on the map was rounded
to one decimal place, which would have turned 0.25 into 0.3. It now keeps
two, and the list heading says "1 mile" rather than "1.0 miles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:52:59 +01:00
TudorandClaude Opus 5.5 96deab7d58 fix(search): start postcode searches at half a mile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
New postcode searches, the near-me button and URLs without a radius now
use 0.5 miles. A postcode URL with no radius used to show "1 mile" in the
Distance control while the API applied its own 5-mile default; the page
and the map fetch now send the same default the control displays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:40:44 +01:00
TudorandClaude Opus 5.5 8ebe461435 fix(search): set the toolbar's line count by width, not by results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The results toolbar wrapped wherever it ran out of room, and the List/Map
switch only appears when there are results, so the same search took two
lines with results and one without.

From 1340px the controls never wrap away from the search, which takes
what they leave (at least 12rem); phase and type chips cap at 11rem to
fit. Between 641px and 1339px the controls always take a full line of
their own. The switch now sits in FilterBar's row via a viewSwitch slot,
so that line runs the full width instead of stopping short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:27:14 +01:00
TudorandClaude Opus 5.5 e211e1376d fix(search): offer every phase while a phase filter is applied
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
The phase select read its options from the result-scoped filters, which
the backend computes after applying the phase filter. With secondary
chosen only secondary and all-through were offered, so switching to
primary meant going back to "Any phase" first. Read the global phase
list instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:06:04 +01:00
TudorandClaude Opus 5.5 5df8c93420 fix(search): keep the map list's count and sort on one line
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
Beside the map, "19 schools within 1.0 miles of SW196AR" (359px) and the
sort menu (281px, as wide as its longest option) could not share the
pane's 427px, so the sort dropped to a second line.

In the map view the heading now reads "19 schools within 1 mile": the
postcode is already in the search box just above, and the radius reads
as the toolbar's does. The sort menu is capped at 11rem, truncating a
long choice, and the header no longer wraps; on a narrow pane the count
truncates rather than pushing the sort down. List view keeps the full
heading. Tried on staging by injecting the change: 243px + 176px, one
line at 1440.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:26 +01:00
TudorandClaude Opus 5.5 ca4ddd2b12 fix(search): address review on the shared result rows
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
- The rows' narrow layout now switches at a 608px list, not 600px. Below
  769px the page pads 1rem each side, so a 640px screen gives a 608px
  list: exactly the old max-width: 640px media query, where 600px left
  633-640px screens on the wide layout.
- rowContainerGuard.test.ts fails if anything other than HomeView renders
  SchoolRow or SecondarySchoolRow, or if one of HomeView's row lists loses
  its `results` container. Outside one the rows silently keep their wide
  layout on phones. (Checked: HomeView is the only importer today.)
- Picking a pin from the list beside the map now works from the keyboard:
  each row carries a "Show <school> on the map" button, visually hidden
  until focused, with aria-pressed for the selected school. The row itself
  cannot be the button, since it holds links and buttons of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:09:47 +01:00
TudorandClaude Opus 5.5 dff3e210ab feat(search): show the list view's rows beside the map
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The list beside the map used its own cut-down card, so it showed less
than the list view (no phase, age range, denomination, trend, local
authority) and looked different. It now renders the list view's
SchoolRow / SecondarySchoolRow, through one renderRow shared by the list
view, the map pane and the phone's bottom sheet, which also gains
Remove from compare. CompactSchoolItem and its styles are gone.

The rows switched to their stacked layout on a max-width: 640px media
query, which never fires in a ~430px pane on a desktop screen. It is now
a container query on the list the row sits in (`results`, set on the
list view, the map pane and the bottom sheet). 600px of list is a 632px
screen less padding, so phones lay out as before.

Clicking a row, outside its links and buttons, still picks its pin, and
the selected row is outlined. The pane widens to 360–460px for the
taller rows.

The bottom sheet's close button was a 30px target; it is now 44px with
the same 30px circle (MOBILE.md), and the phone journey checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:03:50 +01:00
TudorandClaude Opus 5.5 a37da15008 fix(search): keep the phone results toolbar inside the screen
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
The search summary ("SW196AR · within 1 mile  Edit") ran about 40px off
the right edge on every phone, cutting "Edit" in half, and the filter row
bled past the screen with it (reported on an iPhone 17, 402px).

The phone toolbar is a column flex container, but it kept flex-wrap:
wrap from the desktop rule. In a wrapping flex container each line is as
wide as its widest item's content, not the container, so the summary's
~410px of text stretched the line past a 370px toolbar. nowrap makes
stretch mean the container's width. Verified on staging by injecting the
rule: nothing in the toolbar passes the right edge at 360 or 402px.

The E2E overflow check could not see this: .main clips on x, so
scrollWidth stayed equal to the viewport. The phone journey now measures
the toolbar's own right edges, and runs at 402px as well. It also waits
for the bottom sheet's slide-up to finish before measuring, and measures
the Map/List button again after the sheet pushes it up; both assertions
read a mid-animation or stale box before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:08:56 +01:00
TudorandClaude Opus 5.5 1bb3e0360f feat(search): open postcode searches on the map on phones too
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
Trial the map as the default for every postcode search, not only on
desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows
how often readers switch away). The view is now derived: the reader's
choice once made, else the default for the kind of search. That follows
a hero search through client-side navigation without syncing state.

With nothing left varying by device, the user-agent default goes
(lib/device.ts, the headers() read in page.tsx). That settles two review
points: the HTML no longer needs Vary: User-Agent, and there is no
narrow-window fallback left to flash the map before the list.

Review fix in LeafletMapInner: the card reopened only when the selection
or the school list changed, so a rebuild caused by the centre, reference
point or radius closed it for good while the page still had the school
selected. The selection effect now follows a pins-rebuilt counter.
SchoolMap memoises its fallback centre, which was a fresh array on every
render and would have rebuilt every pin each time.

Phones hide the map view's list pane, so it no longer builds up to 500
hidden cards there (decided after mount, so hydration still matches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:50:34 +01:00
TudorandClaude Opus 5.5 4e4b30e812 feat(search): align the map view with the mockup and open it on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The map view now follows option B of the results-controls mockups:

- The list sits in a pane on the left, under the result count and sort,
  and the map fills the rest of the screen below the pinned toolbar. The
  toolbar's height is measured, so the split ends 1rem above the bottom
  of the screen however the controls wrap.
- Pins are brand-teal dots and the selected one is coral. The search
  location is an ink dot, the search radius a dashed circle with its
  distance, and the view fits that circle. Tiles are muted and the zoom
  sits under the fullscreen button.
- A school picked on the map or in the list opens a card on the map
  (View, + Compare, following the basket), and its list card is ringed
  and scrolled into view. Phones keep the bottom sheet.
- The list cards show the full name, Ofsted and school type, the
  headline figure and pupils.

The map cards now follow the list rows: no England benchmark for
special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead
showed "0% RWM -62 pts vs national"). That rule moves to a shared
listRwmValue helper.

A postcode search opens on the map for desktop browsers, chosen on the
server from the user agent so the list never paints first. The client
falls back to the list below 1024px, and follows the default through
client-side navigation from the hero until the reader picks a view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:30:33 +01:00
TudorandClaude Opus 5.5 367a07c15d fix(search): address review on the results toolbar
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
- Refold the phone search, and resync its text, when the query changes
  without a submit (back/forward, "Use my location"). The summary and the
  unfolded input could otherwise describe two different searches.
- Cap the "More filters" panel at every width, not only on phones, so an
  open panel scrolls inside the pinned toolbar on a short screen.
- Drop the phone filter row's right-edge fade once it is scrolled to the
  end or does not overflow (the MOBILE.md pattern), and give focus rings
  room inside the scroll clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:47:57 +01:00
TudorandClaude Opus 5.5 983a581555 feat(search): pin the results toolbar and float the map button on phones
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The List/Map switch was a small grey control beside the results heading,
and the filters were plain dropdowns labelled "All Phases" and "Advanced".
Both scrolled away with the first result.

Search, filters and the List/Map switch now share one card pinned under
the header. Distance, phase and school type are pill controls in the row;
"Advanced" becomes "More filters" and counts only what it hides. The
switch is filled brand teal and says which view is on (aria-pressed).

On phones the search folds to a one-line summary once made, the filter
pills scroll sideways, and a floating Map/List button sits above the tab
bar in place of the toolbar switch. The selected pin's card now stacks
under that button instead of covering the tab bar.

Switching view from far down the list scrolls back to the top of the
results, and each switch is tracked as results_view_changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:35:37 +01:00
TudorandClaude Opus 5.5 587cfe3f0b fix(compare): give every basket entry a real phase
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 1m6s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Review of the per-phase limit found entries reaching the basket with no
phase, and a phase-less entry holds a slot in both groups:

- Nearby-school cards added without one. The API now returns each
  peer's own phase (its pool is a phase group, so an all-through school
  can sit beside a primary); the button passes it through, and an older
  API simply leaves the conservative both-groups count in place.
- Baskets saved before this change were never migrated. The compare
  page now backfills missing phases from the data it already fetches,
  and never overwrites a phase the basket has.
- "16 plus" counted against both groups; it is secondary, as the API's
  PHASE_GROUPS files it.

Also rewraps the HomeView doc comment the previous commit left awkward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:04:39 +01:00
TudorandClaude Opus 5.5 0a4c051ee5 feat(compare): limit the basket to five per phase, not five overall
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
A parent choosing a primary and a secondary school at once hit the old
cap of five total. The basket now holds up to five primary and five
secondary schools (ten in all), matching the compare page's phase tabs.

Schools that could land in either tab (all-through, special schools with
phase "Not applicable", unknown phase) count against both groups, so no
tab ever exceeds the five-slot chart palette and point styles.

- lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits
- search modal disables only the full group and says which one
- rankings rows carry the phase of the tab they are ranked under
- shared ?urns= links are trimmed per group
- copy: compare metadata, homepage value prop, How it works card now
  name primary and secondary schools (also better for search intent)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:56:54 +01:00
TudorandClaude Opus 5.5 d1688ac150 copy(web): replace em dashes in public copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the
punctuation for what the dash was doing: a colon before a list or
explanation, a comma for an aside, a full stop between two thoughts,
parentheses for an aside mid-sentence. Covers page titles and meta
descriptions, the home and admissions guide copy, school page headings
and notes, the compare page, metric labels and tooltips.

Two rewrites also fix the sentence around them: the closure banner no
longer repeats "proposed for closure", and the cut-off caveat's list of
priorities now parses.

A lone dash marking a missing value in a table cell stays: it is a data
convention, not prose. A Jest guard walks the source with the TypeScript
parser and fails on any other em dash in a string or JSX text node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:04:27 +01:00
TudorandClaude Opus 5 0571d1c0ff fix(web): stop the sheet-open rule stealing .sectionNav's layout
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
Review catch, and a bad one: the previous commit anchored its insertion on
`padding: 0.5rem 0.75rem;` and closed .sectionNav there. Everything that
followed in the rule — margin-bottom, box-shadow, display: flex, align-items,
gap — was orphaned into .sectionNavSheetOpen, which is only applied while the
mobile jump sheet is open.

So the sticky nav lost its flex layout, spacing and shadow in the closed
state, which is virtually every page view on every school detail page. A
site-wide regression introduced by a fix for one mobile menu.

Redone by anchoring on the complete rule, closing brace included, so nothing
can be orphaned. .sectionNav is now byte-identical to main and the diff is
purely additive; .sectionNavSheetOpen carries the z-index and nothing else.

The staging experiment that validated this fix set nav.style.zIndex = '1100'
with every other declaration intact, so it was always testing this version
rather than the broken one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:43:58 +01:00
TudorandClaude Opus 5 180d6e9b3e fix(web): lift the jump sheet above the bottom tab bar
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 37s
On mobile the last item in "Jump to section" was painted over by the fixed
bottom tab bar and could not be tapped. Reported as nearby schools missing
from the menu; it was there, underneath the bar.

The sticky nav sets `position: sticky` with `z-index: 10`, which makes it a
stacking context. The sheet's own `z-index: 1600` therefore orders it only
inside that context — against the tab bar (z-index 1000) the nav's 10 is what
counts, so the bar wins. Verified on staging: every menu item returns itself
from elementFromPoint except the last, which returns the tab bar.

Latent rather than new. With five sections the list stopped just above the bar;
"Nearby schools" made six, and the sixth is the first to reach it. Any section
added later would have done the same.

Lifted only while the sheet is open, and only to 1100 — above the bar, below
the comparison toast (2000), the fullscreen map (5000) and the info popover
(9999). The backdrop rises with it, so tapping over the bar now dismisses the
sheet instead of navigating away.

The journey asks what a thumb asks: for each item, whether it is the topmost
element at its own centre. A bounding-box check cannot see this — the item is
in the viewport and the right size, just underneath something. Confirmed to
fail against current staging, naming "Nearby schools", before the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:35:25 +01:00
TudorandClaude Opus 5 cd6a45bf7d refactor: rename similar → nearby, so the code says what the section does
The section ranks on distance and is headed "Other schools nearby", but every
identifier still called it "similar" — the exact drift that leaves a later
reader trusting a name over the behaviour.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:06:32 +01:00
TudorandClaude Opus 5 5c0ccc693d fix: order nearby schools by distance, not by how alike they are
Reported from staging: a Catholic primary showed six Catholic primaries, none
of them close enough to be a real option, and omitted the community school
down the road.

Three causes, compounding. Ranking put tier before distance, so a faith match
at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule —
added so a cap of six would not drag in weak distant matches — filled the row
from the best tier before it ever widened, which is what made every card
Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city
for a primary, whose catchments are routinely under a mile.

The premise was backwards. For a parent, distance is a constraint and intake is
a preference; a school beyond a primary catchment is not a weaker option, it is
not an option. So distance now decides the order and nothing else does. The
hard filters are untouched — they were always where the defensibility lived.
Similarity survives as chips on the card: reported, so a reader applies their
own weighting, rather than ranked, so we apply ours for them.

Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity
bound, not a target: ordering already handles density, so the cap only decides
what happens where an area is sparse. A primary with nothing inside two miles
now renders no section, which is the honest answer.

Deleted: the tier system, the stopping rule, the tier-dependent lede, the
`tier` field, the tier-3 fallback chip and its style. select_similar also stops
taking is_secondary — it reads the phase from the subject's own row, so no
caller can hand it one that disagrees with the data.

The heading is now "Other schools nearby". The hard filters still guarantee a
comparable set, but nothing ranks on likeness, so the heading no longer says it
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:06:32 +01:00
TudorandClaude Opus 5 2175dccb7c fix(api): treat "16 plus" as secondary, the way PHASE_GROUPS already does
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 9s
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
GIAS phase 6 is "16 plus", and PHASE_GROUPS deliberately files it in the
secondary group. The payload helper decided the same question with
`"secondary" in phase_text`, which that value does not satisfy — so a
sixth-form college was handed the primary bucket and offered infant schools
as its peers, with the KS2 metric key to label them. No crash; just a page
confidently showing the wrong schools.

The decision now lives in similar_schools.is_secondary_phase, beside the
PHASE_GROUPS bucket it selects from, so the two cannot drift again. A test
pins them together.

The same binary assumption had a second output. computeSchoolFlags tests for
the substring too, so a 16-plus school renders the primary template, and the
composer was labelling the section from the template: "Other primary schools
near <sixth form college>" above a row of secondaries. The section now
derives its noun from the school's own phase, which also removes the
duplicated wording from both composers. A 16-plus school's candidates span
the whole secondary group, so no single noun fits and it gets the honest
general one.

Reported in review on #150.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:41:49 +01:00
TudorandClaude Opus 5 4e0d8bcf87 feat(web): render similar schools on both detail templates
Inside SchoolDetailShell rather than after it, because the sticky nav's
scroll-spy finds sections with getElementById and can only reach one that
lives in the shell. Last in the order, and last in the nav, because the two
must agree or the nav links to an anchor that was never rendered.

hasSimilarSchools is optional on NavItemsInput, matching hasLocation beside
it: absent has to mean "no section", and making it required would have
churned ten unrelated call sites for no added safety.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:42:59 +01:00
TudorandClaude Opus 5 91314a80b5 feat(web): similar schools section, honest about what it matched
Server-rendered cards inside a client carousel that scrolls rather than
paginates, so all six links stay in the initial HTML and the row still works
with JavaScript off. Three client islands, split by what each needs: a school,
the whole selection, a DOM ref.

The lede claims a similar intake only when no card came from tier 3, chips
list what a school actually shares, a missing figure reads "Not published",
and the neighbour's number carries no valence colour — green and terracotta
mean "against England" everywhere else, and colouring it here would read as
ranking the neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:41:40 +01:00
TudorandClaude Opus 5 0c901cd0d1 feat(ci): gate promotion on the image set that actually passed E2E
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 6m3s
Staging health polling asked only whether something answered HTTP 200 at
the base URL. It could not tell the new deployment from the old one, so
journeys could pass against the previous release, and concurrent merges
could move the staging tags underneath a run in flight.

Each staging run now mints a build ID and stamps all three images with
the commit and that ID, as labels and — for frontend and backend — as a
build-time JSON file that environment overrides cannot rewrite.
/release.json reports both identities uncached, and scripts/ci/release.py
polls for the expected pair before and after the journeys. Only then are
the captured build digests tagged verified-<sha>.

Promotion resolves those verified tags to immutable digests, revalidates
their labels, and refuses a mixed or incomplete set before any :prod tag
moves. The whole staging workflow shares one concurrency group with
cancellation disabled, so releases serialise.

The scripts are stdlib-only and unit-tested against mocked registry and
HTTP behaviour; PR checks now run the pipeline and CI suites too. The
runbook records what this cannot prove locally, and that the first
rollout needs a commit built by this workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:17:50 +01:00
TudorandClaude Opus 5 7b41218e6e fix(web): show an outage as an outage, and drop superseded fetches
The home page caught every fetch failure and rendered its empty state,
so a backend outage looked like a site with no schools in it. School
pages turned any error into notFound(), which told visitors — and
crawlers — that a real school had ceased to exist. Place fetches did the
same by returning [] and null. Failures now reach a retryable error
boundary; only a genuine 404 still calls notFound().

"Load more" and the map fetch resolved against whatever state existed
when they returned, so results from an abandoned search appended
themselves to the new ones. Each fetch now carries an AbortController
and checks that its search scope is still current before touching state.
The map only records its cache key on success, so a failed load retries
instead of pinning the stale marker set.

Jest ignored .next/, whose build output otherwise shadowed real suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:17:18 +01:00
TudorandClaude Opus 5 1d8858fbda chore: remove the code the legacy CSV importer left behind
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m2s
`backend/migration.py` and `scripts/migrate_csv_to_db.py` import `School`,
`SchoolResult`, `init_db` and `set_db_schema_version` — names that no longer
exist. `scripts/geocode_schools.py` imports the same removed ORM model. None of
the three can be imported against the current backend, so they were not dormant
utilities anyone could fall back on; they were files that would fail on the
first line. `backend/version.py` existed only to hand `SCHEMA_VERSION` to that
importer, and the FastAPI lifespan performs no version-triggered import.

Three symbols go with them, each confirmed to have no caller: the unvectorised
`haversine_distance`, superseded by the inline NumPy calculation in search;
`fetcher`, an SWR helper for a dependency this project does not install; and
`kmToMiles`. `calculateDistance` stays — CutoffMapPanel uses it.

Two comments pointed at `migrate_csv_to_db.py --drop` to explain why Payload
owns its own schema. The reason survives the script: blog content must stay
clear of the school marts and Airflow's metadata. Reworded rather than deleted,
so the constraint keeps its justification.

docs/LEGACY_CODE.md records what was removed and where to find it in history. It
also records what was deliberately *not* removed, which is the more useful half:
unused UI components awaiting a design decision, manual data utilities whose
operators a repository search cannot see, and fallbacks that look obsolete but
are load-bearing — `data_loader.py`'s older-mart branches, the generated GIAS
dictionary copies, and the `legacy`-named dbt models that annual DAG selectors
explicitly include. A zero-import count is evidence, not a verdict.

The scripts that fetch DfE CSVs are marked historical and kept, pending
confirmation that nobody runs them by hand.

Checked: 190 backend tests, 429 frontend tests, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan
2026-09-14 23:01:22 +01:00
TudorandClaude Opus 5 eaf5e5d180 docs: describe the system that exists, not the one we started with
The README still opened on "Primary School Compass", a KS2 tool for Wandsworth
and Merton served by FastAPI and vanilla JavaScript with Chart.js. Every layer
of that sentence is now wrong: coverage is England-wide across KS2, KS4,
all-through and post-16, Next.js owns the public UI, and school data comes from
dbt-built `marts.*` rather than CSVs loaded at startup. The setup instructions
walked a reader into a virtualenv and a CSV import that cannot build the current
schema, so following the docs produced an empty database and a wrong mental
model at the same time.

Replace the narrative docs with two reference documents that were checked
against the code: docs/ARCHITECTURE.md for request flow, data ownership, the
backend/frontend module boundaries and the real publication sequence, and
docs/DEVELOPMENT.md for the checks that actually run, including the container
and CI version skew that makes "just run pytest" misleading.

The env examples drifted the same way. ALLOWED_ORIGINS is a JSON array, not a
comma-separated list; the frontend needs FASTAPI_URL, DATABASE_URL and
PAYLOAD_SECRET, none of which were documented; and RATE_LIMIT_BURST,
DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE were presented as tuning controls the routes
do not consult. Each is now stated as it behaves.

MIGRATION_SUMMARY.md keeps its content but gains a banner, because it reads like
setup instructions and is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan
2026-09-14 23:01:15 +01:00
TudorandClaude Opus 5 b6c2cd5116 fix(seo): declare the share card, which the route group stopped inheriting
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
The staging E2E gate's one failure. Every link to the site pasted into
a chat has been rendering bare.

Staging serves og:title, og:description, og:url, og:site_name, og:type
and twitter:card, and no og:image at all. So metadata from the layout
reaches the page; only the file convention does not.

app/opengraph-image.tsx does work — _not-found, which lives in the app
root segment, carries an og:image from it in the build output. It does
not reach the site's pages, which live in the (frontend) route group
whose own layout.tsx is the root layout. The icon conventions are not
affected: /icon.png and /apple-icon.png are both linked correctly on
the same page, verified against staging. The asymmetry is the whole
bug, and it arrived with the route-group split that Payload required.

The file stays at the app root. Moving metadata files into a route
group is what drops /robots.txt and hashes /icon.png, which CLAUDE.md
records and which this must not undo — the build still emits all four
of /robots.txt, /icon.png, /apple-icon.png and /opengraph-image. The
root layout points at the route instead, and metadataBase makes it
absolute, which the journey needs since it calls new URL() on the value.

twitter.images is set for the same reason: the card is declared
summary_large_image, and claiming a large-image card while supplying no
image is worse than claiming a summary card.

Checked before fixing that og:image was the only broken assertion in
that journey: the test aborts at line 911, so its apple-touch-icon and
maskable-icon assertions had never run. All four of those assets return
200 image/png from staging, so this does not simply move the failure
further down the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 21:54:32 +01:00
TudorandClaude Opus 5 d65eb58883 fix(seo): build the phase links the docstring already promised
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m48s
Review caught _places_payload documenting a `phase_url` the function
never returned. The docstring was not stray prose: the approved design
included the phase variant — "Primary schools in Beccles" was one of
its four example links — and it was dropped during implementation
without being mentioned. Deleting the sentence would have closed the
report while losing the feature, so the links are built instead.

These are the pages that most needed them. ~950 phase variants were
once reachable by nothing at all: absent from every sitemap and
unlinked from the place page. "Primary schools in brentwood" is the
query they exist to answer.

Membership is read from the registry's own `phase_urns` rather than
re-derived from the school's phase string. The registry already decides
which phases a place publishes and which schools are listed on each, so
asking it is both shorter and the only way the link cannot disagree
with the page it points at. It also means outcodes need no special
case: they carry empty `phase_urns` by design, because nobody searches
"primary schools in SW11", so they report no phase links on their own.

`phases` is a list rather than a single url. An all-through school is
listed on both the primary and secondary pages, so there is no tie to
break and no reason to invent one. Each entry renders directly after
its own place, so "22 primary schools in Brentwood" reads as part of
Brentwood rather than as an unrelated link further along the row.

The e2e journey now follows a phase link where the town publishes one
and asserts it resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 20:54:59 +01:00