W2 shipped ~5,000 place pages and nothing linked into them. The location layer pointed down at school pages; school pages pointed nowhere on the site. Their only anchor was the school's own website, so the ~27k pages carrying most of the site's inbound authority passed it straight off-site, and the new corpus was reachable mainly through the sitemap.
This is W4's first item, and the thing that makes W2 pay off.
Three changes close the loop
A reverse index over the place registry.places_for_urn answers which published places contain a school. It is derived from the registry rather than stored beside it, so the two cannot disagree about which places exist: a place below the publish threshold is absent from the registry and is therefore never offered as a link. A test asserts that invariant across every place in a built registry, which is the thing that would otherwise rot silently.
GET /api/schools/{urn} gains a places array — name, count and canonical path for each. It rides on the request the page already makes, so the school page costs no extra round trip. The frontend types it optional and defaults to empty, because the two images deploy separately and a frontend ahead of the API has to render without it rather than throw.
The page gains a "More schools near here" module and a BreadcrumbList. Anchors state their destination's size — "37 schools in Brentwood" — which is worth more to a reader and a crawler than "see more". With no published places the module renders nothing at all, not an empty heading.
Two judgement calls worth reviewing
The trail is rooted at the homepage, not /schools. I wrote /schools first and then checked: there is no /schools index page. The location layer lives only at /schools/[place], /schools/authority/[la] and /schools/near/[outcode]. Rooting the trail at the bare path would have opened every breadcrumb on the site with a link to a 404 — the exact failure this PR exists to remove.
Outcodes appear in the module but not the breadcrumb. "Schools near CM15" is a real query and a useful link, but nobody navigates Essex → CM15 → school, and a breadcrumb claiming they do describes a hierarchy the site does not have.
The module and the API also order places differently on purpose: the API is widest-first because that is what the trail reads, the module is narrowest-first because a reader on a school page wants its town before its county.
Also
School pages now declare the School type rather than EducationalOrganization, the parent type that covers universities, training providers and nurseries alike.
Tests
The e2e journey asserts the round trip in both directions, following a place page's own first school so the pair is genuinely related rather than hardcoded. A one-way link is what already existed and is not what the journey is for.
New unit coverage: the reverse index (including a school whose town is below threshold, and an unknown URN, which must yield nothing rather than raise), the breadcrumb's skipped levels and absolute URLs, and the module's five render states.
Verification
tsc --noEmit clean.
423 Jest tests across 51 suites pass.
183 backend tests pass.
npx playwright test --list compiles: 118 journeys, the new one present.
next build succeeds with DATABASE_URL unset, as CI builds it, and /school/[slug] still prerenders as ● (SSG).
The module's CSS reuses the exact token vocabulary and heading treatment of schoolSections.module.css, so it reads as one more section of the page rather than a footer bolted underneath, and darkThemeSafety passes.
W2 shipped ~5,000 place pages and **nothing linked into them.** The location layer pointed down at school pages; school pages pointed nowhere on the site. Their only anchor was the school's own website, so the ~27k pages carrying most of the site's inbound authority passed it straight off-site, and the new corpus was reachable mainly through the sitemap.
This is W4's first item, and the thing that makes W2 pay off.
## Three changes close the loop
**A reverse index over the place registry.** `places_for_urn` answers which published places contain a school. It is derived from the registry rather than stored beside it, so the two cannot disagree about which places exist: a place below the publish threshold is absent from the registry and is therefore never offered as a link. A test asserts that invariant across every place in a built registry, which is the thing that would otherwise rot silently.
**`GET /api/schools/{urn}` gains a `places` array** — name, count and canonical path for each. It rides on the request the page already makes, so the school page costs **no extra round trip**. The frontend types it optional and defaults to empty, because the two images deploy separately and a frontend ahead of the API has to render without it rather than throw.
**The page gains a "More schools near here" module and a `BreadcrumbList`.** Anchors state their destination's size — "37 schools in Brentwood" — which is worth more to a reader and a crawler than "see more". With no published places the module renders nothing at all, not an empty heading.
## Two judgement calls worth reviewing
**The trail is rooted at the homepage, not `/schools`.** I wrote `/schools` first and then checked: there is no `/schools` index page. The location layer lives only at `/schools/[place]`, `/schools/authority/[la]` and `/schools/near/[outcode]`. Rooting the trail at the bare path would have opened every breadcrumb on the site with a link to a 404 — the exact failure this PR exists to remove.
**Outcodes appear in the module but not the breadcrumb.** "Schools near CM15" is a real query and a useful link, but nobody navigates Essex → CM15 → school, and a breadcrumb claiming they do describes a hierarchy the site does not have.
The module and the API also order places differently on purpose: the API is widest-first because that is what the trail reads, the module is narrowest-first because a reader on a school page wants its town before its county.
## Also
School pages now declare the `School` type rather than `EducationalOrganization`, the parent type that covers universities, training providers and nurseries alike.
## Tests
The e2e journey asserts the round trip **in both directions**, following a place page's own first school so the pair is genuinely related rather than hardcoded. A one-way link is what already existed and is not what the journey is for.
New unit coverage: the reverse index (including a school whose town is below threshold, and an unknown URN, which must yield nothing rather than raise), the breadcrumb's skipped levels and absolute URLs, and the module's five render states.
## Verification
- `tsc --noEmit` clean.
- 423 Jest tests across 51 suites pass.
- 183 backend tests pass.
- `npx playwright test --list` compiles: 118 journeys, the new one present.
- `next build` succeeds with `DATABASE_URL` unset, as CI builds it, and `/school/[slug]` still prerenders as `● (SSG)`.
The module's CSS reuses the exact token vocabulary and heading treatment of `schoolSections.module.css`, so it reads as one more section of the page rather than a footer bolted underneath, and `darkThemeSafety` passes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
W2 shipped ~5,000 place pages and nothing linked into them. The
location layer pointed down at school pages; school pages pointed
nowhere on the site. Their only anchor was the school's own website, so
the ~27k pages carrying most of the site's inbound authority passed it
straight off-site, and the new corpus was reachable mainly through the
sitemap.
Three things close the loop.
A reverse index over the place registry, places_for_urn, answers which
published places contain a school. Derived from the registry rather
than stored beside it, so the two cannot disagree about which places
exist: a place below the publish threshold is absent from the registry
and therefore never offered as a link. A test asserts that invariant
across every place in a built registry.
GET /api/schools/{urn} gains a `places` array carrying the name, count
and canonical path for each. It rides on the request the page already
makes, so the school page costs no extra round trip. The frontend types
it optional and defaults it to empty, because the two images deploy
separately and a frontend ahead of the API must render without it.
The page gains a "More schools near here" module and a BreadcrumbList.
The module orders narrowest first, because a reader on a school page
wants its town before its county, while the API orders widest first for
the trail. Anchors state their destination's size — "37 schools in
Brentwood" — which is worth more to a reader and a crawler than "see
more". With no published places it renders nothing rather than an empty
heading.
The trail is rooted at the homepage, not /schools. There is no /schools
index page; the location layer lives only at /schools/[place],
/schools/authority/[la] and /schools/near/[outcode]. Rooting it at the
bare path would have opened every breadcrumb with a link to a 404.
Outcodes are omitted from the trail: "schools near CM15" is a real
query and a useful link, but nobody navigates Essex to CM15 to a
school, and a breadcrumb claiming that describes a hierarchy the site
does not have.
School pages also now declare the School type rather than
EducationalOrganization, the parent type that covers universities and
nurseries alike.
The e2e journey asserts the round trip in both directions, following a
place page's own first school so the pair is genuinely related rather
than hardcoded. A one-way link is what already existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
This PR adds a reverse index (places_for_urn) so school detail pages can link back into the location layer, exposes it via a new places field on the school API response, and consumes it on the frontend for a NearbyPlaces module and a School-typed JSON-LD graph with breadcrumbs. The change is well-tested on both backend and frontend, degrades gracefully when the field is absent (staged deploys), and derives everything from the existing place registry so links can't point at unpublished pages.
🟡 Minor
backend/app.py: The docstring for _places_payload says a phase_url key is present when the place publishes a phase page for the school, but the function's returned dict never includes a phase_url field (only kind/slug/name/count/url). The documentation describes a feature that isn't implemented, which will mislead future readers/maintainers.
## 🤖 AI Code Review (Claude Code)
This PR adds a reverse index (places_for_urn) so school detail pages can link back into the location layer, exposes it via a new `places` field on the school API response, and consumes it on the frontend for a NearbyPlaces module and a School-typed JSON-LD graph with breadcrumbs. The change is well-tested on both backend and frontend, degrades gracefully when the field is absent (staged deploys), and derives everything from the existing place registry so links can't point at unpublished pages.
### 🟡 Minor
- **backend/app.py**: The docstring for _places_payload says a `phase_url` key is present when the place publishes a phase page for the school, but the function's returned dict never includes a `phase_url` field (only kind/slug/name/count/url). The documentation describes a feature that isn't implemented, which will mislead future readers/maintainers.
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
This PR builds a reverse index from school URN to the published place pages containing it, threads it through the school-details API, and uses it to add a 'nearby places' link module and BreadcrumbList JSON-LD to school pages, closing off the previously dead-end school page. The core logic (registry-derived, threshold-consistent, degrades gracefully on version skew between frontend/backend) is sound and well-tested; the two issues found are a test-isolation flake and a per-request performance inefficiency, neither of which is production-breaking.
🟡 Minor
backend/tests/test_school_details.py: The client fixture doesn't reset app._place_registry (unlike every other test in the codebase that touches place data), so the new test_places_is_present_even_when_the_school_belongs_to_none test can silently rely on a stale cached registry from an earlier test rather than actually exercising the fixture's own data.
backend/places.py: places_for_urn linearly scans the entire place registry (with tuple membership checks) on every call, and is now invoked on every /api/schools/{urn} request — the site's highest-traffic endpoint. A precomputed urn -> [Place] reverse index built alongside the cached registry would avoid repeating this scan on each page load.
## 🤖 AI Code Review (Claude Code)
This PR builds a reverse index from school URN to the published place pages containing it, threads it through the school-details API, and uses it to add a 'nearby places' link module and BreadcrumbList JSON-LD to school pages, closing off the previously dead-end school page. The core logic (registry-derived, threshold-consistent, degrades gracefully on version skew between frontend/backend) is sound and well-tested; the two issues found are a test-isolation flake and a per-request performance inefficiency, neither of which is production-breaking.
### 🟡 Minor
- **backend/tests/test_school_details.py**: The `client` fixture doesn't reset `app._place_registry` (unlike every other test in the codebase that touches place data), so the new `test_places_is_present_even_when_the_school_belongs_to_none` test can silently rely on a stale cached registry from an earlier test rather than actually exercising the fixture's own data.
- **backend/places.py**: `places_for_urn` linearly scans the entire place registry (with tuple membership checks) on every call, and is now invoked on every `/api/schools/{urn}` request — the site's highest-traffic endpoint. A precomputed `urn -> [Place]` reverse index built alongside the cached registry would avoid repeating this scan on each page load.
Two review findings, both confirmed before fixing.
The client fixture in test_school_details.py patched load_school_data
but not _place_registry, which is a module-level cache. A probe settled
it rather than an argument: poisoning the global with a registry built
from data the fixture never saw, then issuing the fixture's own
request, returned that other dataset's places. So the new
`places == []` assertion was satisfied by a stale registry exactly as
well as by the fixture's own data, and proved nothing. Every other test
that touches place data already reset it; the fixture predates places
existing and was never updated. It resets it now.
places_for_urn walked every place in the registry and did a tuple
membership test against each, on /api/schools/{urn}, the site's
highest-traffic endpoint. It now reads a dict built once per registry.
Measured against a synthetic corpus of 27k schools in 1,650 places:
0.118ms per request becomes 0.0001ms, with the index built once in
21ms. Production carries ~5,000 places, so the scan there is larger
again. The absolute saving per request is small; the point is that it
is repeated on every school page view and costs nothing to remove.
The index is cached against the registry by identity rather than
behind a second flag. Anything that drops _place_registry — every test
that touches place data does — gets a fresh registry object, which no
longer matches what the index was built from, so the index rebuilds
with it. A separate _place_index = None would be one more thing to
forget, and a stale reverse index is precisely the first finding's bug
wearing a different hat.
That invalidation has its own test, and the test was checked by
breaking the identity check: five tests fail without it, so three
existing ones were already relying on it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
This PR adds a reverse place index so school pages can link back into the location layer (nearby town/authority/outcode/phase pages), plus a BreadcrumbList and 'School' JSON-LD type, backed by extensive backend and frontend tests. The cache-invalidation strategy for the new index (identity check against the registry), URL construction for each place kind, and phase-membership filtering were all traced and are correct and consistent with existing conventions and routes.
✅ No issues found.
## 🤖 AI Code Review (Claude Code)
This PR adds a reverse place index so school pages can link back into the location layer (nearby town/authority/outcode/phase pages), plus a BreadcrumbList and 'School' JSON-LD type, backed by extensive backend and frontend tests. The cache-invalidation strategy for the new index (identity check against the registry), URL construction for each place kind, and phase-membership filtering were all traced and are correct and consistent with existing conventions and routes.
✅ No issues found.
tudor
merged commit dc79d653e5 into main2026-09-14 20:29:48 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
W2 shipped ~5,000 place pages and nothing linked into them. The location layer pointed down at school pages; school pages pointed nowhere on the site. Their only anchor was the school's own website, so the ~27k pages carrying most of the site's inbound authority passed it straight off-site, and the new corpus was reachable mainly through the sitemap.
This is W4's first item, and the thing that makes W2 pay off.
Three changes close the loop
A reverse index over the place registry.
places_for_urnanswers which published places contain a school. It is derived from the registry rather than stored beside it, so the two cannot disagree about which places exist: a place below the publish threshold is absent from the registry and is therefore never offered as a link. A test asserts that invariant across every place in a built registry, which is the thing that would otherwise rot silently.GET /api/schools/{urn}gains aplacesarray — name, count and canonical path for each. It rides on the request the page already makes, so the school page costs no extra round trip. The frontend types it optional and defaults to empty, because the two images deploy separately and a frontend ahead of the API has to render without it rather than throw.The page gains a "More schools near here" module and a
BreadcrumbList. Anchors state their destination's size — "37 schools in Brentwood" — which is worth more to a reader and a crawler than "see more". With no published places the module renders nothing at all, not an empty heading.Two judgement calls worth reviewing
The trail is rooted at the homepage, not
/schools. I wrote/schoolsfirst and then checked: there is no/schoolsindex page. The location layer lives only at/schools/[place],/schools/authority/[la]and/schools/near/[outcode]. Rooting the trail at the bare path would have opened every breadcrumb on the site with a link to a 404 — the exact failure this PR exists to remove.Outcodes appear in the module but not the breadcrumb. "Schools near CM15" is a real query and a useful link, but nobody navigates Essex → CM15 → school, and a breadcrumb claiming they do describes a hierarchy the site does not have.
The module and the API also order places differently on purpose: the API is widest-first because that is what the trail reads, the module is narrowest-first because a reader on a school page wants its town before its county.
Also
School pages now declare the
Schooltype rather thanEducationalOrganization, the parent type that covers universities, training providers and nurseries alike.Tests
The e2e journey asserts the round trip in both directions, following a place page's own first school so the pair is genuinely related rather than hardcoded. A one-way link is what already existed and is not what the journey is for.
New unit coverage: the reverse index (including a school whose town is below threshold, and an unknown URN, which must yield nothing rather than raise), the breadcrumb's skipped levels and absolute URLs, and the module's five render states.
Verification
tsc --noEmitclean.npx playwright test --listcompiles: 118 journeys, the new one present.next buildsucceeds withDATABASE_URLunset, as CI builds it, and/school/[slug]still prerenders as● (SSG).The module's CSS reuses the exact token vocabulary and heading treatment of
schoolSections.module.css, so it reads as one more section of the page rather than a footer bolted underneath, anddarkThemeSafetypasses.🤖 Generated with Claude Code
https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
W2 shipped ~5,000 place pages and nothing linked into them. The location layer pointed down at school pages; school pages pointed nowhere on the site. Their only anchor was the school's own website, so the ~27k pages carrying most of the site's inbound authority passed it straight off-site, and the new corpus was reachable mainly through the sitemap. Three things close the loop. A reverse index over the place registry, places_for_urn, answers which published places contain a school. Derived from the registry rather than stored beside it, so the two cannot disagree about which places exist: a place below the publish threshold is absent from the registry and therefore never offered as a link. A test asserts that invariant across every place in a built registry. GET /api/schools/{urn} gains a `places` array carrying the name, count and canonical path for each. It rides on the request the page already makes, so the school page costs no extra round trip. The frontend types it optional and defaults it to empty, because the two images deploy separately and a frontend ahead of the API must render without it. The page gains a "More schools near here" module and a BreadcrumbList. The module orders narrowest first, because a reader on a school page wants its town before its county, while the API orders widest first for the trail. Anchors state their destination's size — "37 schools in Brentwood" — which is worth more to a reader and a crawler than "see more". With no published places it renders nothing rather than an empty heading. The trail is rooted at the homepage, not /schools. There is no /schools index page; the location layer lives only at /schools/[place], /schools/authority/[la] and /schools/near/[outcode]. Rooting it at the bare path would have opened every breadcrumb with a link to a 404. Outcodes are omitted from the trail: "schools near CM15" is a real query and a useful link, but nobody navigates Essex to CM15 to a school, and a breadcrumb claiming that describes a hierarchy the site does not have. School pages also now declare the School type rather than EducationalOrganization, the parent type that covers universities and nurseries alike. The e2e journey asserts the round trip in both directions, following a place page's own first school so the pair is genuinely related rather than hardcoded. A one-way link is what already existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq🤖 AI Code Review (Claude Code)
This PR adds a reverse index (places_for_urn) so school detail pages can link back into the location layer, exposes it via a new
placesfield on the school API response, and consumes it on the frontend for a NearbyPlaces module and a School-typed JSON-LD graph with breadcrumbs. The change is well-tested on both backend and frontend, degrades gracefully when the field is absent (staged deploys), and derives everything from the existing place registry so links can't point at unpublished pages.🟡 Minor
phase_urlkey is present when the place publishes a phase page for the school, but the function's returned dict never includes aphase_urlfield (only kind/slug/name/count/url). The documentation describes a feature that isn't implemented, which will mislead future readers/maintainers.🤖 AI Code Review (Claude Code)
This PR builds a reverse index from school URN to the published place pages containing it, threads it through the school-details API, and uses it to add a 'nearby places' link module and BreadcrumbList JSON-LD to school pages, closing off the previously dead-end school page. The core logic (registry-derived, threshold-consistent, degrades gracefully on version skew between frontend/backend) is sound and well-tested; the two issues found are a test-isolation flake and a per-request performance inefficiency, neither of which is production-breaking.
🟡 Minor
clientfixture doesn't resetapp._place_registry(unlike every other test in the codebase that touches place data), so the newtest_places_is_present_even_when_the_school_belongs_to_nonetest can silently rely on a stale cached registry from an earlier test rather than actually exercising the fixture's own data.places_for_urnlinearly scans the entire place registry (with tuple membership checks) on every call, and is now invoked on every/api/schools/{urn}request — the site's highest-traffic endpoint. A precomputedurn -> [Place]reverse index built alongside the cached registry would avoid repeating this scan on each page load.Two review findings, both confirmed before fixing. The client fixture in test_school_details.py patched load_school_data but not _place_registry, which is a module-level cache. A probe settled it rather than an argument: poisoning the global with a registry built from data the fixture never saw, then issuing the fixture's own request, returned that other dataset's places. So the new `places == []` assertion was satisfied by a stale registry exactly as well as by the fixture's own data, and proved nothing. Every other test that touches place data already reset it; the fixture predates places existing and was never updated. It resets it now. places_for_urn walked every place in the registry and did a tuple membership test against each, on /api/schools/{urn}, the site's highest-traffic endpoint. It now reads a dict built once per registry. Measured against a synthetic corpus of 27k schools in 1,650 places: 0.118ms per request becomes 0.0001ms, with the index built once in 21ms. Production carries ~5,000 places, so the scan there is larger again. The absolute saving per request is small; the point is that it is repeated on every school page view and costs nothing to remove. The index is cached against the registry by identity rather than behind a second flag. Anything that drops _place_registry — every test that touches place data does — gets a fresh registry object, which no longer matches what the index was built from, so the index rebuilds with it. A separate _place_index = None would be one more thing to forget, and a stale reverse index is precisely the first finding's bug wearing a different hat. That invalidation has its own test, and the test was checked by breaking the identity check: five tests fail without it, so three existing ones were already relying on it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq🤖 AI Code Review (Claude Code)
This PR adds a reverse place index so school pages can link back into the location layer (nearby town/authority/outcode/phase pages), plus a BreadcrumbList and 'School' JSON-LD type, backed by extensive backend and frontend tests. The cache-invalidation strategy for the new index (identity check against the registry), URL construction for each place kind, and phase-membership filtering were all traced and are correct and consistent with existing conventions and routes.
✅ No issues found.