Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5 d1a8596208 feat(analytics): measure the location layer, and stop calling it direct
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 40s
The location pages were only half-tracked. Umami counts a pageview for
each of the ~3,900 URLs automatically, but nothing else: components/
places contained no track() call, and place_viewed was not even a
declared event name.

The part that mattered was worse than a gap. getNavigationSource mapped
a same-origin referrer to a funnel source and had no case for /schools/,
so every school view arriving through the location layer fell through to
'direct' — the bucket you read as "typed the URL, no referrer". W2's
whole purpose is funnelling search traffic onto school pages, so the one
measurement that says whether it worked was reporting the wrong answer,
and reporting it confidently. Verified live against staging: expected
"place", received "direct".

/schools/ is checked before /school/. They differ by one letter and mean
different things — the location layer versus a single school — and a
prefix test in the wrong order silently merges them.

place_viewed carries kind, slug, phase and school_count. kind is the
reason it exists: whether to keep investing in these pages turns on
which sort earns engagement, and a pageview cannot say, because all four
families share the /schools/ prefix and only the registry knows which is
which. It is a client component because PlaceView is a server component;
one line in PlaceView covers all four families, since they all render
through it.

Both E2E journeys were verified failing against staging first — one
because place_viewed does not exist there, the other on the exact
"place" vs "direct" mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-27 08:23:21 +01:00
tudor a3c09d9b67 Merge pull request 'fix(suggest): the dropdown reopened on top of the search results' (#131) from fix/suggest-reopens-over-results into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m37s
Reviewed-on: #131
2026-08-26 21:07:55 +00:00
tudor a7f4c86464 Merge pull request 'fix(search): the mobile hero search was indented by a card's padding' (#130) from fix/mobile-hero-search into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #130
2026-08-26 20:51:20 +00:00
TudorandClaude Opus 5 0804566736 fix(test): drop a committed scratch probe, and close a hole in the guard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Code review, all three findings valid.

e2e/tests/__m.spec.ts was a throwaway probe used to measure the mobile
hero geometry. It asserts nothing, so it could never fail; it carried a
leftover `pick('form').constructor === Object ? null : null` that is
null either way and throws if no form matches; and it should never have
been committed. Deleted.

It survived because `rm -f e2e/tests/__m.spec.ts` ran with the shell
already inside e2e/, so the path resolved to e2e/e2e/tests/... — which
does not exist, and rm -f is silent about that. `git add -A` then swept
it in. I checked `git diff --stat` before committing, which lists only
tracked modifications and never shows an untracked file; `git status
--short` would have.

The scoping guard compared the last line of a rule's prelude against the
literal '.filterBar', so a regression written as a selector list —
`.filterBar, .other { padding }`, or the same split across two lines —
would have walked straight past the test meant to catch it. Selectors
are now split on commas and matched individually, and comments are
stripped first so a brace inside one cannot desynchronise the parse.

Verified against all three shapes: bare, inline comma list, and
multi-line comma list. Each is caught; each passes again once reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:49:48 +01:00
TudorandClaude Opus 5 55363cbd18 fix(suggest): the dropdown reopened on top of the search results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 55s
Three staging-gate failures, two of them one real bug.

After a search, the results-page bar still holds the term in its input,
so on every render the query was >= 2 characters and the suggestion list
opened again — on top of the very results the search had just produced.
Playwright reported it as "<li role=option ...> intercepts pointer
events" while trying to click the first result; a reader would simply
have found their first result unclickable. Both the school-detail and
hero-map journeys failed on it, and neither is about autosuggest.

Suggestions now answer typing, not the mere presence of a value:
`hasTyped` gates the hook, is set on change, and is cleared when a
search is submitted or a suggestion is chosen. A pre-filled input makes
no request and shows no list.

Third failure was my test, not the product. An unphased place page
renders one table per phase, and an all-through school legitimately
appears in both — so the page's school links were never one alphabetical
run. The assertion collected them all together and only passed because
no town it picked had held an all-through school. When the data gave
Abbots Langley one, Breakspeare School appeared in the primary table and
again in the secondary, and the test failed on correct behaviour. It now
checks each table separately, and passes against the data that broke it.

Guards: a jest test that a pre-filled input neither fetches nor opens
(verified by reverting — it is the only one that fails), and an E2E
journey that submits a search and then requires the first result to be
clickable, which is the reader-facing version of the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:38:16 +01:00
tudor 868eb344f5 Merge pull request 'fix(map): the hero map's fade to the header was hardcoded white' (#129) from fix/dark-map-fade into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #129
2026-08-26 20:32:45 +00:00
TudorandClaude Opus 5 0b15497c09 fix(search): the mobile hero search was indented by a card's padding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m3s
Measured at 390px: the headline and lede sit at x=34, while the search
box, the hint and the location link all sat at x=48 and the field was
28px narrower than the copy above it.

The 14px came from `@media (max-width: 768px) { .filterBar { padding:
0.875rem } }`. That rule is for the results filter bar, which is a card
— background, border, shadow — and needs inner padding. The hero search
is not a card: .heroMode strips all of it, padding included.

Both selectors are specificity (0,1,0), so source order decides, and
.heroMode only wins because it is declared right after .filterBar. A
bare .filterBar rule inside a media query comes later and silently wins
instead. The two rules directly below this one in the same block were
already written as `.filterBar:not(.heroMode)`; this one was missed.

Scoping it aligns the search box, hint and location link to the same
left edge as the headline and gives the field back its 28px.

The location link also carried its own 6px of button padding, so its
label started further right than the hint even once the boxes agreed.
Pulled back with a negative margin, which keeps the tap target.

The guard is a stylesheet test: the failure is a plausible-looking
layout rather than a broken one, so nothing short of measuring or
looking would catch it. Verified by reverting: it names ".filterBar sets
padding".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:32:41 +01:00
tudor d55f6cce23 Merge pull request 'fix(suggest): let the dropdown out of the hero panel' (#128) from fix/hero-dropdown-clipping into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m50s
Reviewed-on: #128
2026-08-26 20:23:33 +00:00
TudorandClaude Opus 5 3236efa846 fix(map): the hero map's fade to the header was hardcoded white
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 49s
The fade between the map band and the school header ramped through
rgba(255,255,255,...) and landed on var(--bg-card). In the light theme
that is white into white and invisible, as designed. In the dark theme
it climbed to 95% WHITE and then met a near-black card, putting a bright
band across the full width exactly where the map should dissolve into
the title.

Fading to the colour the gradient lands on is the whole trick, and it
only works if that colour is a token — so --bg-card-rgb now exists in
both theme blocks, matching the --hero-ground-rgb precedent.

Two more defects in the same file, same cause, found while in there:

The controls floating over the map paired a hardcoded white background
with color: var(--text-primary), which resolves to #E9EEF0 in dark —
near-white text on a near-white button. These deliberately do NOT follow
the theme, because the map tiles are light in both, so the ink is now
literal too and says why. A themed token is the wrong tool for a surface
that never changes.

The loading skeleton swept 50% white across var(--bg-secondary), which
is a bright flash every 1.4s on a dark page. It now sweeps toward the
card colour, a shade lighter than the ground in both themes.

The guard is a stylesheet test rather than a render test, because the
bug is invisible in the theme it was written for. Verified by reverting
each fix in turn: it names .fade and .openHint exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:20:57 +01:00
TudorandClaude Opus 5 d5a6db289d fix(suggest): let the dropdown out of the hero panel
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
.heroPanel had overflow: hidden to clip its artwork and scrim to the
rounded corners. It clipped the suggestion dropdown too. Measured on
staging with the flag on: the list runs 482 to 802, the panel ends at
624 — so 178px of 320 was cut off, about half the options, with nothing
on screen to say anything was missing.

The two things that actually needed clipping now round themselves:
.heroArt gets border-radius: inherit plus its own overflow, and the
::before scrim inherits the radius. Below 860px the artwork is a band
flush with the top of the panel rather than a layer covering it, so it
takes the top two corners only — inheriting all four would leave it
floating with rounded corners against the copy.

Nothing else depended on the panel clipping: .valueProps below it is
entirely static, so a positioned dropdown paints above it without a
z-index fight.

The regression test asserts the LAST option is the element actually
painted at its own coordinates. toBeVisible() would not have caught
this — it checks for a non-empty box and visibility, and an ancestor's
overflow clips neither. elementFromPoint catches clipping and occlusion
alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 21:10:32 +01:00
tudor d8ccb5b733 Merge pull request 'feat(suggest): school autosuggest, and the rate-limit fix it needed first' (#127) from feat/school-autosuggest into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 5m49s
Reviewed-on: #127
2026-08-26 19:58:57 +00:00
TudorandClaude Opus 5 0fa1a292c7 fix(api): bound what a forged CF-Connecting-IP can buy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Code review, both findings valid.

The design doc claimed Cloudflare "replaces the header, so a browser
cannot forge it", and that only the X-Forwarded-For fallback was
forgeable. That is true only for traffic that actually passed through
Cloudflare, and nothing in this process can verify that it did. Reaching
the origin directly, both headers are equally attacker-controlled — and
rotating CF-Connecting-IP mints a fresh rate-limit bucket per request,
defeating per-client limits on every endpoint including the
DataFrame-heavy /api/schools. Against abuse that is worse than the
shared bucket it replaced, which at least capped everyone together.

So the ceiling comes back. I dropped it earlier arguing it belonged at
Cloudflare; that argument assumed the keying was sound, and it is not.
GlobalRateLimitMiddleware counts all /api/ traffic in a fixed window
against a total, independent of client identity, outermost so it refuses
before any work happens. Written by hand because slowapi cannot express
a global cap: default_limits and application_limits are both keyed by
key_func, and the latter needs middleware this app does not install.

It does not make the header trustworthy — it makes trusting it
survivable. The real fix is Authenticated Origin Pulls or an origin
firewall, now documented in DEPLOY.md as the open gap it is.

127.0.0.1 is exempt: the healthcheck curls localhost from inside the
container, and starving it would restart the container and turn a load
spike into an outage loop. Keyed on the peer address, never the Host
header, which the caller sets.

Second finding: suggest_schools_typesense promised "never raises" while
the parsing loop sat outside the try, so int(None) on a malformed
document would have made a keystroke a 500. The loop now skips bad rows
rather than dropping the whole list — and a hit with no document no
longer becomes a suggestion pointing at /school/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:56:32 +01:00
tudor c3f044bd65 Merge pull request 'docs(flags): Unleash does not create flags by itself' (#126) from fix/flags-runbook into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m46s
Reviewed-on: #126
2026-08-26 19:48:58 +00:00
TudorandClaude Opus 5 d2115364ae test(e2e): autosuggest journeys, gated on the flag
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m13s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m40s
Feature state is read from its observable effect — whether the search
box is a combobox — because /api/flags is denied to the public on
purpose. Same approach as the distance journeys.

The three endpoint tests are ungated: /api/suggest is live whether or
not the UI is, which is what lets it be smoke-tested in an environment
where the feature is still dark.

The flag-off journey asserts the plain search still works, not just that
the combobox is absent. Verified against staging, where the flag is off:
it passes and the flag-on journey correctly skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:39:33 +01:00
TudorandClaude Opus 5 28cf0a342c feat(suggest): wire autosuggest into the search box behind a flag
Off means off — no combobox role, no listener, no fetch. A test asserts
the absence of the request, not just the absence of the dropdown,
because a hidden-but-fetching control would still be spending the rate
limit on a feature nobody can see.

Enter with no active option falls through to the form's submit handler
and searches the typed text exactly as before. The existing behaviour is
preserved, not replaced, and that has its own test.

Suppressed once the value parses as a postcode: the box takes a name OR
a postcode, and suggesting schools during postcode entry fights the user.

.omniBoxContainer gains position: relative — the dropdown is absolutely
positioned and without it would have anchored to the page instead.

Four render sites, all wired: page.tsx renders HomeView in the success
path AND the catch fallback, and HomeView renders FilterBar as hero AND
sticky. Missing any one would make the flag silently do nothing
somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:38:52 +01:00
TudorandClaude Opus 5 d88e77f459 feat(suggest): the dropdown, with combobox ARIA
Presentational only — it fetches nothing and owns no state, so the
fetching rules and the ARIA rules can be read separately.

onMouseDown, not onClick. The input's blur handler closes the list and
blur fires before click, so a click handler never runs: the classic bug
where a dropdown works perfectly by keyboard and is dead to the mouse.

The plan's CSS guessed at token names like --color-surface. The real
tokens are --bg-card, --border, --text-muted, --bg-secondary and
--shadow-soft, and all five are redefined in the dark theme — invented
names would have silently fallen back to hardcoded light values and
broken dark mode.

Local authority is rendered because there are many schools called
'St Mary's'; a list without it is unusable for exactly the query
autosuggest exists to serve, which is what the test asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:36:39 +01:00
TudorandClaude Opus 5 06eb433db5 feat(suggest): debounced, abortable suggestion hook
The AbortController is correctness, not economy. Without it a slow
response for 'st' can land after the fast one for 'st marys' and replace
a correct list with a stale one — the classic autosuggest race.

No cache: 'no-store', unlike the compare modal's search. This is the one
endpoint where prefix queries repeat most across users, so discarding
the browser cache and the backend's ETag 304s would be throwing away the
cheapest win available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:35:27 +01:00
TudorandClaude Opus 5 1a6d349dad feat(suggest): GET /api/suggest, cacheable and DataFrame-free
A dedicated endpoint rather than a mode of /api/schools, because that
path filters and sorts 25,000 pandas rows per query while holding the
GIL — affordable once per search, not once per keystroke. A test asserts
the distinction directly by making load_school_data raise and requiring
the endpoint to answer anyway.

Nothing errors on ordinary input: a short query, no matches, or
Typesense being down are all 200 with an empty list.

Cached deliberately. Prefix queries repeat enormously across users and
school names change once a year, so s-maxage plus the existing ETag
middleware turns most keystrokes into 304s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:34:34 +01:00
TudorandClaude Opus 5 75d3534d82 feat(suggest): Typesense rows for autosuggest, no DataFrame
search_schools_typesense returns URNs, which forces the caller to
hydrate from the 25,000-row in-memory frame. Every field a suggestion
needs is already in the Typesense document, so this returns documents
and the caller needs no pandas at all — the difference between a query
that can run per keystroke and one that cannot.

Never raises. Typesense unreachable or erroring gives an empty list,
because a dropdown that quietly stops appearing is the right failure for
a keystroke path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:33:42 +01:00
TudorandClaude Opus 5 ff041544f2 fix(api): rate-limit per caller, not per proxy
The limiter keyed on request.client.host, which in staging and prod is
the Next container — the backend has no published ports and nothing else
can reach it. So every browser user on the site shared one 60/minute
bucket per route. Measured against staging: 70 concurrent requests to
/api/schools returned exactly 60 OK and 10 refused, from one machine.

CF-Connecting-IP first. Cloudflare fronts both environments and
overwrites any client-supplied value, which a parsed X-Forwarded-For
chain does not guarantee. The XFF fallback is forgeable only from inside
the Docker network.

Named rather than hidden: the shared bucket was an accidental global
throttle on a single-process backend, and correct per-user keying
removes it. A real global ceiling belongs at Cloudflare, which is
already in the path; slowapi cannot express one without a second Limiter
and middleware this app does not install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:32:58 +01:00
TudorandClaude Opus 5 59265f78b6 docs(flags): Unleash does not create flags by itself
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
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 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 35s
The runbook said a flag 'appears in the Unleash UI after the backend has
evaluated it once'. That is wrong. SDKs read definitions from the server
and never register anything, and metrics for an unknown flag are
discarded — so a declared flag is evaluated on every request, stays
False forever, and never shows up until someone creates it by hand.

Found the way these things usually are: staging had been running the
flag code for a while and the UI was still empty.

Also names the environment trap while here — each stack's token is
scoped to one environment, so toggling the other does nothing visible
and looks like the flag is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:11:45 +01:00
TudorandClaude Opus 5 6e0a278340 docs(suggest): implementation plan, eight tasks
Self-review caught three defects in the plan. .omniBoxContainer, the
wrapper the dropdown positions against, does not declare position:
relative — without it the list anchors to the page. The postcode
suppression test typed character by character, so it would have asserted
no request while 'NW1' legitimately fires one; it now sets the value in
one go. And the Enter-submits-search assertion needed waitFor, because
updateURL pushes inside startTransition.

Task 1 is the one to review hardest: it is the only unflagged change and
it alters rate limiting for every endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:48:39 +01:00
TudorandClaude Opus 5 e651dd0d65 docs(suggest): the in-app global ceiling would not have worked
Reading slowapi rather than assuming: default_limits and
application_limits are both evaluated with the same key_func, so they
are per-client across routes, not global. And application_limits only
apply 'if in_middleware' — this app installs no SlowAPIMiddleware, so
they would never have fired at all.

A genuine global cap would need a second Limiter with a constant key
plus that middleware. Cloudflare is already in the path on both
environments and does this at the right layer, so the ceiling is named
as a follow-up there rather than built badly here.

The risk that leaves is stated plainly in the risks section instead of
being papered over with a mechanism that does not do the job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:44:21 +01:00
TudorandClaude Opus 5 22c113fc29 docs(suggest): design for school autosuggest
The load-bearing finding is not about autosuggest. The rate limiter keys
on request.client.host, which in staging and prod is the Next container
— so all browser users share one 60/min bucket per route. Measured
against staging: 70 concurrent requests gave exactly 60 x 200 and
10 x 429. Eight concurrent searchers would 429 the site once each
keystroke costs a request, so the keying fix is part of this work.

Both environments are behind Cloudflare, which sets CF-Connecting-IP and
overwrites any client-supplied value — trustworthy in a way a parsed
X-Forwarded-For chain is not, and the backend is unreachable except
through the Next proxy.

Named honestly: the shared bucket has been an accidental global throttle
on a single-process backend, so correct per-user keying removes a
protection. A global ceiling ships with it rather than instead of it.

Suggestions come from Typesense alone. The existing search path filters
a 25,000-row DataFrame per query, which is exactly the cost a keystroke
endpoint cannot pay, so there is deliberately no DataFrame fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 19:41:00 +01:00
tudor e953ee7c5f Merge pull request 'feat(flags): ship-dark feature flags, with last-distance-offered behind the first one' (#125) from feat/feature-flags into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 40s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m34s
Reviewed-on: #125
2026-08-23 11:34:56 +00:00
TudorandClaude Opus 5 413d86cc3c chore(flags): wire UNLEASH_URL and the SDK cache volume into the stacks
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 27s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m59s
Both variables default to empty, so an environment without Unleash has
every flag off — the correct dark state rather than a boot failure.

The cache volume is the mitigation for the one real regression risk in
this design: the SDK evaluates everything False until it syncs, so a
backend cold-starting with an empty cache while Unleash is unreachable
would make a *released* feature disappear. On a named volume the disk
cache survives a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:58:29 +01:00
TudorandClaude Opus 5 4f01fbdedb test(e2e): make the distance journeys fail loudly, not skip quietly
The existing distance journeys all skip when no school has a published
figure, which is right when the feature is off — and wrong when it is
supposed to be on and is silently broken, because that shows up as a
green run full of skips. The new gate fails in exactly that case.

Feature state is read from the data, not from /api/flags: the public
proxy denies that path on purpose, since it names unreleased features.
Presence of the admission_distance key is the observable effect.

Verified against staging, where the feature is currently on: the on-gate
passes, the off-gate skips, the existing eight distance journeys are
unaffected.

One honest caveat — the /api/flags check passes on staging today because
that image predates the endpoint, not because the denylist works. The
denylist itself is covered by the jest unit test; this is defence in
depth and becomes a real assertion once deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:57:34 +01:00
TudorandClaude Opus 5 c3ba7aae0d feat(flags): ship last-distance-offered dark behind a flag
One gate, at the source. The frontend needs no change: DistanceSection
already returns null when distance_m is missing, and the admissions
block already conditions on (admissions || admissionDistance). Only 57
local authorities publish cut-offs, so the off-path is the commonest
path on the site and is well covered already.

Absent, not null. /api/schools/ is public and unauthenticated, so a
field left in the payload is a published field — the reasoning already
recorded in c9a1892 when history was withheld. The two are also
different claims: null says this school has no cut-off, absent says
cut-offs are not being published at all. The frontend type now says so.

The feature is on main and live on staging and has never reached
production, which is what makes it the right first consumer: the flag
lets the code promote without the feature appearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:56:33 +01:00
TudorandClaude Opus 5 54a30de0d8 feat(flags): server-side getFlags for the frontend
Ships without a consumer, deliberately. The first flag needs none — the
backend withholds the field and the page follows — but 'UI elements on
existing pages' is one of the three surfaces this capability exists for,
and a flag layer that cannot gate one is incomplete.

Never throws: an unreadable flag is a dark one, which matches the
backend's fail-closed default. A page that 500s because the flags
endpoint blinked would be a worse outcome than a hidden feature.

Reading flags pins the calling route to a 300s ISR floor, since Next
takes the lowest revalidate among a route's fetches. That matches what
/school/[slug] already sits at, and it is the same property that makes a
flip propagate without a webhook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:55:32 +01:00
TudorandClaude Opus 5 c30ad1db07 feat(flags): serve /api/flags, and keep the public proxy off it
The endpoint and its exposure control ship together on purpose. The
moment /api/flags exists, app/api/[...path] forwards it — and the
response names every unreleased feature the codebase knows about, along
with whether it is on. Publishing that is the opposite of shipping dark.

Denied on an exact first-segment match, not a prefix, so /api/flagship
does not go down with /api/flags. Next reads the endpoint server-side
over the Docker network, which never transits the public proxy.

jest.setup.js now guards its browser globals. It runs for every suite,
including the one that declares @jest-environment node to exercise the
route handler — NextRequest needs Fetch API globals jsdom lacks, and
there is no window there to define matchMedia on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:54:26 +01:00
TudorandClaude Opus 5 7424cef7c6 feat(flags): the registry and a fail-closed Unleash client
Unleash holds flag state; it does not hold the list of flags. REGISTRY
is that list, because the SDK evaluates an unknown flag to False and
without a registry that is an undeclared False — indistinguishable from
a typo in a flag name.

Fail-closed throughout, and never raises: an unset UNLEASH_URL, an
unreachable server, a client that throws, an undeclared name — all
False. A flag layer that can 500 a request path or stop the API booting
is worse than one that is switched off.

Every flag defaults to False, with no per-flag override, because a flag
that defaults on is a kill switch and this is deliberately not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:52:55 +01:00
TudorandClaude Opus 5 01ccbb8e82 feat(flags): add the Unleash stack and its runbook
Its own Portainer stack, belonging to neither application stack: a
staging redeploy must not be able to disturb production's flag state.

One instance serves both. OSS Unleash ships development and production
environments with environment-scoped client tokens, so the same flag
holds independent state in each — which is what lets a feature be on in
staging, where the E2E journeys exercise it, while production stays dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:51:35 +01:00
TudorandClaude Opus 5 c339c2f1a1 docs(flags): implementation plan, eight tasks
Task 1 is the Unleash stack and ends with a human step — the Portainer
deploy and the token generation cannot be automated from here. Nothing
else blocks on it: an unset UNLEASH_URL means every flag is False, which
is what local development and CI get, so the whole suite runs without a
flag server existing.

Self-review caught three defects in the plan itself. get_supplementary_data
takes (db, urn), not (urn), and the test DataFrame was minimised to the
point where the endpoint would have failed for reasons unrelated to
flags — both now copy the known-good shape from test_school_details.py.
The proxy test needs the node jest environment, since NextRequest wants
Fetch API globals jsdom does not provide. And the e2e off-state check
hardcoded a URN, so a 404 page would have satisfied it without proving
anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:46:15 +01:00
TudorandClaude Opus 5 e2ca3d79f9 docs(flags): drop the webhook — the seven-day premise was wrong
Next uses the LOWEST revalidate among a route's fetches, not the segment
value. School pages fetch school details at 300s and place pages fetch
national averages at 3600s, so the effective ISR period is five minutes
and one hour respectively — not the seven days the segment declares.

A flag flip therefore propagates on its own, well inside the monthly,
by-hand cadence these flags are for. That deletes two webhook
integrations, a revalidate route, a secret-in-query-string scheme, an
idempotency requirement, and the rule that every fetch carry a cache
tag — which was the part most likely to rot as fetches are added.

Two constraints survive: a flag must never gate content on a
force-static page, because app/admissions never revalidates; and a
route-family flag must rebuild the sitemap, deferred with the route case
since no flag in scope touches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:40:35 +01:00
TudorandClaude Opus 5 c2364bf09e docs(flags): design for a ship-dark feature flag layer
Unleash self-hosted in its own Portainer stack, with FastAPI holding the
only SDK and Next reading flags through a tagged fetch.

The two hard parts are consequences of putting flag state in a service
rather than the repo: main stops being the whole truth about what is on,
and a flag can now change without the deploy that would have cleared the
caches. A code-declared registry bounds the first; webhook-driven
revalidateTag handles the second.

Cache tagging is deliberately coarse — every server fetch carries the
flags tag, not just the flags fetch itself. The first consumer proves
why: admission_distance changes the shape of /api/schools/{urn}, so a
narrow purge would leave ~25,000 school pages serving the pre-flip
render for a week, invisibly.

First consumer is the last-distance-offered feature, which is on main
and staging and has never reached production. It needs one gate, at the
API, because the frontend already no-ops on a missing field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 09:47:58 +01:00
tudor 43e0621728 Merge pull request 'fix(places): phase links must stay in their own namespace' (#124) from fix/place-phase-links into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m31s
Reviewed-on: #124
2026-08-22 17:33:12 +00:00
TudorandClaude Opus 5 d1358cc00f fix(places): phase links must stay in their own namespace
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the
shape that belongs to towns alone.

On an authority page that pointed into the town namespace. For 87 of the
151 authorities the target does not exist and the link 404s; for the
other 64 it resolves to the town of the same name — a different set of
schools, which is precisely the near-duplicate the two namespaces were
introduced to prevent. On an outcode page it 404s outright.

Two causes behind it, both a rule written twice and inherited by only
one of the places that needed it.

The authority phase route was in the spec and dropped by the plan, which
built the three bare routes and no fourth. The sitemap is generated from
the place registry, which was right about them all along, so 302
authority phase URLs have been submitted to Google and every one 404s.
Adding the route makes the sitemap true and serves a real query —
admissions are authority-run, so "primary schools in Kent" is how a
parent searches before they have settled on a town.

The outcode variants were the opposite: the registry computed phases for
outcodes although the spec gives them no route, and the sitemap knew to
skip them while the API did not. The registry now decides alone, and the
sitemap's duplicate of that rule is gone.

Also: an authority under the five-school threshold has no page, so the
API sends a null slug for it and the page names it without linking.
Two English authorities are in that position. It was unreachable in
today's data — verified across the EC and TR outcodes — but the thin
place redirect would have sent a reader to a 404 the year it isn't.

The e2e journey now walks every /schools link a page of each family
emits and requires a 200, which is the check that was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 17:22:11 +01:00
tudor 865a69b54d Merge pull request 'feat(places): list schools alphabetically on place pages' (#123) from feat/place-alphabetical-sort into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m27s
Reviewed-on: #123
2026-08-21 23:22:49 +00:00
TudorandClaude Opus 5 9cc87c41bb fix(places): a phase page needs results, not merely publishable schools
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m4s
Asked where schools with no results should sit in an alphabetical list, and
found that some pages were almost entirely made of them.

The per-phase threshold counted schools that were publishable — a result OR
an Ofsted grade — while a phase page exists for its results column.
/schools/kent/primary published with none of its five rows carrying a result;
Minehead had one of seven, Buntingford one of five. Forty-four phase pages
were majority-blank.

It is the same rule as "no page without a local average", which was written
into the spec as a thin-page control and never extended per phase.

The threshold now counts schools with a result for that phase. It gates
whether the page exists; it does not filter rows — a page that publishes still
lists every school of the phase, because someone looking up a school by name
has to find it whether or not it published results.

126 of 1,012 variant pages stop publishing: 62 primary, 64 secondary. Every
one of them was a table with too little in it to be worth a page.

The ordering itself is unchanged: pure A-Z, blanks interleaved. A school sits
where its name says it does, and at roughly a tenth of rows that reads fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 00:14:31 +01:00
TudorandClaude Opus 5 8967966eef feat(places): list schools alphabetically on place pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 1m21s
Someone on a place page is usually looking for a school they can name, so the
order should serve scanning for it rather than ranking. /api/rankings keeps
its league-table ordering; this is a place-page decision, not a site-wide one.
Sorted case-insensitively, or a capitalised name would sort ahead of every
lowercase one.

The change made five pieces of copy untrue, so they go with it. The phase
variant titled itself "— Ranked", and all four route families described
themselves as "ranked by SATs and GCSE results". A page that opens by claiming
an order it does not keep is worse than one that claims nothing.

The ItemList markup carried `position` with no declared order, which reads as
a ranking. It now declares ItemListOrderAscending, so the structured data says
what the table does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-22 00:09:58 +01:00
tudor 4a9a5c734b Merge pull request 'fix(e2e): three assertions that were wrong about correct behaviour' (#122) from fix/e2e-canonical-and-robots into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m26s
Reviewed-on: #122
2026-08-21 23:07:57 +00:00
TudorandClaude Opus 5 4e82e6c916 fix(e2e): three assertions that were wrong about correct behaviour
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 41s
The staging gate was red on three journeys. All three were faults in the
tests; the site was behaving correctly in each case.

Next normalises canonical URLs against trailingSlash:false, so the homepage
ships "https://www.schoolcompare.co.uk" with no slash while every other route
keeps its path. Both address the same document. The test hardcoded the slash
and so failed only on the root — /rankings and /admissions passed throughout,
which is what made it look like a homepage bug rather than a test bug.
Compared with trailing slashes stripped from both sides.

The robots.txt assertion matched "Disallow: /" anywhere in the file and
tripped over the AI-crawler groups Cloudflare injects — ClaudeBot, GPTBot,
Amazonbot and six others all carry a blanket disallow, deliberately, and none
of them is Googlebot. It now parses the file into user-agent groups and checks
only the "*" group, which is also the thing the test was always trying to say:
Google may crawl the page, so it can see the noindex header.

Both were the same mistake as the doubled brand: asserting a naive string
rather than the semantics, and asserting against what the code assembles
rather than what the page renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 23:51:34 +01:00
tudor d4340a8fdd Merge pull request 'feat(places): name every authority a place sits in' (#121) from feat/place-multiple-authorities into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #121
2026-08-21 21:56:50 +00:00
TudorandClaude Opus 5 bb2f7a5841 fix(places): address review, and merge places GIAS spells more than one way
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m34s
Two findings from review on #121, plus a third the review prompted.

The cap at three authorities silently dropped the fourth in exactly the case
where the information matters most — a genuinely fragmented place — and
contradicted the stated goal of naming every authority a place sits in. It is
gone. The share rule was always the real limit and already bounds the list at
ten. Measured against the live corpus, one town would have been truncated
today: LONDON, split evenly between Hackney, Lambeth, Westminster and
Lewisham.

parent_authority used mode() while authorities used value_counts(), and on an
exact tie pandas does not guarantee the two pick the same name, so the 301
could have pointed somewhere other than the authority named first on the page.
The parent is now derived from authorities[0]: one computation, one answer.
It also inherits the sentinel filter, so a place can no longer redirect to
/schools/authority/does-not-apply.

Chasing the truncation case surfaced a worse bug. Places were grouped by raw
town value, but the registry is keyed by slug, and GIAS spells the same place
several ways. Five town slugs come from more than one spelling: "London"
(1,819 schools) and "LONDON" (12) both slugify to `london`, so the later group
simply overwrote the earlier one — /schools/london could have shown twelve
schools, silently, depending on row order. Weston-super-Mare was split 14/19
across two spellings and Newcastle-under-Lyme across three. Grouping is now by
slug, and the display name is the most common spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:42:18 +01:00
TudorandClaude Opus 5 1cb5314c53 feat(places): name every authority a place sits in
SW19 is mostly Merton but partly Wandsworth, and the page said only Merton.
The cause was one field doing two jobs: _parent_authority takes the modal
authority, which is right for a 301 target and wrong as a statement about
where a place is.

This is not a corner case. A quarter of viable outcodes (425 of 1,760) and a
third of viable towns (263 of 783) cross an authority boundary — Bedford the
town spans Bedford and Central Bedfordshire.

Place now carries `authorities`, every authority holding at least a tenth of
the schools and at least two of them, largest first. parent_authority stays
single and unchanged, because a redirect still needs one target.

The share threshold exists because GIAS carries postcode errors: EN6 lists two
Shropshire schools among fourteen in Hertfordshire, and a bare "any authority
present" rule would print those as though they were real. A place too small or
too fragmented to clear the threshold still names its largest, so the page
never goes silent about where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:40:58 +01:00
tudor 4cea26b813 Merge pull request 'fix(places): align the measure column's heading with its values' (#120) from fix/place-table-alignment into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #120
2026-08-21 21:21:03 +00:00
TudorandClaude Opus 5 dbb74d9b60 fix(places): align the measure column's heading with its values
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
The heading sat on the right edge of the column and every value on the left.
A specificity collision, not a layout problem: the two were aligned by
different selectors and only one of them won.

  .table td            (0,1,1)  text-align: left    <- won for the value
  .num                 (0,1,0)  text-align: right   <- lost
  .table th:last-child (0,2,1)  text-align: right   <- won for the heading

The heading and the value cell now share one class and one rule, so they
cannot drift apart again whatever else changes around them.

The column also stretched to half the table. It now hugs its content with
width:1% and nowrap, so the school name takes the remaining width — which is
what made the gap read as misalignment on a wide screen, and what crowded the
name column on a narrow one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 22:15:02 +01:00
tudor 9545aec7f4 Merge pull request 'fix(places): phase-grouped tables, plain-English measures, styled links' (#119) from fix/place-presentation-to-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 56s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #119
2026-08-21 20:57:13 +00:00
Tudor 3365ebcb3a fix(places): phase-grouped tables, plain-English measures, styled links
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Three presentation faults on the place pages, all found by looking at a
rendered page rather than at a test.

An unphased place page showed one primary-only measure for a list holding both
phases: 8 of 27 rows on /schools/brentwood were blank, because secondaries
have no reading-writing-maths score. Picking the other measure would only have
inverted which rows were empty, and putting both in one column would have
mixed a percentage with a 0-90 score. Each phase now gets its own table, so a
blank cell means the school genuinely has no published result — which is worth
saying, and now says "Not published" rather than a bare dash.

"RWM expected" was invented here. The site already names the measure in
METRIC_DEFINITIONS, surfaced at /api/metrics: "Reading, Writing & Maths
Combined %". The heading now reads "Reading, writing & maths" with the full
definition in the tooltip.

Links carried no class at all, so they rendered as default blue underlined
browser links beside a site that styles table links as body colour with a
brand hover. They now follow RankingsView's convention, and running-copy links
take the brand colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:56:06 +01:00
tudor 6d79bd3331 Merge pull request 'fix(places): stop the place titles doubling the brand' (#117) from fix/place-title-brand-doubling into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m30s
Reviewed-on: #117
2026-08-21 20:53:14 +00:00
TudorandClaude Opus 5 24e114dee7 fix(places): stop the place titles doubling the brand
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 27s
Every place page shipped as 'Schools in Brentwood - Compare 27 Schools |
schoolcompare | schoolcompare'. The root layout's title template appends
'| schoolcompare' to any plain-string title, and all four place routes already
carried the brand. W8 opted the other routes out with an absolute title; the
place routes were written afterwards and did not inherit the lesson.

~2,600 titles affected, and the repetition pushed them past Google's
truncation point, so the doubled brand displaced real words in the result.

An e2e journey now asserts no title repeats the brand, across the static
routes and a place page, so this cannot come back on a route added later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:43:32 +01:00
tudor 6c5db0c266 Merge pull request 'fix(places): submit and link the phase variants' (#116) from fix/place-phase-variants into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #116
2026-08-21 19:46:55 +00:00
TudorandClaude Opus 5 6f749ed21f fix(places): submit and link the phase variants
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m39s
/schools/[place]/[phase] shipped as routes but reached nothing. The sitemap
emitted one URL per registry entry and the registry had no phase dimension, so
~950 pages were absent from every sitemap — and PlaceView did not link them
either, leaving them reachable by nothing at all.

That is the query shape the baseline actually showed: 'primary schools in
beccles', 'secondary schools in brentwood'. Publishing the routes without a
path in meant building for the demand and then hiding from it.

Place now carries phase_urns so the per-phase threshold can be applied without
re-querying, the sitemap emits a variant wherever a phase clears the threshold
on its own, and the API exposes the qualifying phases so the place page links
only variants that exist. Outcodes are excluded: nobody searches 'primary
schools in SW11' and those routes do not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 20:43:07 +01:00
tudor d423826840 Merge pull request 'fix(places): a locality collision must not break the sitemap' (#115) from fix/locality-collision-skip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m38s
Reviewed-on: #115
2026-08-21 19:26:02 +00:00
TudorandClaude Opus 5 d3c63ccc6d fix(places): a locality collision must not break the sitemap
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 34s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 34s
Sitemap regeneration failed on staging. 'richmond' in the curated locality
list collides with the GIAS town Richmond in North Yorkshire (37 schools), the
registry raised, and the admin endpoint 500d — taking down sitemap generation
for all 25,000 school pages over one bad row of curated data.

The guard now skips the colliding locality and logs an error. Skipping still
achieves what the guard was for — a locality never silently shadows a town —
without letting curated data break the site. That matters beyond this bug:
GIAS town names change with no code change here, so a raise could fire
spontaneously in production later.

Also removes four localities that were London boroughs rather than districts.
Hackney, Islington, Greenwich and Ealing are local authorities with 104, 72,
108 and 115 schools and already have authority pages; a locality defined by
two or three outcodes would have been a partial near-duplicate of one — the
thin-content failure the two-namespace design exists to avoid. A test now
guards the whole borough list.

Validated against the live corpus: 15 localities, no town collisions, no
authority duplicates, all 15 clear the threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 20:20:52 +01:00
tudor b93eb3a691 Merge pull request 'feat(seo): the location layer — town, locality, authority and outcode pages (W2)' (#114) from feat/w2-location-layer into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m18s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 11m8s
Reviewed-on: #114
2026-08-21 17:56:05 +00:00
TudorandClaude Opus 5 6b871ce1e9 feat(places): ItemList and BreadcrumbList, and the e2e gate
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m33s
ItemList tells Google the page is a ranked set rather than prose;
BreadcrumbList puts the place in a hierarchy. School URLs in the markup are
absolute on the canonical host, since a relative URL in JSON-LD is ambiguous.

Eight journeys covering all four families, the two-namespace guarantee, the
threshold, the canonical, the sitemap and the local-versus-England line — the
last because that comparison is the reason these pages are not a name dropped
into a template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:19:08 +01:00
TudorandClaude Opus 5 c981d89137 feat(places): town, locality, authority and outcode routes
Every generateStaticParams is gated behind PRERENDER_PLACES and wrapped in the
same try/catch the school route uses. The plan claimed authority pages were
'few enough to always prebuild' — but few enough still means the API must be
reachable at build time, and in CI it is not: the build failed with
ECONNREFUSED rather than degrading to ISR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:17:57 +01:00
TudorandClaude Opus 5 de5e790112 feat(places): place page client and view component
One component for all four families: they differ in what fills the registry,
not in what the page shows, so a second would be a second place to forget the
same change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:15:10 +01:00
TudorandClaude Opus 5 42138fc402 feat(places): submit place and outcode sitemaps
Separate children per family so Search Console reports the location layer's
indexation apart from the school pages' — which is the point of the index
built in W1, and the number the stop condition watches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:13:55 +01:00
TudorandClaude Opus 5 c5af476213 feat(places): /api/places registry and place detail endpoints
The registry is cached for the process and reset by the same admin endpoint
that rebuilds the sitemaps, so places and sitemap always describe the same
corpus rather than drifting apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:13:05 +01:00
TudorandClaude Opus 5 de853b90b3 feat(places): London localities and postcode districts
The GIAS town field puts 1,819 London schools under the single value
'London', so it cannot answer 'schools in Battersea' — a query that appears in
the baseline. No single field can: parliamentary constituency gives Battersea
but not Canary Wharf, admin_ward gives Canary Wharf but not Battersea, and
neither gives Clapham or Shoreditch. So a locality is curated, defined by the
postcode districts it covers, which needs no new ingestion.

A locality may not shadow a published town: the registry raises rather than
silently costing a page that carries real demand. One below the threshold is
logged rather than raising, because a locality can legitimately be too small.

The pipeline seed mirrors the module, with a test guarding the drift — the
same arrangement gias_codes has, and for the same reason: the backend image
does not contain pipeline/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:11:56 +01:00
TudorandClaude Opus 5 759d9f5cea feat(places): registry of towns and authorities
Two namespaces because 67 town names collide with an authority name and
neither set contains the other — postal towns cross authority boundaries, so
Bedford the town holds 104 schools against the authority's 86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:10:37 +01:00
TudorandClaude Opus 5 555d3f0a7d docs(seo): implementation plan for the W2 location layer
Seven tasks: the place registry, London localities and outcodes, the places
API, per-family sitemaps, the shared place view, the four route families, and
structured data plus the e2e gate.

Two things the plan corrects against the spec. The backend image does not
contain pipeline/, so the curated locality list cannot live only in a dbt
seed — it follows the gias_codes.py precedent instead, canonical in backend
with the seed as a mirror. And NationalAverages is nested by phase rather than
flat, which the first draft read wrongly and would have rendered every page
without its England comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:09:35 +01:00
TudorandClaude Opus 5 ecc847091c docs(seo): design for the W2 location layer
Supersedes the original spec's W2. The Search Console baseline inverted its
ordering: every measured location query is town or district level, none is an
administrative area, and phase is part of the query rather than a filter.

Two problems the original design did not anticipate. 67 viable towns share a
name with a local authority, and the authority is the larger set in only 43 of
them — postal towns cross authority boundaries, so neither can absorb the
other. Two namespaces resolve it by construction. And the GIAS town field
collapses 1,819 London schools into one value, which a curated
locality-to-outcode seed solves without new ingestion.

Sizing is measured against the live 25,185-school corpus rather than
estimated: 783 viable towns, 1,760 outcodes, 154 authorities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:09:35 +01:00
tudor b187a478c9 Merge pull request 'feat(seo): rewrite the C1 snippets to earn the click (W8)' (#113) from feat/seo-metadata-c1 into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m24s
Reviewed-on: #113
2026-08-20 23:26:12 +00:00
TudorandClaude Opus 5 c0547c45e5 feat(seo): rewrite the C1 snippets to earn the click (W8)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 11s
The baseline says these pages already rank and are not clicked. 'compare
school performance' sits at position 6.1 with 0.43% CTR; 'compare schools' at
7.2 with 0.87%. The brand query 'school compare' draws 9.16% from the same
neighbourhood of the same results page, which rules out a ranking explanation
— when the snippet gives a reason to click, it gets clicked.

These SERPs are owned by the DfE's own 'Compare school performance' service.
The old title put a lowercase brand nobody searches for in the most valuable
pixels, then a near-paraphrase of that service's name. Beside the government's
own result it read as a lookalike.

Intent in the title, differentiator in the description. Titles now match what
people type, and the descriptions carry the one fact gov.uk does not publish:
how close you had to live to get a place.

/compare deliberately takes the tool phrasing rather than the homepage's, so
the two pages stop competing for one phrase. The root layout's default and
Open Graph copy were saying something different again; they now agree.

No hard school counts in any of it. The corpus moves with every data refresh
and this repo has already shipped one copy bug of that kind.

Tests guard the mechanics — SERP length, intent keyword, the differentiator,
no brand-first title — and leave the wording free to iterate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 00:21:08 +01:00
tudor 4a3928df9f Merge pull request 'fix(seo): a school is publishable on any year's results, not the latest' (#112) from fix/sitemap-any-year-data into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 18s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m24s
Reviewed-on: #112
2026-08-20 23:15:19 +00:00
TudorandClaude Opus 5 07c97a46c5 fix(seo): a school is publishable on any year's results, not the latest
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
_school_sitemap_rows tested only the latest year's row, which quietly dropped
every school with results in its history but a null row for the most recent
year — a school that stopped reporting, or whose figures were suppressed for
small-cohort disclosure.

The Mallard Academy (150367) is the case that caught it: real KS2 results for
2015-16 through 2018-19, then null rows from 2022-23 on. Its detail page shows
all four years; the sitemap omitted it. Sampling 40 of the 2,206 excluded
schools found 4 like this, so roughly 220 real pages were being withheld.

Publishable is now a property of the school, computed across every row, while
lastmod still comes from the latest row so the most recent Ofsted date wins.
The field list is a module constant shared with _has_publishable_data so the
two checks cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 00:04:32 +01:00
tudor bb81337aba Merge pull request 'fix(seo): keep staging out of the search index' (#111) from fix/staging-noindex into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 2m1s
Reviewed-on: #111
2026-08-20 22:39:38 +00:00
TudorandClaude Opus 5 b34511e459 chore: record the branch cleanup manifest
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m30s
79 remote branches deleted: 77 fully merged into main, plus
feat/seo-crawl-hygiene and feat/england-only-corpus, whose content is
preserved on feat/seo-crawl-hygiene-main (PR #110).

Each line carries the SHA, so any branch can be restored with
  git push origin <sha>:refs/heads/<name>

The 14 branches left standing all carry content that differs from main and
none of them is mine to judge abandoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:23:30 +01:00
tudor f928a15c1e Merge pull request 'fix(seo): crawl hygiene and a per-family sitemap index (W1)' (#110) from feat/seo-crawl-hygiene-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 18s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m21s
Reviewed-on: #110
2026-08-20 22:23:10 +00:00
TudorandClaude Opus 5 1fc1e07d21 fix(seo): keep staging out of the search index
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 13s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Staging serves the same image as production off stx., with robots.txt saying
Allow: / and no noindex — a fully crawlable duplicate of the site. Nothing
appears indexed today, most likely because the pages canonicalise across to
production, but that is a side effect rather than a control.

X-Robots-Tag, not a robots.txt Disallow. Disallow blocks crawling, which is
not the same as blocking indexing: a disallowed URL can still be indexed from
external links, and blocking the crawl means Google never fetches the page and
so never sees a noindex at all. Staging stays crawlable and answers noindex.

Matched on the staging host explicitly rather than 'any host that is not
production'. The inverted form would cover future environments automatically,
but its failure mode is deindexing production if the Host header ever arrives
rewritten by a proxy — which cannot be verified from here. This form's failure
mode is a new environment being indexable until someone adds it, which is
recoverable. Any new non-production hostname must be added.

The journeys only ever run against staging (deploy.yml passes
STAGING_BASE_URL; promote.yml smoke-polls production without Playwright), so
asserting the header there is safe. The two assertions live in one test
because the halves only work together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:21:58 +01:00
TudorandClaude Opus 5 2208ad93c1 feat(seo): split the sitemap into a per-family index
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m2s
Search Console reports coverage per submitted sitemap, so one file per page
family is what will make W2's location pages measurable when they land. The
index's lastmod is generation time, which is the correct semantic there —
unlike on a <url>, where it would be a claim we cannot support.

Children sit under /sitemaps/ because Next only treats a whole bracketed path
segment as dynamic; a route folder named sitemap-[...parts] would be read as a
literal static segment and never match. Confirmed by the build output, which
lists /sitemaps/[...parts] as a dynamic route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 24f3cb4c65 fix(seo): submit only school pages that have something to show
Drops the schools with neither results nor an Ofsted grade, adds /admissions
which was never listed, replaces the invented priority and changefreq with a
lastmod taken from each school's Ofsted date.

lastmod is omitted where no date is known rather than defaulted to now. An
always-now lastmod is a claim Google learns to distrust; absent honestly
means unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 3816b92d06 fix(seo): noindex parameterised comparisons, keep bare /compare
25,193 schools make ~317 million pairs. The bare page stays indexable as the
landing page for the head term; the parameter space goes noindex, follow so
its outbound links still count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 51ce2d6373 fix(seo): declare a canonical on every route
The homepage read eleven search params and declared no canonical, so every
filter combination was a crawlable near-duplicate of the page we most want to
rank. Rankings and admissions declared none either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
TudorandClaude Opus 5 5ddc7314fd fix(seo): canonicalise on the www host, which is the one that serves 200
The apex 301s to www at Cloudflare, but metadataBase, the school-page
canonical, robots.txt's Sitemap: line and the sitemap's own <loc> entries all
named the apex. Every one of those pointed Google at a redirect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 23:15:00 +01:00
tudor 8ad2070e76 Merge pull request 'fix(data): drop the Welsh school GIAS does not type as Welsh' (#109) from fix/welsh-establishment-leak into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m18s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m25s
Reviewed-on: #109
2026-08-20 21:59:38 +00:00
TudorandClaude Opus 5 3aad5101a8 fix(data): drop the Welsh school GIAS does not type as Welsh
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 38s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
Beechwood College (URN 142458, Sully, CF64 5SE) survived the England-only
filter. GIAS types it a Special post 16 institution (32), not a Welsh
establishment (30), so filtering on establishment type alone left it behind —
the last Welsh school on the site, and the reason Vale of Glamorgan was still
in the authority list.

The earlier verification claimed the type codes mapped onto the Welsh
authorities in both directions. That was checked exhaustively for Cardiff and
by count for three others; Vale of Glamorgan was never checked, and it was the
one that did not hold.

LA code is the reliable discriminator: GIAS gives the 22 Welsh unitary
authorities the contiguous block 660-681, which English authorities never use.
Filtering on postcode would have been wrong — Redbrook, Tutshill, Wyedean and
two other Gloucestershire schools carry NP16/NP25 postcodes because Royal Mail
areas straddle the border, and they are English schools with English data. An
e2e test now pins those five so the fix cannot be simplified into a postcode
filter later.

The 660-681 range is documented GIAS structure this project cannot verify from
its own data, so the range filters and the authority NAME checks: if the range
is ever wrong, a Welsh authority reappears in assert_england_only_schools and
the pipeline fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:54:24 +01:00
tudor c47fe38971 Merge pull request 'feat(data): publish England only, dropping Welsh and overseas establishments' (#107) from feat/england-only-corpus into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m27s
Reviewed-on: #107
2026-08-20 21:20:13 +00:00
tudor 8f211577c8 Merge pull request 'fix(e2e): scope the Distance-tab assertion, and separate the tile figures' (#106) from fix/e2e-distance-locator into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m26s
Reviewed-on: #106
2026-08-20 21:18:28 +00:00
TudorandClaude Opus 5 69f2201244 docs(seo): correct W1 plan's sitemap child routes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Next only treats a whole bracketed path segment as dynamic, so the planned
app/sitemap-[...parts]/route.ts would have been read as a literal static
folder and never matched. Children move under /sitemaps/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:09:00 +01:00
TudorandClaude Opus 5 e6048c9ca6 docs(seo): implementation plan for W1, crawl hygiene and sitemap
Five tasks: one canonical host, canonicals on every route, noindex on
parameterised comparisons, a sitemap that drops dataless schools and invented
priorities, and a per-family sitemap index.

Planning turned up a fault the spec had missed: the apex 301s to www, but
metadataBase, the school-page canonical, robots.txt's Sitemap: line and every
sitemap <loc> named the apex. Task 1 fixes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 22:05:00 +01:00
TudorandClaude Opus 5 7650b16f62 feat(data): publish England only, dropping Welsh and overseas establishments
GIAS ships the whole UK plus overseas and offshore establishments. None of
them carry comparable DfE performance data — Wales does not publish on the
English measures at all — so every one of these pages rendered with null
results, null Ofsted and null phase. There were 2,036 of them: 1,569 Welsh,
123 offshore (Jersey, Guernsey, Isle of Man, Gibraltar), 316 British schools
overseas and 28 service children's schools. All 2,036 were being submitted to
search engines, alongside 29 local authorities that existed in the filters
purely to list them.

Filter at the mart boundary rather than the view layer. dim_school and
dim_location both exclude TypeOfEstablishment in {25, 26, 30, 37}, listed once
as vars.non_england_school_type_codes. Everything downstream reads those two
marts — search, the school page, /api/filters, rankings, Typesense and
build_sitemap() — so one filter removes them from the site and the sitemap
together, and Typesense drops them on its next rebuild since it recreates the
collection and swaps the alias rather than upserting in place.

coalesce rather than a bare NOT IN: a null type code would make the predicate
null and drop the row silently, and an unknown type is not grounds for
exclusion. No establishment has a null type today, but a future GIAS refresh
could ship one and the loss would be invisible.

assert_england_only_schools guards both directions: no excluded type survives
in dim_school, and dim_location holds no URN dim_school lacks — the API
inner-joins them, so the two filters drifting apart would silently shrink the
corpus.

Corpus goes from 27,229 schools to 25,193, and the authority list from 182 to
153. The 1,569 Welsh URLs now 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-20 21:46:28 +01:00
TudorandClaude Opus 5 9abd020967 fix(e2e): scope the Distance-tab assertion, and separate the tile figures
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 59s
The failing journey was wrong; the page was correct.

  Locator: getByRole('button', { name: 'Distance' })
  Expected: 0   Received: 1

getByRole matches accessible names by case-insensitive SUBSTRING, so
{ name: 'Distance' } matched <button>Show this distance on a map</button> —
the map toggle added by the same feature. The assertion was meant to say "no
Distance tab in the admissions segmented control" and instead said "no button
anywhere whose label contains the word distance".

Now scoped to the control it is about, via its own aria-label, and read
positively: the tab list must contain "This year" and must not contain
"Distance". An absence check against an unscoped locator passes for the wrong
reason the moment the selector stops matching, which is exactly how the
regression this test guards would return unnoticed.

Two sibling locators had the same weakness and are tightened: 'Check' is a
prefix of the button's own busy label "Checking…", and the figure matcher
accepted `(miles|m)` — a leftover from the mixed-unit era that would have kept
passing if the headline regressed to metres, which is the thing #105 just
fixed.

Tightening that matcher surfaced a real defect behind it. The cut-off figure
and its metric support are flex children with the gap drawn by CSS and nothing
between them in the text layer, so the element read "0.88 miles1.4 km" —
what a screen reader announces, and why a `miles\b` boundary could never
match. Both templates now carry an explicit space. Whitespace text nodes are
not rendered as flex items, so the reading changes and the layout does not.

Verified against staging: 54/54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 21:01:28 +01:00
tudor 228eb214f5 Merge pull request 'fix(admissions): state distance in miles throughout, never mixed' (#105) from fix/standardise-distance-units into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m28s
Reviewed-on: #105
2026-08-20 19:39:18 +00:00
TudorandClaude Opus 5 ea5249a2ea fix(admissions): state distance in miles throughout, never mixed
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 58s
The postcode check read "69 m away — inside the September 2026 cut-off of
0.17 miles". Both numbers are right and the sentence is still useless: the
reader has to convert one of them to check a comparison we had already made
for them.

The cause was a readability rule of mine in formatCutoffDistance, which swapped
to metres below 100m on the grounds that "0.04 miles" carries less than "69 m".
Taken one figure at a time that holds. Taken in a sentence containing two
figures it guarantees a mismatch whenever they fall either side of the
threshold — and a 270m cut-off with a nearby home does exactly that.

Miles now lead everywhere. It is the unit UK school admissions runs on:
councils publish cut-offs in miles (90% of the collected source rows), and it
is what a parent has already been quoted in their booklet and offer letter.
The metric figure survives only as support beside the miles figure on the
Admissions tile, where it converts the same value rather than presenting a
second one to compare.

Below 0.01 miles the decimal places run out rather than the unit being wrong,
so a very short distance is described — "under 0.01 miles" — instead of
rounding to a flat "0.00 miles", which would read as no distance at all.

Both figures in the verdict now go through one formatter with no fallback that
could reach for another unit. The old `?? "N m"` fallbacks on that line were a
second route to the same defect and are gone.

Covered by a sweep over sixty home/cut-off combinations spanning the old
switch point, asserting no verdict contains a metric reading and that exactly
two miles figures appear; plus the reported case pinned verbatim, and an e2e
guard on the rendered verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 20:35:05 +01:00
tudor ffe7e04951 Merge pull request 'feat(admissions): publish the latest cut-off only, holding history back' (#104) from feat/latest-cutoff-only into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #104
2026-08-20 17:51:18 +00:00
TudorandClaude Opus 5 c9a1892bfb feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.

The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.

Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.

What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.

Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.

One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.

The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 18:44:57 +01:00
tudor 50b599a09b Merge pull request 'fix(admissions): move the cut-off detail into its own section' (#103) from fix/admissions-section-height into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #103
2026-08-20 14:03:01 +00:00
TudorandClaude Opus 5 94151c58ea fix(admissions): move the cut-off detail into its own section
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height
of every section put together, and nearly three times the next largest — with
its default view rendering as four tiles adrift in about 1080px of blank card.

The cause was a layout trick meeting content it was never sized for. The
admissions views are stacked in one grid cell so switching them never shifts
layout, and the hidden ones keep their box: only visibility is dropped. That
works while the views are comparable. The distance view added in #102 carries
a chart, a table and a map, came to 1402px against the tile grid's 316px, and
pinned every other view to its height — including the one that renders by
default, which nobody had clicked.

Rather than only unpinning it, the detail moves out. Every other topic on the
page is a section with a nav entry, and "how close did we need to live, and
would we have got in?" is a topic, not a variant reading of the intake
figures. The headline number stays on the Admissions tile where the intake
story is; the record behind it now lives in a Distance section directly below.

  admissions   1503px -> 554px
  distance        new -> 743px   (median section on the page is ~528px)

Three further changes, each of which also makes the content better rather
than only shorter:

  * The map renders on request. Before a postcode is entered it is a circle
    drawn round a school, and it costs a Leaflet bundle and 240px to say so;
    a successful check opens it automatically, which is the point at which it
    starts answering something. Map height 320px -> 240px.
  * The chart appears only at the four published years that let the summary
    state a direction. Below that we already refuse to call the series a
    trend, and a line through three points asserts one regardless of what the
    sentence beneath it admits. The table carries those years anyway, with
    the reasons a line cannot show.
  * Three caveat paragraphs become one. They said walking-route twice and
    made the same point about priorities in two voices. It now sits in
    CutoffDistanceDetail rather than inside the check, so it still renders
    for a school with coordinates missing, where there is a table but no map
    and no check.

The new e2e guard asserts no section exceeds 2.5x the median section height.
Measuring the card's internals cannot catch this: the tile grid is flex: 1,
so it absorbs the stretch and every box still looks full. The first version
of this test targeted an arbitrary primary, passed against the live bug, and
proved nothing; pointed at a school that actually holds cut-off history it
fails on staging with "#admissions is 1459px against a 526px median".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 14:54:02 +01:00
tudor 8bf6145a73 Merge pull request 'feat(admissions): add the cut-off history, map and postcode check' (#102) from feat/last-distance-offered-full into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m14s
Reviewed-on: #102
2026-08-17 07:51:14 +00:00
TudorandClaude Opus 5 a72323874f feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Completes the last-distance-offered feature against the mockup: the
year-by-year record, the same numbers drawn over real streets, and the
reader's own address measured against them.

Serving the history
  The first cut deliberately served only the latest year, because a plain
  series would draw a trend line straight through gaps that are absences of
  publication, not of a cut-off. That reasoning is answered rather than
  abandoned: cutoffYearRows classifies every year in the span, and the chart
  breaks the line rather than interpolating across it.

  A missing year is not one fact but three. It may be unpublished; it may be
  a year the school was not oversubscribed; or there may be no record at all.
  Collapsing them into "no data" throws away the reassuring case and hides
  the important caveat, so each is stated in words in the table.

  The claim is held to what the data supports. fact_admissions.oversubscribed
  compares FIRST PREFERENCES against places, which does not establish that
  every applicant was offered one — so the copy says "places available on
  first preferences" and a test asserts the stronger claim never appears.

The trend summary is not a verdict
  It names both endpoints and their years and lets the reader conclude. It is
  withheld below four published points, and a swing under a tenth of the
  earlier figure is reported as "broadly the same" rather than dressed up as
  a direction.

The postcode check
  This is the only place on the site that answers a question about a family
  rather than a school, so most of the care went into what it refuses to say.
  postcodes.io returns a centroid covering roughly fifteen addresses, which
  against a 500 m cut-off is a fifth of the whole distance — so a margin
  inside 100 m returns "too close to call" rather than a place a family does
  not have. Unpublished years count as unknown, never as a pass. The limits
  are stated before the check is used, not revealed with the answer.

  The postcode is geocoded in the browser and never stored.

Both templates
  Banded and selective secondaries are exactly where this matters most, so
  the detail is shared. The primary page gives it a third tab; the secondary
  page is one flat panel by design and renders it inline.

Absence is explained rather than reported. A selective school's missing
figure is explained by how it admits; a consistently undersubscribed school
reads as good news.

Also makes the batch loader's test double honour ORDER BY. It was a no-op,
so "latest row per URN" was really "first row in the fixture" and the test
would have passed with the sort reversed or removed.

Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging
(the 8 cut-off journeys skip until the DAG runs). Rendered offline against
the real compiled CSS in both themes and at 390px; every new surface clears
WCAG AA, measured on composited pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-16 13:42:06 +01:00
tudor 5a71f54d94 Merge pull request 'feat(admissions): show the last distance offered where councils publish it' (#101) from feat/last-distance-offered into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 43s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 3m15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m53s
Reviewed-on: #101
2026-08-16 12:21:24 +00:00
TudorandClaude Opus 5 88c653215d feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.

There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.

Data
  * tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
    (urn, year, school_name), because school_name carries the admission
    route: (urn, year) alone collides on 118 keys and a reload would have
    silently dropped every band but one.
  * stg_school_distance applies a 25 m – 25 km plausibility band. The source
    contains 0.0-mile rows (published where a school filled on a higher
    criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
    all of which would put a visibly wrong number on a live page.
  * fact_admission_distance collapses routes to one row per school per year
    using the furthest, and keeps route_count so the page can say the figure
    is the widest of several bands rather than the one for a given child.

Serving
  * Kept out of fact_admissions: that mart is EES-derived and near-complete
    for England, this one covers 57 LAs, and the two refresh independently.
  * Latest year only. Coverage is ragged — a school may have 2021 and 2026
    and nothing between — so a history array would invite a trend line drawn
    through gaps that are absences of publication, not of a cut-off.
  * The Admissions section now renders on either source. 3% of the schools
    that render have a cut-off and no EES admissions row, and gating on
    admissions alone would have hidden the figure on those pages.

Interface
  * The year travels with the figure everywhere it appears; a cut-off
    detached from its admissions round is not a fact about anything.
  * "Not a fixed catchment — it moves every year" sits under every instance,
    because that is the inference a parent will otherwise draw.
  * Replaces a hardcoded "Historical distance cut-off data is not available
    for this school" that appeared on every secondary page, including the
    ones whose council does publish it. The absence is now stated only when
    it is real, and names the authority that would hold it.

The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.

The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-15 22:48:30 +01:00
tudor 5156a85bd1 Merge pull request 'chore: drop the hero byline, and refresh a figure in the UX audit notes' (#100) from chore/byline-removal-and-audit-figure into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m5s
Reviewed-on: #100
2026-08-15 09:25:59 +00:00
TudorandClaude Opus 5 fa1abff642 chore: drop the hero byline, and refresh a figure in the UX audit notes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
Two unrelated working-tree changes, committed as one at the owner's request.

Removes "Built for parents, by a parent." and its comment from the hero. It
was added two commits ago; taking it out is the owner's call, and the reasons
it was placed under the search rather than in the footer no longer apply.

The .heroByline rules in HomeView.module.css are deliberately left in place.
They are now unreferenced, but the class was purpose-built for this one line
and keeping it makes restoring the byline a one-line change. If the removal is
permanent, that block (and its 640px media query) should go with it.

Also updates two quoted figures in the 2026-07-02 UX audit notes from
"24,000+" to "27,000+".

Worth noting for the record: that file documents what the page said when it was
audited, and at that time it genuinely did say "24,000+" — which was itself the
bug later fixed by reading unique_schools instead of a field the API never
sent. Editing the quoted evidence makes the note read consistently with the
current site, at the cost of no longer being a verbatim record of what was
observed. Left as the owner edited it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:21:29 +01:00
tudor 38acc76555 Merge pull request 'fix(charts): make the national-average marker visible on both templates' (#99) from fix/chart-marker-contrast into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 16s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m3s
Reviewed-on: #99
2026-08-15 08:56:40 +00:00
tudor c7e0c2eac7 Merge pull request 'feat(home): swap in the higher-fidelity hero artwork' (#98) from feat/hero-artwork-v2 into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 59s
Reviewed-on: #98
2026-08-15 08:56:33 +00:00
TudorandClaude Opus 5 59ac9c10b9 fix(charts): make the national-average marker visible on both templates
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m5s
The marker was var(--brand) — the identical value to the bar fill it sits on.
Measured by pixel-sampling every bar on both templates, in both themes:

  primary   SatsChart .natTick        1.00:1  (6 bars)
  secondary .att8VizNatLine           1.00:1

Not low contrast. The same colour. It scored 5.47:1 only against the empty
track, which means it was visible precisely when a school was BELOW the
national average and vanished for every school at or above it — failing for
exactly the schools people are looking for.

No single colour fixes this, because the marker's position is data-driven: it
can land on the bar, on the empty track, or across the boundary. It is now a
knockout — a light core carrying a dark edge, both from tokens that flip with
the theme, so one part or the other always separates:

              on the bar        on the track
  light       5.47 / 9.70:1     15.17:1
  dark        7.81 / 10.85:1    13.52:1

THE LEGEND DESCRIBED A CHART THAT DID NOT EXIST

Both data swatches were var(--status-above) green while their bars were
var(--brand) teal, and the two were identical to each other — one swatch for
two series. Worse, the only swatch matching the bar colour was the one
labelled "National average", so reading the chart by matching colours told you
the teal bars were the benchmark. Each swatch now carries its bar's exact
value, and the marker swatch mirrors the knockout.

TWO SERIES, ONE COLOUR

Expected and Exceeding were both var(--brand), distinguished only by row.
They are a sequential pair — exceeding is the same cohort at a harder bar — so
they take two steps of one hue, the harder measure being the step further from
the ground in each theme.

They measure 1.77:1 (light) and 1.39:1 (dark) against each other, and that is
accepted rather than overlooked: two fills that must EACH clear 3:1 against
the same white track are geometrically forced close together. The distinction
is carried by the row labels and printed values; colour is redundant here, not
load-bearing.

THE TEST THAT SHOULD HAVE CAUGHT THIS

The WCAG journey composites backgrounds by walking the ancestor chain, but
these markers are absolutely positioned over a sibling — ancestor-walking is
structurally blind to overlap, which is why this shipped in two templates and
passed every gate. The new journey asks the stacking order instead, via
document.elementsFromPoint.

Writing it surfaced a second trap worth recording: elementsFromPoint takes
viewport coordinates and returns an empty stack off-screen, and these markers
sit ~1200px down. The first version defaulted an empty stack to white, which
made teal-on-teal look like teal-on-white and PASS in the light theme. It now
scrolls each marker into view and counts any marker it cannot resolve a
backdrop for as a failure rather than a pass.

Verified both directions: the new journey fails against current staging with
"1.00:1 over rgb(15,118,110)" in both themes, and passes against this build
with 6/6 markers measured at 5.47–15.17:1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:48:41 +01:00
TudorandClaude Opus 5 eddf74745f feat(home): swap in the higher-fidelity hero artwork
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Same 16:9 frame, but not a drop-in replacement — the composition, palette and
copy area all moved, and each one drives a change here.

COPY AREA

The old artwork was a flat #FDF9F3 down its whole left side. This one runs
peach #FEE8D2 at the top to cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream at all. --hero-ground is resampled
to #FEF2E1, the middle of the pale run where the headline and search actually
sit; the scrim covers the foliage further down.

Measured on rendered pixels with the text hidden, sampling the real glyph runs
(via Range, not the element boxes) plus a 120px growth margin:

           title      body      byline
  light    13.61:1    5.74:1    6.66:1
  dark      9.49:1    5.45:1    8.35:1

Body drops from 6.52:1 to 5.74:1 as the foliage comes closer, still clear.

BAND CROP

The schoolhouse moved to ~82% across the frame, leaving only ~140px of artwork
to its right, so the band crop is anchored to the right edge and takes 1344px
back — putting the school at 73%, which the build script now derives and
prints rather than leaving it to drift from the CSS.

The crop is also 3.2:1 rather than matching the phone. The band is not one
ratio: it runs 2.4:1 on a phone to about 3.8:1 under the one-column
breakpoint. Cropping at the narrow end means the wide end throws away height,
which cut the flag off the roof and the base off the building. Sitting above
the middle costs a little width on phones — where the crop's left is hillside —
and keeps the building whole where it matters.

BAND HEIGHTS

Raised to 13rem (861–860px) and 10rem (≤640px). The band was widest-per-height
at exactly 640px, where an 8.5rem band measured 4.2:1 — worse than any wider
viewport, because the height steps down at that breakpoint while the width does
not. Ratios across the range are now 2.0 / 2.6 / 3.6 / 3.1 / 3.8 rather than
spiking. The search still lands at 442px on a 667px viewport.

WEIGHT

Source is 3.1MB; a browser fetches one file — 29–59kB on desktop, 10–14kB on a
phone. Widths re-cut for the larger master: 2000/1400/1000 wide, 1344/900/600
band.

Verified: 0 AA failures across both themes at 1440 / 860 / 390, every srcset
entry present on disk with no orphans, tsc clean, 159/159, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:22:37 +01:00
tudor d5f3fdf0f9 Merge pull request 'feat(home): add a first-person byline under the hero search' (#97) from feat/hero-byline into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m1s
Reviewed-on: #97
2026-08-15 08:00:47 +00:00
TudorandClaude Opus 5 3015c37bac feat(home): add a first-person byline under the hero search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
"Built for parents, by a parent." — the one line on the page written by a
person rather than by a product.

Placed under the search rather than in the footer, which is where it would
have been true and unread. It does its work at the moment someone is deciding
whether to trust a page full of government statistics, which is the moment
they are looking at the search box.

Set apart without shouting: display face, a step down in size, and a short
brand rule in place of a bullet. No italic — Manrope ships none in the loaded
weights, so font-style would be synthesised into a slant, the same reason
.heroEmph resets it.

Deliberately the only claim of its kind on the page. Every other trust signal
here is about the data's provenance and is checkable against the DfE and
Ofsted; this one is about who built it, and is not. So it is stated once,
plainly, and never repeated — the opposite of the "Official & trusted" line it
now sits near, which claimed something about the site that was not true.

Measured 7.26:1 in the light theme and 8.86:1 in dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:59:43 +01:00
tudor 153b26a32f Merge pull request 'fix(home): stop implying we are official, and fix the mobile hero and search' (#96) from fix/hero-mobile-and-wording into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 56s
Reviewed-on: #96
2026-08-14 22:28:33 +00:00
tudor e373241d51 Merge pull request 'fix(e2e): assert the brand lockup and touch icon as they are actually built' (#95) from fix/e2e-brand-assertions into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m2s
Reviewed-on: #95
2026-08-14 22:28:10 +00:00
TudorandClaude Opus 5 4043270a77 fix(home): stop implying we are official, and fix the mobile hero and search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Three things, all reported from the live mobile view.

WE ARE NOT AN OFFICIAL SERVICE

The value prop read "Official & trusted", whose grammatical subject is this
site — it reads as a claim that schoolcompare is an official service. It is
not; it is an independent site that republishes official figures. Now "Built
on official data", which describes the data instead.

Checked every other use of the word: all nine describe the data ("official DfE
figures", "the official figure isn't in") and are correct as they stand. That
one title was the only place the site described itself.

The footer now says it outright — "An independent site. Not affiliated with
the Department for Education or Ofsted." — so it appears on every page rather
than being left to inference. An e2e test asserts both halves: that the
statement is present, and that no text presents the site itself as official.

The disclaimer first measured 4.71:1 against a 4.5 floor. That is a fine
margin for decoration and the wrong one for a line whose job is to be legible
to someone checking whether this is a government site; it is now 5.59:1,
quieter than the copy around it by size rather than by contrast.

THE ARTWORK SAT UNDER THE SEARCH ON PHONES

The DOM keeps .heroContent first so the desktop overlay does not depend on
source order, which left the band stranded at the bottom of the panel, reading
as a strip stuck on the end rather than a hero image. `order` moves it above
the copy on phones; it is decorative and aria-hidden, so no reading order
changes. Measured on a 667px viewport — the shortest phone still in use — the
search button lands at 418px, comfortably inside the fold.

THE SEARCH INPUT WAS UNUSABLE ON PHONES

"Search schools" is a fixed 134px with white-space: nowrap, so it took 48% of
the row. Measured on staging:

  390px   input text space 102px   placeholder needs 194px
  360px                     72px
  320px                     32px

At 320px you could not see what you were typing. The button now wraps to its
own full-width row below 480px, which fixes 360px and up — 390px goes from
102px to 226px of text space. The pill stays a single element, so it keeps its
border, shadow and :focus-within ring, and the button gains a full-width tap
target.

320px is still ~30px short. Closing it needs a shorter placeholder, which three
other tests match on by exact string; left alone deliberately rather than
churn them for a width that is effectively gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:59:12 +01:00
TudorandClaude Opus 5 e65688d600 fix(e2e): assert the brand lockup and touch icon as they are actually built
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The staging gate has been failing these two since the supplied logo artwork
replaced the reconstruction. Both tests were still asserting the previous
implementation, and both were right to fail — they were just describing
something the site no longer does.

  the header carries the schoolcompare lockup
    Looked for an <svg> inside the header link. The mark is raster now:
    <picture><source><img src="/brand/mark.png">. Nothing matched, so the
    locator timed out.

  the brand asset set is complete and served
    Requested /apple-icon, which 404s. The route moved when the generated
    app/apple-icon.tsx became a static app/apple-icon.png — generated icons
    serve at /apple-icon, static ones at /apple-icon.png with a content hash.
    The icon was present and correctly linked the whole time.

Both now read from the page instead of hardcoding the shape of the answer: the
touch icon is fetched from its own <link rel="apple-touch-icon"> href, the way
this test already handles og:image, so it follows whatever Next emits.

The lockup assertion also got stronger rather than merely corrected. A
<picture> whose sources all 404 still lays out and still satisfies
toBeVisible(), so that alone would go green on a broken lockup; it now asserts
naturalWidth, which only a decoded image can satisfy.

Verified against staging directly: 41/41 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:34:22 +01:00
tudor 7f4dfa2748 Merge pull request 'fix(home): art-direct the hero's fallback path, and declare sharp' (#94) from fix/hero-fallback-and-sharp into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m12s
Reviewed-on: #94
2026-08-14 21:28:13 +00:00
TudorandClaude Opus 5 bdaa05cd54 style(home): lift the hero artwork's dark-theme brightness to 0.75
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
At 0.52 the scene was legible but heavily suppressed; 0.75 lets the hills,
path and schoolhouse read while the white H1 stays comfortably the brightest
thing on the panel.

Re-measured rather than assumed, because in the dark theme the text is light
and the artwork is behind it — brightening the image lowers text contrast
rather than raising it. Off rendered pixels, sampling background up to 120px
past each line's right edge:

  brightness   title      body
  0.52         11.01:1    6.44:1
  0.75          9.48:1    5.21:1

Both still clear the 4.5:1 floor, body being the binding one. The trade is
recorded next to the value so the next person to reach for it knows it has a
floor and not just a taste range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:26:23 +01:00
TudorandClaude Opus 5 043506cb6b fix(home): art-direct the hero's fallback path, and declare sharp
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m32s
Two findings from review on #93, both verified before fixing.

<img src> cannot vary by viewport, so it was always the wide desktop crop. A
browser taking neither AVIF nor WebP therefore fell through to the desktop
frame on a phone and lost the schoolhouse — the exact failure the two-crop
<picture> exists to prevent, surviving in the one path nobody looks at. The
band JPEG the build script already emitted was never referenced, which was the
tell. It now backs a <source media> placed after the modern formats, so they
still win wherever they are supported.

Verified by stripping the AVIF and WebP <source>s at runtime and letting
<picture> re-resolve, which is what an old browser actually sees:

  phone    hero-band-500.avif  →  hero-band-700.jpg   (band crop, school kept)
  desktop  hero-wide-1672.avif →  hero-wide-1200.jpg

sharp was not declared: it arrives transitively from next@16.1.6, so the
documented regeneration command works today and breaks on a Next upgrade or a
clean install that resolves differently. Declared in devDependencies for the
same reason next.config.js already declares its traced font files rather than
trusting the tracer to keep finding them.

The third finding — that the hero's licence is marked unconfirmed in
CREDITS.md while the artwork ships — is accurate and deliberate. It is the
owner's to answer; recording it as unknown is the point of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:07:01 +01:00
tudor 0466986195 Merge pull request 'feat(home): use the supplied hero artwork instead of a drawn one' (#93) from feat/hero-artwork into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m14s
Reviewed-on: #93
2026-08-14 20:41:46 +00:00
TudorandClaude Opus 5 3f0e05cc99 feat(home): use the supplied hero artwork instead of a drawn one
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m29s
Replaces the hand-drawn SVG landscape with the illustration supplied by the
project owner. Master is assets/hero-source.png; everything under
public/brand/hero-* comes from scripts/build-hero-images.js and should never be
hand-edited.

LAYOUT

The artwork is composed as a full-bleed hero: it reserves an empty cream area
down its left side for the headline. The old hero was a two-column grid with
art in the right 0.88fr, which would have cropped that reserved area off and
shrunk the scene into a thumbnail — the one thing the picture is built not to
be. So the panel is now a single layered block: artwork behind, copy on top,
and below the one-column breakpoint the artwork leaves the background and
becomes a band under the search.

Overlaying does not survive down to phone widths — the panel gets too narrow
for the copy to stay inside the cream, so the scrim would have to cover nearly
the whole image and you would be left with a tinted rectangle. Hence the
switch at 860px rather than a single treatment stretched across every width.

CONTRAST

The copy sits on a gradient of --hero-ground, a new token sampled from the
artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen
points darker per channel, which leaves a visible seam straight down the hero.
The scrim exists because the artwork is a fixed image on a fluid panel: past
some width the headline would otherwise land on hillside green.

Measured on rendered pixels, sampling background up to 120px beyond the right
edge of each line, so a longer line still has margin:

  light  title 14.36:1   body 6.52:1
  dark   title 11.01:1   body 6.44:1

The worst light case is hillside green showing through the scrim at 6.52:1.

TWO CROPS

The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and
2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops,
and at the band's extreme that slices a strip through the scene and loses the
schoolhouse — exactly how the drawn hero failed on phones. <picture> switches
crop, not just resolution: the band file is pre-cropped around the school and
is already near 2.6:1, with the subject at ~65% across so squeezing toward
4.9:1 crops the empty sides instead.

WEIGHT

1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about
23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback.
`sizes` describes the real panel box (max-width 1400 minus padding) rather than
100vw, which over-requested a candidate at every width on the LCP element.

DARK THEME

A raster cannot be re-graded token by token the way the drawing was, but it
would reproduce the same failure — a bright illustration is the brightest
object on a near-black page. It is dimmed in CSS to read as dusk, and the
scrim fades it into the dark panel rather than into cream.

TESTS

The two illustration journeys are replaced by three, each covering a failure
that still renders a valid-looking page: the artwork actually loading
(naturalWidth, not src), the crop switching at the breakpoint, a modern format
winning negotiation, and the dark theme dimming it.

public/brand/CREDITS.md records provenance for every asset in that folder. The
hero's licence line is marked unconfirmed — that one needs the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:36:40 +01:00
tudor f1819e9c4d Merge pull request 'fix(home): correct what the landing page claims, and give it one rhythm' (#92) from fix/homepage-truth-and-rhythm-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m13s
Reviewed-on: #92
2026-08-14 18:18:53 +00:00
TudorandClaude Opus 5 97ac5c9cef fix(a11y): clear the four AA failures blocking the staging E2E gate
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 5m29s
Main's staging E2E has been red since #90. Two of the four failures were the
staging container serving a stale image at the moment the gate ran — apple-icon
and the header lockup both check out against staging now. The other two were
real, and both are the same mistake: a colour pairing verified against one
ground while the element sits on another.

  * .heroEyebrow  --brand on its own 10% tint over Sand is 4.19:1. The token
    clears AA on Sand, but the tint darkens the ground under the label, and
    that composite was never the thing measured. --brand-strong is 5.85:1.

  * .hiwVisual    The preview panel was grounded in Sand while everything
    inside it is a translucent status tint — and those tints are specced to
    clear AA over --bg-card. Over Sand the report-card chips landed at 4.22:1
    and 4.29:1. The ratio depended on a background two levels up. Moving the
    panel to the card surface puts them at 5.69:1 and 5.81:1; a border keeps
    it reading as an inset frame now that panel and card share a colour.

  * Footer .sectionTitle  --sage flips with the theme; the footer band does
    not (it is teal in both). So the pairing only held in one of them — the
    dark sage measured 4.08:1, on every page. Now --on-sunken-muted, which is
    what the --on-sunken-* family exists for, as Footer.tsx's own header
    comment already says.

  * .compareHeadLabel  --text-muted on the header row's tint is 4.45:1 in the
    dark theme. Under by a hair, same cause. Now --text-secondary.

The harness gained the footer, because it had no footer and so could not have
caught the one failure that appeared on all three pages. Rewriting fragments
now uses each CSS module's own hash — the footer rewritten with HomeView's
prefix renders unstyled, which would have made any contrast measured on it
meaningless while still reporting a number.

Verified: 0 AA failures in both themes across the assembled page, measured with
the same probe the e2e test uses, against the real compiled CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:08:38 +01:00
TudorandClaude Opus 5 dc22fd2853 fix(home): correct what the landing page claims, and give it one rhythm
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m25s
The homepage made four statements that were not true, carried elements that
asked nothing of anyone, and had a hero illustration that broke in both the
places it had to work.

Claims, all verified against the code or the API:

  * "24,000+ schools" (three places) against a real 27,230. The fact box meant
    to show the live figure rendered its own fallback on every request, because
    DataInfoResponse declared a `total_schools` field the API has never sent —
    it sends `unique_schools`. The fetch succeeded; only that field was
    undefined, so nothing threw and nothing failed. The interface, not the
    code, was the thing that was wrong.
  * "Up to three schools side by side" against MAX_SCHOOLS = 5, contradicting a
    card 400px below it that correctly said five.
  * "Class sizes" — data the codebase has never held. That copy line was the
    only hit in a full-repo grep.
  * Invented results and Ofsted grades attributed to two real, named schools
    in the compare preview.

Also one feature, three words: Compare (nav), shortlist (footer), pin (cards).
Settled on Compare everywhere. And <title> was the bare string "Home".

Cut: the trust line (repeated the coverage figure one paragraph after the hero
gave it, behind three decorative dots), the "Start exploring" row (three links
to two destinations already in the nav), the six-row coverage table, and three
of the four countdown cards — which gave the page's largest numeral to dates up
to 245 days away, two of them offer days, which cannot be missed. All four
dates remain, at proportionate weight. Value-prop titles drop from <h2> to <p>;
they were outranking the page's real headings in the document outline.

Rhythm: the gaps between the seven landing bands were 24/32/24/16/48/32/16px,
each band setting its own margin, with four different section-header
treatments between them. The page container now owns one gap, and there is one
header pattern. An e2e test asserts the gaps are identical.

Illustration: it kept a fixed light palette in both themes, which left a pale
sky slab as the brightest object on a near-black page, out-shouting the H1 and
the search box. It now reads from --ill-* tokens with a dark re-grade. And the
hero slot ranges from 1.34:1 to 4.9:1 across breakpoints, which no single
composition survives under `slice` — at 860x176 a 540x520 scene shows only its
bottom 110 units, so the schoolhouse was cropped away entirely on phones,
leaving hills and a pin pointing at nothing. There are now two compositions,
each drawn against the crop window its own breakpoint produces, with CSS
showing one. Both are static server-rendered SVG.

The deadline bar renders on the server rather than on hydrate. The effect-based
version needed a reserved height, and one guessed number cannot cover a block
whose supporting line wraps differently at every width — measured, it was short
at all four, shifting the page up to 108px on a phone.

Verified on the built output through an offline render harness (no local
server): real compiled CSS, real rendered markup, four widths, both themes.
tsc clean, 159/159 unit tests, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:29:47 +01:00
tudor 6b117dd26c Merge pull request 'feat(brand): use the supplied logo artwork instead of a reconstruction' (#90) from feat/brand-logo-artwork into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 42s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m0s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m42s
Reviewed-on: #90
2026-08-08 21:41:47 +00:00
Tudor 9ee4d45a94 fix(brand): give both logo colourways one canvas so the aspect hint is right
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
LogoMark derived width from a single hardcoded 153/189, taken from mark.png,
while mark-on-dark.png was 98×112. Both call sites size the mark with
height:100%/width:auto, so the rendered size came out right in the end — but
the width/height attributes are the pre-load aspect hint, so the on-dark
artwork reserved the wrong box and reflowed on load. Any future call site
without that CSS would have rendered it stretched outright.

Per-variant attributes would not have fixed it. variant="auto" is a single
<img> whose srcset swaps the file underneath it, so one set of attributes has
to serve both colourways; the only correct fix is for the two files to share
a ratio.

They also had different artwork ratios (0.810 against 0.833) and different
padding, which the review did not reach: the logo changed size slightly
whenever the OS theme flipped. Both are now written onto one 176×208 canvas
with the artwork at the same height, centred, so they are drop-in swappable.

Measured on the built output, header mark, before → after:
  light   attr 31×38, rendered 30.75×38   →   attr 32×38, rendered 32.15×38
  dark    attr 31×38, rendered 33.25×38   →   attr 32×38, rendered 32.15×38
A 2.25px reflow and a cross-theme size jump both become 0.15px of rounding.

The share card reads the same file and had its own hardcoded 45×56, which the
re-crop would have stretched; it is now 47×56, with a comment tying it to the
canvas. The comment on ASPECT records that re-cropping either file means
re-normalising both.

Verified: tsc clean, 159/159 tests, build green, and both files report
naturalWidth 176 × naturalHeight 208 in the browser.
2026-08-08 22:39:28 +01:00
Tudor b11ee3c8de feat(brand): use the supplied logo artwork instead of a reconstruction
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m7s
The mark shipped so far was my SVG approximation, and it was wrong: the real
mark is a teardrop pin with a white window and a path flowing out of its base,
carrying three leaves — not a circle with a separate tail.

Both colourways are extracted from the supplied sheet, which has a genuinely
transparent background, so these are the artwork rather than a trace:

  public/brand/mark.png          teal pin, white window and path, green leaves
  public/brand/mark-on-dark.png  white pin with the counter knocked through

Two colourways are needed, not one. Rendered against every real ground, the
teal pin holds up on Warm White, white cards, Sand and both dark-theme grounds
— but it vanishes on the teal footer band, where only the white window
survives. The header therefore serves the teal artwork and swaps to the
on-dark artwork for dark-theme viewers through a <picture> source, needing no
JavaScript; the footer forces on-dark, because its band is teal in both
themes.

Everything downstream now derives from those two files: the favicon
(app/icon.png, replacing icon.svg), the touch icon (app/apple-icon.png,
replacing the generated apple-icon.tsx), the three PWA rasters, and the share
card, which reads public/brand/mark.png off disk so it can never drift from
the header. The logo sizing CSS keyed off a square box, which would have
squashed a 153:189 artwork — height now drives and width follows.

The wordmark stays live text in Manrope. The sheet's wordmark is a raster with
visible edge fringing, and the written style guide specifies Manrope; live
text also stays selectable, scales cleanly and recolours with the theme.

KNOWN LIMITATION: the largest instance on the sheet is 153×189. That is ample
for the header at 38px, the favicon and the share card, but short of the 512px
PWA icon, which is upscaled and slightly soft. A vector would fix it and is a
single swap — every consumer goes through components/Logo.tsx or public/brand.

Verified: tsc clean, 159/159 tests, build green, /icon.png and /apple-icon.png
emit as static routes, and the header, footer, share card and all five icons
were rendered and inspected in both themes.
2026-08-08 22:22:58 +01:00
tudor eecb84fb35 Merge pull request 'feat(brand): adopt the schoolcompare identity across the site' (#89) from feat/schoolcompare-brand into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m6s
Reviewed-on: #89
2026-08-07 17:58:55 +00:00
155 changed files with 17892 additions and 1013 deletions

No files matched your search

+6
View File
@@ -5,3 +5,9 @@ __pycache__/
pipeline/transform/target/
pipeline/transform/logs/
pipeline/transform/.user.yml
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
.playwright-mcp/
# Playwright run artefacts written when the suite is run from the repo root
test-results/
+478 -53
View File
@@ -6,7 +6,9 @@ Uses real data from UK Government Compare School Performance downloads.
import hashlib
import re
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Optional
import numpy as np
@@ -14,7 +16,7 @@ import pandas as pd
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
@@ -32,8 +34,11 @@ from .data_loader import (
get_supplementary_data,
get_supplementary_data_batch,
search_schools_typesense,
suggest_schools_typesense,
)
from .data_loader import get_data_info as get_db_info
from . import flags
from .places import build_place_registry
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .utils import clean_for_json, convert_to_native
@@ -48,11 +53,20 @@ PHASE_GROUPS: dict[str, set[str]] = {
"all-through": {"all-through"},
}
BASE_URL = "https://schoolcompare.co.uk"
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
BASE_URL = "https://www.schoolcompare.co.uk"
MAX_SLUG_LENGTH = 60
# In-memory sitemap cache
_sitemap_xml: str | None = None
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
# regenerate endpoint after a pipeline run.
_sitemaps: dict[str, str] | None = None
# Built from the same DataFrame the sitemap uses, so places and sitemap can
# never describe different corpora. Reset by the same admin endpoint.
_place_registry: dict | None = None
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
def _slugify(text: str) -> str:
@@ -70,43 +84,206 @@ def _school_url(urn: int, school_name: str) -> str:
return f"/school/{urn}-{slug}"
def build_sitemap() -> str:
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
# Routes worth submitting that are not a school page. /admissions was missing
# from the sitemap entirely despite being a static, indexable guide.
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
# A page has something a search result could state if any of these is present
# in any year. Shared by _has_publishable_data and the per-school check in
# _school_sitemap_rows so the two can never drift.
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
def _has_publishable_data(row) -> bool:
"""True when a school page has something a search result could state.
A school with no results in any year and no Ofsted grade renders an empty
page. Submitting it spends crawl budget and drags the corpus-wide quality
signal down, so it stays out of the sitemap. The page itself still resolves
for anyone who has the URL.
"""
for field in _PUBLISHABLE_FIELDS:
value = row.get(field)
if value is not None and not pd.isna(value):
return True
return False
def _url_element(loc: str, lastmod: str | None = None) -> str:
"""One <url> entry. No priority or changefreq — Google ignores both."""
body = f"<loc>{loc}</loc>"
if lastmod:
body += f"<lastmod>{lastmod}</lastmod>"
return f" <url>{body}</url>"
def _school_sitemap_rows(df) -> list[str]:
"""A <url> element per school that has something to show.
lastmod comes from the school's Ofsted date where there is one and is
omitted otherwise. An always-now lastmod is a claim Google learns to
distrust; an absent one honestly means "unknown".
"""
if df.empty or "urn" not in df.columns or "school_name" not in df.columns:
return []
rows: list[str] = []
seen: set[int] = set()
# Publishable is a property of the SCHOOL, not of its latest row.
#
# The first cut tested the latest year's row alone, which quietly dropped
# every school that has results in its history but a null row for the most
# recent year — a school that stopped reporting, or whose figures were
# suppressed for small-cohort disclosure. The Mallard Academy (150367) is
# the case that caught it: real KS2 results for 2015-16 through 2018-19,
# then null rows for 2022-23 onward. Its page shows all four years; the
# sitemap omitted it. Roughly 220 schools were affected.
publishable_cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
publishable: set[int] = (
set(df.loc[df[publishable_cols].notna().any(axis=1), "urn"].astype(int))
if publishable_cols else set()
)
# Latest row per URN first, so a school's most recent Ofsted date wins.
ordered = df.sort_values("year", ascending=False) if "year" in df.columns else df
for _, row in ordered.iterrows():
urn = int(row["urn"])
if urn in seen:
continue
seen.add(urn)
if urn not in publishable:
continue
lastmod = None
ofsted_date = row.get("ofsted_date")
if ofsted_date is not None and not pd.isna(ofsted_date):
lastmod = pd.Timestamp(ofsted_date).date().isoformat()
rows.append(_url_element(
BASE_URL + _school_url(urn, str(row["school_name"])), lastmod))
return rows
# Sitemaps cap at 50,000 URLs per file. 10,000 keeps a child small enough to
# scan by eye in Search Console, which is the point of splitting at all:
# coverage is reported per submitted sitemap, so one file per page family is
# what makes an indexation problem attributable to a family.
SITEMAP_CHUNK_SIZE = 10_000
# Children are served under /sitemaps/ because Next.js only treats a whole
# bracketed path segment as dynamic — a route folder named "sitemap-[...parts]"
# is read as a literal static segment and never matches.
SITEMAP_CHILD_PREFIX = "/sitemaps"
def get_place_registry() -> dict:
"""The place registry, built once and cached for the process."""
global _place_registry
if _place_registry is None:
_place_registry = build_place_registry(load_school_data())
return _place_registry
def _urlset(rows: list[str]) -> str:
return "\n".join([
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
*rows,
"</urlset>",
])
def _place_url(place) -> str:
"""The canonical path for a place. Two namespaces, per the spec.
Towns and localities share /schools/[place]; authorities take their own
prefix because 67 town names collide with an authority name and neither
set contains the other.
"""
if place.kind == "authority":
return f"/schools/authority/{place.slug}"
if place.kind == "outcode":
return f"/schools/near/{place.slug}"
return f"/schools/{place.slug}"
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
"""A <url> per place, plus a phase variant wherever that phase clears the
threshold on its own.
Phase is part of the query — "primary schools in beccles" — so each
variant is its own indexable page. Submitting only the bare place URL left
~950 of them reachable by nothing: absent from every sitemap, and not
linked from the place page either.
"""
rows: list[str] = []
for p in sorted(get_place_registry().values(), key=lambda p: (p.kind, p.slug)):
if p.kind not in kinds:
continue
rows.append(_url_element(BASE_URL + _place_url(p)))
# Which phases a place publishes is the registry's decision alone —
# outcodes report none, because the spec gives them no phase route.
# Repeating that rule here was how the page and the sitemap came to
# disagree about which URLs exist.
for phase in ("primary", "secondary"):
if p.publishes_phase(phase):
rows.append(_url_element(f"{BASE_URL}{_place_url(p)}/{phase}"))
return rows
def build_sitemaps() -> dict[str, str]:
"""Build the sitemap index and every child, keyed by name."""
df = load_school_data()
static_urls = [
(BASE_URL + "/", "daily", "1.0"),
(BASE_URL + "/rankings", "weekly", "0.8"),
(BASE_URL + "/compare", "weekly", "0.8"),
children: dict[str, str] = {
"static.xml": _urlset(
[_url_element(BASE_URL + path) for path in STATIC_SITEMAP_PATHS]),
}
school_rows = _school_sitemap_rows(df)
# Always emit at least one school child, so the index shape is stable even
# on an empty database.
chunks = [school_rows[i:i + SITEMAP_CHUNK_SIZE]
for i in range(0, len(school_rows), SITEMAP_CHUNK_SIZE)] or [[]]
for n, chunk in enumerate(chunks, start=1):
children[f"schools-{n}.xml"] = _urlset(chunk)
# Separate children per family: Search Console reports coverage per
# submitted sitemap, which is how the location layer's indexation is
# measured apart from the school pages'.
for label, kinds in (("places", ("town", "locality", "authority")),
("outcodes", ("outcode",))):
rows = _place_sitemap_rows(kinds)
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
for n, chunk in enumerate(chunks, start=1):
children[f"{label}-{n}.xml"] = _urlset(chunk)
# On a sitemap index, lastmod means "when this sitemap file last changed",
# so generation time is the correct value here — unlike on a <url>, where
# it would be a claim about content we cannot support.
generated = datetime.now(timezone.utc).date().isoformat()
index_rows = [
f" <sitemap><loc>{BASE_URL}{SITEMAP_CHILD_PREFIX}/{name}</loc>"
f"<lastmod>{generated}</lastmod></sitemap>"
for name in children
]
index = "\n".join([
'<?xml version="1.0" encoding="UTF-8"?>',
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
*index_rows,
"</sitemapindex>",
])
return {**children, "sitemap.xml": index}
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for url, freq, priority in static_urls:
lines.append(
f" <url><loc>{url}</loc>"
f"<changefreq>{freq}</changefreq>"
f"<priority>{priority}</priority></url>"
)
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
seen = set()
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
urn = int(row["urn"])
name = str(row["school_name"])
if urn in seen:
continue
seen.add(urn)
path = _school_url(urn, name)
lines.append(
f" <url><loc>{BASE_URL}{path}</loc>"
f"<changefreq>monthly</changefreq>"
f"<priority>0.6</priority></url>"
)
lines.append("</urlset>")
return "\n".join(lines)
def build_sitemap() -> str:
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
return build_sitemaps()["sitemap.xml"]
def clean_filter_values(series: pd.Series) -> list[str]:
@@ -121,8 +298,101 @@ def clean_filter_values(series: pd.Series) -> list[str]:
# SECURITY MIDDLEWARE & HELPERS
# =============================================================================
# Rate limiter
limiter = Limiter(key_func=get_remote_address)
def client_key(request: Request) -> str:
"""The rate-limit bucket: the real caller, not the proxy in front of them.
`get_remote_address` reads request.client.host. In staging and production
the backend has no published ports and sits on the internal network, so its
only caller is the Next proxy — meaning every browser user on the site
shared one bucket. Measured before this fix: 70 concurrent requests to
/api/schools returned 60 OK and 10 refused.
CF-Connecting-IP first, because Cloudflare (in front of both environments)
sets it on every origin request and *overwrites* any client-supplied value,
which a parsed X-Forwarded-For chain does not guarantee. The XFF fallback is
forgeable, but only by a caller already inside the Docker network, which is
the one place nothing untrusted can reach.
"""
cf = request.headers.get("cf-connecting-ip")
if cf:
return cf.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return get_remote_address(request)
# Per-client limiter. Paired with the global ceiling below — the two do
# different jobs and neither substitutes for the other.
limiter = Limiter(key_func=client_key)
# --- The ceiling no header can raise ----------------------------------------
#
# client_key trusts CF-Connecting-IP, and nothing in this process can tell an
# edge-set header from an attacker-set one. That distinction can only be made
# at Cloudflare, with Authenticated Origin Pulls or an origin firewall. A
# caller reaching the origin directly could otherwise mint a fresh rate-limit
# bucket per request and evade per-client limits entirely — which would make
# correct keying a net regression against abuse, since the single shared bucket
# it replaced at least capped everyone at 60/minute together.
#
# So per-client limits give fairness, and this gives the origin a hard total.
# It does not make the header trustworthy; it bounds what trusting it can cost.
# The header problem itself is closed at Cloudflare, not here.
#
# [window_start_monotonic, count], or None before the first request. A fixed
# window is crude, which is right for a backstop: it has to be obviously
# correct rather than fair.
_global_window: Optional[list] = None
# The container healthcheck runs `curl http://localhost:80/api/data-info` from
# inside the container. Starving it would fail the check, restart the
# container, and turn a load spike into an outage loop — the ceiling exists to
# protect the origin, not to kill it.
_LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def exempt_from_ceiling(request: Request) -> bool:
"""Whether the ceiling should ignore this request.
Its own function so the rule is testable without standing up a server —
and so the healthcheck exemption is somewhere a reader can find it.
"""
if not request.url.path.startswith("/api/"):
return True
# The peer address, never the Host header: Host is set by the caller and
# would hand every attacker an exemption.
return (request.client.host if request.client else "") in _LOCAL_HOSTS
class GlobalRateLimitMiddleware(BaseHTTPMiddleware):
"""A cap on total /api/ traffic, independent of any client identity."""
async def dispatch(self, request: Request, call_next):
global _global_window
if exempt_from_ceiling(request):
return await call_next(request)
now = time.monotonic()
# One event loop, and no await between the read and the write, so this
# sequence is atomic without a lock.
if _global_window is None or now - _global_window[0] >= 60:
_global_window = [now, 0]
_global_window[1] += 1
if _global_window[1] > settings.global_rate_limit_per_minute:
return JSONResponse(
# Distinguishable from slowapi's per-client 429: an operator
# reading logs has to be able to tell "one noisy client" from
# "the origin is saturated".
{"detail": "The service is at capacity. Please retry shortly."},
status_code=429,
headers={"Retry-After":
str(max(1, int(60 - (now - _global_window[0]))))},
)
return await call_next(request)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -180,6 +450,7 @@ CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
("/api/rankings", (60, 600, 3600)),
("/api/compare", (60, 600, 3600)),
("/api/suggest", (60, 3600, 86400)), # autosuggest
("/api/schools", (30, 300, 1800)), # search list
]
@@ -284,7 +555,8 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan - startup and shutdown events."""
global _sitemap_xml
global _sitemaps
flags.init()
print("Loading school data from marts...")
df = load_school_data()
if df.empty:
@@ -294,9 +566,9 @@ async def lifespan(app: FastAPI):
# Pre-compute the latest-year snapshot so the first search request is fast
await asyncio.to_thread(load_latest_school_data)
try:
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
print(f"Sitemap built: {n} URLs.")
_sitemaps = build_sitemaps()
n = sum(x.count("<url>") for x in _sitemaps.values())
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
except Exception as e:
print(f"Warning: sitemap build failed on startup: {e}")
@@ -326,6 +598,10 @@ app.add_middleware(CacheAndETagMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512)
# Added last, so it is outermost and refuses before anything downstream does
# work. A ceiling that only applies after the expensive part has run is not a
# ceiling.
app.add_middleware(GlobalRateLimitMiddleware)
# CORS middleware - restricted for production
app.add_middleware(
@@ -632,6 +908,13 @@ async def get_school_details(request: Request, urn: int):
"census": supplementary.get("census"),
"admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [],
# Behind a flag, and withheld at the source rather than rendered-but-
# hidden: this endpoint is public and unauthenticated, so a field left
# in the payload is a published field. The key is absent, not null —
# null would state that this school has no cut-off, which is a
# different claim from "we are not publishing cut-offs".
**({"admission_distance": supplementary.get("admission_distance")}
if flags.is_enabled("admission_distance") else {}),
"sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"),
@@ -1006,6 +1289,132 @@ async def get_rankings(
}
@app.get("/api/places")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def list_places(request: Request):
"""Every published place. The sitemap and the link modules read this."""
registry = get_place_registry()
return {"places": [
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
]}
@app.get("/api/places/{kind}/{slug}")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_place(request: Request, kind: str, slug: str,
phase: Optional[str] = None):
"""One place: its schools ranked, and its local averages."""
if kind not in VALID_PLACE_KINDS:
raise HTTPException(status_code=404, detail="No such place")
registry = get_place_registry()
place = registry.get(f"{kind}:{slug}")
if place is None:
raise HTTPException(status_code=404, detail="No such place")
df = load_latest_school_data()
rows = df[df["urn"].isin(place.urns)]
if phase:
wanted = PHASE_GROUPS.get(phase.lower())
if wanted and "phase" in rows.columns:
rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)]
# The metric the page shows, and averages.
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
# Alphabetical, not by score. A place page is read by someone looking for
# a school they can name, and scanning for it is what the order should
# serve. /rankings is where the league-table ordering lives, and it keeps
# sorting by metric.
if "school_name" in rows.columns:
rows = rows.sort_values("school_name", key=lambda c: c.str.lower())
averages = {
m: (None if m not in rows.columns or rows[m].dropna().empty
else float(rows[m].dropna().mean()))
for m in ("rwm_expected_pct", "attainment_8_score")
}
cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
"rwm_expected_pct", "attainment_8_score",
"total_pupils"]
if c in rows.columns]
return {
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
"count": len(place.urns),
"parent_authority": place.parent_authority,
# Every authority the place meaningfully sits in. SW19 is
# mostly Merton but partly Wandsworth; naming one asserts
# something false.
#
# The slug is null where that authority has no page of its
# own: City of London and the Isles of Scilly hold fewer
# schools than the threshold. Naming them is still right;
# linking them would be a 404.
"authorities": [
{"name": name,
"slug": (_slugify(name)
if f"authority:{_slugify(name)}" in registry
else None),
"count": n}
for name, n in place.authorities
],
# Only phases that clear the threshold, so the page links
# variants that exist rather than 404s.
"phases": [ph for ph in ("primary", "secondary")
if place.publishes_phase(ph)]},
"schools": clean_for_json(rows[cols]),
"averages": averages,
}
# Two characters. One is not a query — it matches thousands of schools and the
# response is useless, so it is not worth a round trip.
SUGGEST_MIN_QUERY = 2
@app.get("/api/suggest")
@limiter.limit("120/minute")
async def suggest_schools(
request: Request,
q: str = Query("", max_length=100),
limit: int = Query(8, ge=1, le=20),
):
"""School name suggestions, from Typesense alone.
Deliberately not a mode of /api/schools: that path filters and sorts the
full in-memory DataFrame, which is far too expensive to run per keystroke.
Nothing here returns an error for ordinary input. A short query, no
matches, or Typesense being unreachable are all 200 with an empty list —
a dropdown that quietly does not appear is the right failure for a
keystroke path, and there is no DataFrame fallback because the 25,000-row
substring scan is precisely what this endpoint exists to avoid.
120/minute rather than the default 60: a 200 ms debounce makes typing
legitimately bursty.
"""
query = q.strip()
if len(query) < SUGGEST_MIN_QUERY:
return {"suggestions": []}
return {"suggestions": suggest_schools_typesense(query, limit)}
@app.get("/api/flags")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_feature_flags(request: Request):
"""Every declared flag and its current value.
Internal only. The Next proxy denies this path, because the response names
every unreleased feature the codebase knows about — which is exactly what
shipping dark is meant to keep quiet.
"""
return flags.all_flags()
@app.get("/api/data-info")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_data_info(request: Request):
@@ -1086,16 +1495,28 @@ async def robots_txt():
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
@app.get("/sitemap.xml")
async def sitemap_xml():
"""Serve sitemap.xml for search engine indexing."""
global _sitemap_xml
if _sitemap_xml is None:
def _serve_sitemap(name: str) -> Response:
global _sitemaps
if _sitemaps is None:
try:
_sitemap_xml = build_sitemap()
_sitemaps = build_sitemaps()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
return Response(content=_sitemap_xml, media_type="application/xml")
if name not in _sitemaps:
raise HTTPException(status_code=404, detail="No such sitemap")
return Response(content=_sitemaps[name], media_type="application/xml")
@app.get("/sitemap.xml")
async def sitemap_xml():
"""Serve the sitemap index."""
return _serve_sitemap("sitemap.xml")
@app.get("/sitemaps/{name}")
async def sitemap_child(name: str):
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
return _serve_sitemap(name)
@app.post("/api/admin/regenerate-sitemap")
@@ -1105,10 +1526,14 @@ async def regenerate_sitemap(
_: bool = Depends(verify_admin_api_key),
):
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
global _sitemap_xml
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
return {"status": "ok", "urls": n}
global _sitemaps, _place_registry
# Places and sitemap are rebuilt together — they read the same marts, and
# letting them drift apart would submit URLs for places that no longer
# exist.
_place_registry = None
_sitemaps = build_sitemaps()
n = sum(x.count("<url>") for x in _sitemaps.values())
return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)}
# Mount static files directly (must be after all routes to avoid catching API calls)
+15
View File
@@ -35,6 +35,11 @@ class Settings(BaseSettings):
# Security
admin_api_key: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
rate_limit_per_minute: int = 60 # Requests per minute per IP
# A ceiling on total /api/ traffic, independent of any client identity.
# client_key trusts headers only Cloudflare can vouch for, so a caller
# reaching the origin directly could otherwise mint a fresh bucket per
# request. See GlobalRateLimitMiddleware in backend/app.py.
global_rate_limit_per_minute: int = 3000
rate_limit_burst: int = 10 # Allow burst of requests
max_request_size: int = 1024 * 1024 # 1MB max request size
@@ -42,6 +47,16 @@ class Settings(BaseSettings):
typesense_url: str = "http://localhost:8108"
typesense_api_key: str = ""
# Feature flags (Unleash). An empty unleash_url disables flags entirely and
# every flag evaluates False — the correct behaviour for local development
# and CI, and the reason no test needs a running Unleash.
unleash_url: str = ""
unleash_api_token: str = ""
unleash_app_name: str = "schoolcompare-backend"
# On a named volume, so a restart during an Unleash outage keeps
# last-known state instead of reverting a released feature to dark.
unleash_cache_directory: str = "/app/.unleash"
# Analytics
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
+91 -1
View File
@@ -18,7 +18,7 @@ from .config import settings
from .database import SessionLocal, engine
from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions,
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactDeprivation, FactFinance, FactPupilCharacteristics,
)
from .ofsted_codes import ofsted_page_url, report_card_labels
@@ -100,6 +100,58 @@ def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
return []
# The most a public endpoint will return in one response.
SUGGEST_MAX_LIMIT = 20
# Fields a suggestion row carries, and the default when the document omits an
# optional one. phase and school_type are optional in the Typesense schema.
_SUGGEST_FIELDS = ("school_name", "local_authority", "postcode",
"phase", "school_type")
def suggest_schools_typesense(query: str, limit: int = 8) -> List[dict]:
"""Autosuggest rows straight from Typesense. Never raises.
Returns documents rather than URNs, unlike search_schools_typesense, so the
caller needs no DataFrame. Every field below is already in the index — see
pipeline/scripts/sync_typesense.py — which is what makes this cheap enough
to run per keystroke.
"""
client = _get_typesense_client()
if client is None:
return []
try:
result = client.collections["schools"].documents.search({
"q": query,
"query_by": "school_name,local_authority",
"per_page": max(1, min(limit, SUGGEST_MAX_LIMIT)),
"typo_tokens_threshold": 1,
})
except Exception:
# A dropdown that quietly stops appearing is the right failure here.
return []
rows = []
for hit in result.get("hits", []) or []:
doc = (hit or {}).get("document") or {}
try:
urn = int(doc["urn"])
except (KeyError, TypeError, ValueError):
# Skip the row, keep the rest. Typesense declares urn as int32 so
# this should be unreachable, but the index is a separate system
# that something other than this code can reindex — and "never
# raises" is a promise the keystroke path actually depends on.
# Dropping one malformed document is right; blanking the whole
# dropdown, or serving a suggestion pointing at /school/0, is not.
logging.getLogger(__name__).warning(
"skipping malformed suggestion document: %r", doc)
continue
row = {"urn": urn}
row.update({f: str(doc.get(f, "") or "") for f in _SUGGEST_FIELDS})
rows.append(row)
return rows
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
"""Convert cryptic school type codes to user-friendly names."""
if not school_type:
@@ -723,6 +775,17 @@ def _admissions_row_dict(a) -> dict:
}
def _admission_distance_dict(d) -> dict:
"""Serialize one fact_admission_distance row for API responses."""
return {
"year": d.year,
"distance_m": d.distance_m,
"route_count": d.route_count,
"la_name": d.la_name,
"distance_unit_raw": d.distance_unit_raw,
}
def _census_dict(pc) -> dict:
return {
"year": pc.year,
@@ -759,6 +822,7 @@ def _empty_supplementary() -> dict:
"census": None,
"admissions": None,
"admissions_history": [],
"admission_distance": None,
"sen_detail": None,
"phonics": None,
"deprivation": None,
@@ -837,6 +901,32 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
_safe(_admissions)
# Last distance offered — the latest year per URN, and only that.
#
# The mart holds every published year and the DAG keeps loading them; what
# changed is what leaves this process. Earlier years are being held back as
# a paid feature, and this API is public and unauthenticated — serving the
# history here would hand it to anyone who opened the network tab, whatever
# the page chose to render. Withholding it in the client would have been
# decoration, not a decision.
#
# Restoring it for entitled callers is a change to this function, not to
# the pipeline: fact_admission_distance is untouched and complete.
def _admission_distance():
rows = (
db.query(FactAdmissionDistance)
.filter(FactAdmissionDistance.urn.in_(urns))
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
.all()
)
seen = set()
for d in rows:
if d.urn in seen:
continue
seen.add(d.urn)
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
_safe(_admission_distance)
# Deprivation — one row per URN.
def _deprivation():
rows = (
+118
View File
@@ -0,0 +1,118 @@
"""Feature flags: what can be switched, and what is switched right now.
Ship-dark, not a kill switch. Flags let work merge and deploy without becoming
visible; they are expected to flip about monthly, by a person, deliberately.
Nothing here does percentage rollouts or user targeting — the site has no user
identity to target.
Unleash holds the state. It does not hold the list. REGISTRY below is that
list, and it exists for three reasons: the SDK evaluates an unknown flag to
False, so without a registry that is an *undeclared* False, indistinguishable
from a typo; /api/flags needs a key set to return when Unleash is unreachable;
and a flag in the UI but not in the registry is orphaned and should be visibly
so rather than quietly authoritative.
Every flag defaults to False. There is no per-flag default, because a flag that
defaults on is a kill switch, and this is not one.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from .config import settings
logger = logging.getLogger(__name__)
# A flag is temporary scaffolding. See test_a_flag_older_than_the_limit.
MAX_FLAG_AGE_DAYS = 90
@dataclass(frozen=True)
class Flag:
# One string: the registry key, the Unleash flag name, and the JSON key in
# /api/flags. snake_case, matching the API's existing convention. No case
# transformation anywhere, so there is no mapping layer to get wrong.
name: str
description: str # one line: what turning this on reveals
added: date # for the staleness tripwire
REGISTRY: dict[str, Flag] = {
f.name: f for f in (
Flag(
name="admission_distance",
description=(
"The last-distance-offered figure on the Admissions tile and "
"the 'How far away are you?' section on school pages."
),
added=date(2026, 8, 23),
),
Flag(
name="school_autosuggest",
description=(
"School name suggestions as you type in the main search box."
),
added=date(2026, 8, 26),
),
)
}
_client = None
def init() -> None:
"""Start the Unleash client, or log why flags are all off.
Called once from the app lifespan. Never raises: a flag system that can
stop the API from booting is worse than one that is switched off.
"""
global _client
if not settings.unleash_url or not settings.unleash_api_token:
logger.warning(
"Unleash is not configured (UNLEASH_URL / UNLEASH_API_TOKEN); "
"every feature flag evaluates to False.")
return
try:
from UnleashClient import UnleashClient
_client = UnleashClient(
url=settings.unleash_url,
app_name=settings.unleash_app_name,
custom_headers={"Authorization": settings.unleash_api_token},
cache_directory=settings.unleash_cache_directory,
refresh_interval=15,
)
_client.initialize_client()
logger.info("Unleash client initialised against %s", settings.unleash_url)
except Exception:
# Fail closed and keep serving. The SDK also evaluates everything False
# until its first successful sync, so this is the same direction.
_client = None
logger.exception("Unleash client failed to start; flags are all False.")
def is_enabled(name: str) -> bool:
"""Whether `name` is on. False for anything unknown, unreachable or broken."""
if name not in REGISTRY:
logger.error(
"undeclared feature flag %r was evaluated; returning False. "
"Add it to backend/flags.py REGISTRY or fix the name.", name)
return False
if _client is None:
return False
try:
return bool(_client.is_enabled(
name, fallback_function=lambda feature_name, context: False))
except Exception:
logger.exception("flag %r failed to evaluate; returning False", name)
return False
def all_flags() -> dict[str, bool]:
"""Every declared flag and its current value. Serves /api/flags."""
return {name: is_enabled(name) for name in REGISTRY}
+54
View File
@@ -0,0 +1,54 @@
"""Curated London localities, defined by the postcode districts they cover.
The GIAS `town` field puts 1,819 London schools under the single value
"London", so it cannot answer "schools in Battersea" — a query that appears in
the Search Console baseline. No single field can: parliamentary constituency
gives Battersea but not Canary Wharf; postcodes.io's admin_ward gives Canary
Wharf but not Battersea; neither gives Clapham or Shoreditch, which are postal
and colloquial rather than administrative.
So this is curated. Where a locality ends is a judgement, not a fact, and a
reviewable file is the honest place for a judgement. No new ingestion is
needed — the corpus already carries postcodes.
This is the canonical copy. `pipeline/transform/seeds/locality_outcodes.csv`
mirrors it for anyone querying the warehouse directly; the backend image does
not contain `pipeline/`, which is why the module rather than the seed is
canonical. Same arrangement as `backend/gias_codes.py`.
A locality whose outcodes hold fewer than MIN_SCHOOLS schools is not
published, so a typo produces no page rather than an empty one. Places that
fail that check are logged at startup, because a locality you meant to publish
quietly not appearing is the failure worth hearing about.
Two rules for anything added here.
**Sub-borough districts only.** A London borough is a local authority and
already has a page at /schools/authority/[la] covering all of its schools; a
locality defined by two or three outcodes would be a partial, near-duplicate
subset of it. Hackney, Islington, Greenwich and Ealing were all in the first
draft for that reason and have been removed.
**The slug must not match a GIAS town.** "Richmond" did — GIAS has a Richmond
in North Yorkshire with 37 schools — so the London one could never publish.
The registry skips any locality that collides and logs it.
"""
# slug -> (display name, outcodes)
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]] = {
"battersea": ("Battersea", ("SW11",)),
"canary-wharf": ("Canary Wharf", ("E14",)),
"clapham": ("Clapham", ("SW4",)),
"shoreditch": ("Shoreditch", ("EC2A", "E1")),
"peckham": ("Peckham", ("SE15",)),
"brixton": ("Brixton", ("SW2", "SW9")),
"camden-town": ("Camden Town", ("NW1",)),
"wimbledon": ("Wimbledon", ("SW19",)),
"putney": ("Putney", ("SW15",)),
"fulham": ("Fulham", ("SW6",)),
"chiswick": ("Chiswick", ("W4",)),
"stratford": ("Stratford", ("E15",)),
"walthamstow": ("Walthamstow", ("E17",)),
"tooting": ("Tooting", ("SW17",)),
"dulwich": ("Dulwich", ("SE21", "SE22")),
}
+31
View File
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
admissions_policy = Column(String(100))
class FactAdmissionDistance(Base):
"""Last distance offered — one row per URN per year.
Separate from FactAdmissions because the source is separate: EES publishes
admissions for the whole country, whereas cut-off distances exist only for
the local authorities that choose to publish them (57 at the time of
writing), and the two refresh on unrelated timetables.
"""
__tablename__ = "fact_admission_distance"
__table_args__ = (
Index("ix_admission_distance_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
# Straight-line distance in metres from the school to the last home offered
# a place that year.
distance_m = Column(Float)
# How many admission routes (ability bands, separate reception/junior
# intakes) were collapsed into distance_m. >1 means the figure is the
# furthest of several and the page must say so.
route_count = Column(Integer)
la_code = Column(Integer)
la_name = Column(String(100))
# The unit the council published in, so the page can lead with the unit a
# parent was given rather than always converting.
distance_unit_raw = Column(String(20))
source_file = Column(Text)
class FactPupilCharacteristics(Base):
"""School pupil composition from EES census — one row per URN per year."""
__tablename__ = "fact_pupil_characteristics"
+314
View File
@@ -0,0 +1,314 @@
"""The place registry: what places the site publishes, and what is in each.
One module owns this question. The pages, the sitemap and the internal-link
modules all read from here, so the threshold and the collision rules exist in
exactly one place and are testable without a browser or a database.
Two namespaces, never one. 67 viable town names collide with a local
authority name, and the authority is the larger set in only 43 of them —
postal towns cross authority boundaries, so neither can absorb the other.
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# Five schools with publishable data. Below this a place has nothing to say
# that a list of schools does not, and publishing it is index bloat.
MIN_SCHOOLS = 5
@dataclass(frozen=True)
class Place:
kind: str # "town" | "locality" | "authority" | "outcode"
slug: str
name: str
urns: tuple[int, ...]
parent_authority: str | None # authority NAME, for the 301 target
# Every authority the place meaningfully sits in, largest first. A quarter
# of outcodes and a third of towns straddle a boundary — SW19 is mostly
# Merton but partly Wandsworth — so naming only one asserts something
# false. parent_authority stays single because a redirect needs one
# target; this is what the page shows.
authorities: tuple[tuple[str, int], ...] = ()
# URNs per phase, so the per-phase threshold can be applied without
# re-querying. A place with 30 primaries and 2 secondaries publishes a
# primary variant and no secondary one.
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
def publishes_phase(self, phase: str) -> bool:
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
@property
def key(self) -> str:
return f"{self.kind}:{self.slug}"
def _publishable_urns(df) -> set[int]:
"""URNs with something a page could state, deduplicated across years."""
from backend.app import _PUBLISHABLE_FIELDS
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
if not cols:
return set()
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
# The measure a phase page is built around. A page with no results in this
# column has nothing a list of school names does not already give.
_PHASE_METRIC = {
"primary": "rwm_expected_pct",
"secondary": "attainment_8_score",
}
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
"""URNs per phase, counting only schools with a result for that phase.
Not merely "publishable". A school with an Ofsted grade and no results is
worth a page of its own and belongs in the place list, but it cannot
populate a phase page's results column — and the threshold is there to ask
whether that column will have anything in it.
Counting publishable schools instead let /schools/kent/primary publish
with none of its five rows carrying a result, and left 44 phase pages
majority-blank. It is the same rule as "no page without a local average",
which was never extended per phase.
All-through schools count toward both phases, matching the PHASE_GROUPS
mapping the search filters already use.
"""
from backend.app import PHASE_GROUPS
if "phase" not in group.columns:
return {}
lowered = group["phase"].fillna("").str.lower()
out: dict[str, tuple[int, ...]] = {}
for phase in ("primary", "secondary"):
wanted = PHASE_GROUPS.get(phase, set())
subset = group[lowered.isin(wanted)]
# The page lists every school of the phase; the threshold counts only
# those carrying a result, so a mostly-empty table never publishes.
metric = _PHASE_METRIC[phase]
with_result = (
{int(u) for u in subset.loc[subset[metric].notna(), "urn"]}
if metric in subset.columns else set()
)
if len(with_result & publishable) < MIN_SCHOOLS:
continue
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
if urns:
out[phase] = urns
return out
# A place is described by an authority when it holds at least a tenth of the
# schools, and at least two. GIAS carries occasional postcode errors — EN6
# lists two Shropshire schools among fourteen in Hertfordshire — and a bare
# "any authority present" rule would print those as though they were real.
# There is deliberately no cap on how many are named. An earlier cut stopped
# at three, which silently dropped the fourth in exactly the case where the
# information matters most — a genuinely fragmented place. The share rule is
# the only limit, and it already bounds the list at ten.
_AUTHORITY_MIN_SHARE = 0.10
_AUTHORITY_MIN_SCHOOLS = 2
def _authorities(group) -> tuple[tuple[str, int], ...]:
"""Authorities this place meaningfully sits in, largest first."""
from backend.app import EXCLUDED_FILTER_VALUES
if "local_authority" not in group.columns:
return ()
counts = group["local_authority"].dropna().value_counts()
total = int(counts.sum())
if not total:
return ()
kept = [
(str(name), int(n)) for name, n in counts.items()
if str(name) not in EXCLUDED_FILTER_VALUES
and n >= _AUTHORITY_MIN_SCHOOLS
and n / total >= _AUTHORITY_MIN_SHARE
]
# A place too small or too fragmented for the share rule still names its
# largest authority, or the page would say nothing about where it is.
if not kept:
for name, n in counts.items():
if str(name) not in EXCLUDED_FILTER_VALUES:
return ((str(name), int(n)),)
return ()
return tuple(kept)
def _parent_authority(authorities: tuple[tuple[str, int], ...]) -> str | None:
"""The 301 target: the largest authority a place sits in.
Derived from `authorities` rather than computed separately. The first cut
used `mode()` here while `authorities` used `value_counts()`, and on an
exact tie pandas does not guarantee the two pick the same name — so the
redirect could have pointed somewhere other than the authority the page
named first. One computation, one answer.
Deriving it also inherits the sentinel filter, so a place can no longer
redirect to /schools/authority/does-not-apply.
"""
return authorities[0][0] if authorities else None
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
"""One Place per distinct SLUG in `column` that clears the threshold.
Grouped by slug, not by raw value, because GIAS spells the same place
several ways and they all resolve to one URL. Five town slugs come from
more than one spelling: "London" (1,819 schools) and "LONDON" (12) both
slugify to `london`; Weston-super-Mare is split 14/19 across two
spellings; Newcastle-under-Lyme across three.
Grouping by raw value meant the later group simply overwrote the earlier
one in this dict — so /schools/london could have shown twelve schools
instead of 1,819, silently and depending on row order.
The display name is the most common spelling, which is the one a reader
expects to see.
"""
from backend.app import _slugify
if column not in df.columns:
return {}
working = df.assign(_slug=df[column].map(
lambda v: _slugify(str(v).strip()) if isinstance(v, str) and v.strip() else None))
working = working[working["_slug"].notna() & (working["_slug"] != "")]
out: dict[str, Place] = {}
for slug, group in working.groupby("_slug"):
slug = str(slug)
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
continue
spellings = group[column].dropna().value_counts()
if spellings.empty:
continue
name = str(spellings.index[0]).strip()
authorities = () if kind == "authority" else _authorities(group)
place = Place(
kind=kind, slug=slug, name=name, urns=urns,
parent_authority=_parent_authority(authorities),
authorities=authorities,
phase_urns=_phase_urns(group, publishable),
)
out[place.key] = place
return out
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
def _outcode(postcode) -> str | None:
if not isinstance(postcode, str):
return None
m = _OUTCODE_RE.match(postcode.upper().strip())
return m.group(1) if m else None
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
"""One Place per postcode district clearing the threshold.
These carry no phase variants: nobody searches "primary schools in SW11",
so the spec gives them no /primary or /secondary route. `phase_urns` is
left empty rather than computed and then filtered downstream — the
registry is the one place that decides which phases a place publishes,
and the page links whatever it reports.
Computing them here put a link to a route that does not exist on every one
of the 1,720 outcode pages.
"""
if "postcode" not in df.columns:
return {}
working = df.assign(_oc=df["postcode"].map(_outcode))
working = working[working["_oc"].notna()]
out: dict[str, Place] = {}
for oc, group in working.groupby("_oc"):
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
continue
authorities = _authorities(group)
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
urns=urns, parent_authority=_parent_authority(authorities),
authorities=authorities)
out[place.key] = place
return out
def _locality_places(df, publishable: set[int],
town_slugs: set[str]) -> dict[str, Place]:
"""One Place per curated locality clearing the threshold."""
from backend.localities import LOCALITY_OUTCODES
if "postcode" not in df.columns:
return {}
working = df.assign(_oc=df["postcode"].map(_outcode))
out: dict[str, Place] = {}
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
if slug in town_slugs:
# Skip, do not raise. The guard exists so a locality never
# silently shadows a town — skipping achieves that, and the error
# log makes it loud.
#
# Raising here took down sitemap generation for all 25,000 school
# pages when "richmond" met the GIAS town Richmond in North
# Yorkshire. Worse, GIAS town names change without any code change,
# so a raise means curated data can break the site spontaneously.
# A curation mistake must cost one page, not the sitemap.
logger.error(
"locality %r collides with the published town of the same "
"slug and has been skipped; rename it or remove it", slug)
continue
group = working[working["_oc"].isin(outcodes)]
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
# Not an error — a locality can legitimately be too small. Logged
# because one you meant to publish quietly vanishing is the
# failure worth hearing about.
logger.warning(
"locality %s (%s) has %d publishable schools, below the "
"threshold of %d - not published",
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
continue
authorities = _authorities(group)
place = Place(kind="locality", slug=slug, name=name, urns=urns,
parent_authority=_parent_authority(authorities),
authorities=authorities,
phase_urns=_phase_urns(group, publishable))
out[place.key] = place
return out
def build_place_registry(df) -> dict[str, Place]:
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
if df.empty or "urn" not in df.columns:
return {}
publishable = _publishable_urns(df)
registry: dict[str, Place] = {}
registry.update(_group(df, "local_authority", "authority", publishable))
towns = _group(df, "town", "town", publishable)
registry.update(towns)
town_slugs = {p.slug for p in towns.values()}
registry.update(_locality_places(df, publishable, town_slugs))
registry.update(_outcode_places(df, publishable))
return registry
+163
View File
@@ -0,0 +1,163 @@
"""Tests for the feature flag layer (spec 2026-08-23).
None of these need a running Unleash. That is the point: an unset UNLEASH_URL
means every flag is False, which is what local development and CI get.
"""
from datetime import date, timedelta
from backend import flags
def test_every_declared_flag_is_keyed_by_its_own_name():
# One string is the registry key, the Unleash flag name and the JSON key.
# A mismatch here would mean the UI toggles a flag the code never reads.
for key, flag in flags.REGISTRY.items():
assert key == flag.name
def test_flag_names_are_snake_case():
# Matches the API's existing convention (admission_distance,
# rwm_expected_pct) so no case transformation exists to get wrong.
for name in flags.REGISTRY:
assert name == name.lower()
assert "-" not in name and " " not in name
def test_an_unconfigured_client_evaluates_every_flag_false(monkeypatch):
monkeypatch.setattr(flags, "_client", None)
for name in flags.REGISTRY:
assert flags.is_enabled(name) is False
def test_an_undeclared_flag_is_false_rather_than_an_error(monkeypatch):
# A typo'd flag name must not raise in a request path. It is logged as an
# error, because an undeclared flag is always a bug.
monkeypatch.setattr(flags, "_client", None)
assert flags.is_enabled("no_such_flag") is False
def test_an_exploding_client_is_false_rather_than_a_500(monkeypatch):
class Boom:
def is_enabled(self, *a, **kw):
raise RuntimeError("unleash is on fire")
monkeypatch.setattr(flags, "_client", Boom())
name = next(iter(flags.REGISTRY))
assert flags.is_enabled(name) is False
def test_all_flags_reports_every_declared_flag(monkeypatch):
monkeypatch.setattr(flags, "_client", None)
assert set(flags.all_flags()) == set(flags.REGISTRY)
assert all(v is False for v in flags.all_flags().values())
def test_a_flag_older_than_the_limit_fails_this_test():
"""A tripwire, not an assertion about correctness.
Flags are temporary scaffolding and the failure mode of every flag system
is accumulation. This fails on the day a flag turns 90, on whatever PR
happens to be open — which is the point: someone has to decide.
To fix: delete the flag and the branches that read it, or, if it genuinely
still needs to exist, move its `added` date and say why in the commit.
"""
stale = [
f.name for f in flags.REGISTRY.values()
if date.today() - f.added > timedelta(days=flags.MAX_FLAG_AGE_DAYS)
]
assert not stale, (
f"Flags older than {flags.MAX_FLAG_AGE_DAYS} days: {stale}. "
"Remove the flag and the code branches it guards, or move its `added` "
"date deliberately."
)
def _client():
from fastapi.testclient import TestClient
from backend import app as app_module
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_flags_endpoint_lists_every_declared_flag(monkeypatch):
monkeypatch.setattr(flags, "_client", None)
body = _client().get("/api/flags").json()
assert set(body) == set(flags.REGISTRY)
def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatch):
# The endpoint must still answer. A frontend that cannot read flags renders
# everything dark, which is right; one that gets a 500 renders nothing.
monkeypatch.setattr(flags, "_client", None)
res = _client().get("/api/flags")
assert res.status_code == 200
assert all(v is False for v in res.json().values())
def _school_payload(monkeypatch, *, flag_on: bool):
"""Fetch one school's payload with the distance flag forced on or off.
The DataFrame shape is copied from test_school_details.py rather than
minimised: the endpoint reads a wide set of GIAS columns, and a trimmed
frame fails for reasons that have nothing to do with flags.
"""
import numpy as np
import pandas as pd
from fastapi.testclient import TestClient
from backend import app as app_module
df = pd.DataFrame([{
"urn": 150275,
"school_name": "West London Performing Arts Academy",
"phase": "Secondary",
"school_type": "Special post 16 institution",
"trust_name": None,
"religious_denomination": "Does not apply",
"gender": None,
"age_range": "16-25",
"admissions_policy": None,
"capacity": np.nan,
"gias_total_pupils": np.nan,
"headteacher_name": None,
"website": None,
"ofsted_grade": np.nan,
"local_authority": "Ealing",
"address": "268 Northfield Avenue, London, W5 4UB",
"postcode": "W5 4UB",
"latitude": 51.4986,
"longitude": -0.3148,
"year": np.nan,
"total_pupils": np.nan,
"eligible_pupils": np.nan,
"rwm_expected_pct": np.nan,
}])
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
# Two arguments: get_supplementary_data(db, urn). See backend/app.py.
monkeypatch.setattr(
app_module, "get_supplementary_data",
lambda db, urn: {"admission_distance": {"distance_m": 772.49,
"year": 2024}})
monkeypatch.setattr(flags, "is_enabled", lambda name: flag_on)
client = TestClient(app_module.app, raise_server_exceptions=False)
res = client.get("/api/schools/150275")
assert res.status_code == 200, res.text
return res.json()
def test_the_distance_field_is_absent_when_the_flag_is_off(monkeypatch):
"""Absent, not null, and withheld at the source.
/api/schools/ is public and unauthenticated. Leaving a withheld field in
the payload while declining to render it hands the record to anyone who
opens the network tab — the reasoning already recorded in c9a1892.
"""
body = _school_payload(monkeypatch, flag_on=False)
assert "admission_distance" not in body
def test_the_distance_field_is_present_when_the_flag_is_on(monkeypatch):
body = _school_payload(monkeypatch, flag_on=True)
assert body["admission_distance"]["distance_m"] == 772.49
+420
View File
@@ -0,0 +1,420 @@
"""Tests for the place registry (spec 2026-08-21).
The registry is built from the in-memory school DataFrame, so these build a
small frame directly rather than touching a database.
"""
import numpy as np
import pandas as pd
import pytest
from backend.places import MIN_SCHOOLS, build_place_registry
def _df(rows: list[dict]) -> pd.DataFrame:
base = {
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
"rwm_expected_pct": 60.0, "attainment_8_score": np.nan,
"phase": "Primary", "postcode": "AA1 1AA",
}
return pd.DataFrame([{**base, **r} for r in rows])
def _town(n: int, town: str, la: str, start: int = 100000, **kw) -> list[dict]:
"""`start` offsets the URNs so two calls can describe different schools —
the Bedford case needs two authorities' worth of distinct URNs in one
town."""
return [
{"urn": start + i, "school_name": f"{town} School {i}",
"town": town, "local_authority": la, **kw}
for i in range(n)
]
def test_town_clearing_the_threshold_is_published():
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
assert "town:brentwood" in reg
assert reg["town:brentwood"].name == "Brentwood"
assert len(reg["town:brentwood"].urns) == MIN_SCHOOLS
def test_town_below_the_threshold_is_not_published():
reg = build_place_registry(_df(_town(MIN_SCHOOLS - 1, "Crosby", "Sefton")))
assert "town:crosby" not in reg
def test_a_town_below_threshold_still_names_its_authority():
# The route layer needs somewhere to 301 to.
reg = build_place_registry(_df(
_town(MIN_SCHOOLS - 1, "Crosby", "Sefton") + _town(MIN_SCHOOLS, "Bootle", "Sefton")))
assert "authority:sefton" in reg
def test_town_and_authority_of_the_same_name_are_separate_places():
# 67 real collisions. Neither set contains the other: Bedford the town has
# 104 schools, Bedford the authority 86, because postal towns cross
# authority boundaries.
rows = (_town(MIN_SCHOOLS, "Bedford", "Bedford")
+ _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire", start=200000))
reg = build_place_registry(_df(rows))
town, authority = reg["town:bedford"], reg["authority:bedford"]
assert set(town.urns) != set(authority.urns)
assert len(town.urns) == MIN_SCHOOLS * 2 # both authorities' schools
assert len(authority.urns) == MIN_SCHOOLS # only this authority's
def test_schools_without_publishable_data_do_not_count_toward_the_threshold():
rows = _town(MIN_SCHOOLS, "Ghosttown", "Nowhere")
for r in rows:
r["rwm_expected_pct"] = np.nan
r["ofsted_grade"] = np.nan
reg = build_place_registry(_df(rows))
assert "town:ghosttown" not in reg
def test_blank_town_is_ignored():
rows = _town(MIN_SCHOOLS, "", "Essex")
reg = build_place_registry(_df(rows))
assert not any(k.startswith("town:") for k in reg)
def test_a_school_is_counted_once_even_with_several_years_of_rows():
rows = []
for year in (202324, 202425):
rows += [{**r, "year": year} for r in _town(MIN_SCHOOLS, "Beccles", "Suffolk")]
reg = build_place_registry(_df(rows))
assert len(reg["town:beccles"].urns) == MIN_SCHOOLS
def test_locality_groups_schools_by_outcode(monkeypatch):
# The GIAS town field collapses 1,819 London schools into "London", so a
# locality is defined by its postcode districts instead.
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"battersea": ("Battersea", ("SW11",))})
rows = _town(MIN_SCHOOLS, "London", "Wandsworth")
for r in rows:
r["postcode"] = "SW11 2AA"
reg = build_place_registry(_df(rows))
assert reg["locality:battersea"].name == "Battersea"
assert len(reg["locality:battersea"].urns) == MIN_SCHOOLS
def test_locality_below_the_threshold_is_not_published(monkeypatch):
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"nowhere": ("Nowhere", ("ZZ99",))})
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "London", "Wandsworth")))
assert "locality:nowhere" not in reg
def test_a_locality_may_not_shadow_a_viable_town(monkeypatch, caplog):
"""A colliding locality is skipped loudly, and the town survives.
This used to raise, which took down sitemap generation for all 25,000
school pages the first time a curated slug met a real GIAS town. Curated
data must not be able to break the site — and GIAS town names change with
no code change at all, so the raise could fire spontaneously.
"""
import logging
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"brentwood": ("Brentwood", ("CM13",))})
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "CM13 1AA"
with caplog.at_level(logging.ERROR):
reg = build_place_registry(_df(rows))
assert "locality:brentwood" not in reg # skipped
assert "town:brentwood" in reg # the town is untouched
assert "brentwood" in caplog.text # and it was loud about it
def test_a_locality_collision_does_not_break_the_rest_of_the_registry(monkeypatch):
# The whole point of skipping rather than raising.
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"brentwood": ("Brentwood", ("CM13",))})
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "CM13 1AA"
reg = build_place_registry(_df(rows))
assert "authority:essex" in reg
assert "outcode:cm13" in reg
def test_outcode_places_are_built_from_postcodes():
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "CM13 1AA"
reg = build_place_registry(_df(rows))
assert reg["outcode:cm13"].name == "CM13"
assert len(reg["outcode:cm13"].urns) == MIN_SCHOOLS
def test_malformed_postcodes_do_not_create_places():
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "not a postcode"
reg = build_place_registry(_df(rows))
assert not any(k.startswith("outcode:") for k in reg)
def test_every_curated_locality_is_structurally_valid():
# Guards the hand-maintained file: real slug, real name, real outcodes.
import re
from backend.localities import LOCALITY_OUTCODES
assert LOCALITY_OUTCODES, "the curated locality list must not be empty"
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
assert re.fullmatch(r"[a-z0-9-]+", slug), slug
assert name.strip() == name and name, slug
assert outcodes, f"{slug} has no outcodes"
for oc in outcodes:
assert re.fullmatch(r"[A-Z]{1,2}\d{1,2}[A-Z]?", oc), (slug, oc)
def test_the_pipeline_seed_mirrors_the_canonical_module():
"""Two copies with no drift guard is worse than one copy.
backend/localities.py is canonical because the backend image does not
contain pipeline/. The seed exists so the warehouse can join on the same
definitions, and this is what stops the two diverging — the same
arrangement assert_gias_code_names_match_seed.sql gives gias_codes.
"""
import csv
from pathlib import Path
from backend.localities import LOCALITY_OUTCODES
seed_path = (Path(__file__).resolve().parents[2]
/ "pipeline/transform/seeds/locality_outcodes.csv")
assert seed_path.exists(), f"missing seed mirror at {seed_path}"
seed = {
row["locality_slug"]: (row["locality_name"],
tuple(row["outcodes"].split("|")))
for row in csv.DictReader(seed_path.open())
}
assert seed == LOCALITY_OUTCODES
def test_no_curated_locality_names_a_london_borough():
"""Boroughs are authorities and already have a page.
A locality defined by two or three outcodes inside a borough would be a
partial, near-duplicate subset of that authority page — the exact
thin-content failure the two-namespace design exists to avoid. Hackney,
Islington, Greenwich and Ealing were all in the first draft.
Hardcoded rather than read from the corpus because this must fail in CI,
where there is no database.
"""
from backend.localities import LOCALITY_OUTCODES
boroughs = {
"barking-and-dagenham", "barnet", "bexley", "brent", "bromley",
"camden", "croydon", "ealing", "enfield", "greenwich", "hackney",
"hammersmith-and-fulham", "haringey", "harrow", "havering",
"hillingdon", "hounslow", "islington", "kensington-and-chelsea",
"kingston-upon-thames", "lambeth", "lewisham", "merton", "newham",
"redbridge", "richmond-upon-thames", "southwark", "sutton",
"tower-hamlets", "waltham-forest", "wandsworth", "westminster",
}
named = boroughs & set(LOCALITY_OUTCODES)
assert not named, (
f"these are boroughs, not districts: {sorted(named)} - they already "
"have an authority page covering every school"
)
def test_a_place_names_every_authority_it_straddles():
"""SW19 is mostly Merton but partly Wandsworth.
A quarter of viable outcodes and a third of viable towns cross an
authority boundary, so naming only the largest asserts something false.
"""
rows = (_town(26, "London", "Merton", start=300000)
+ _town(7, "London", "Wandsworth", start=400000))
for r in rows:
r["postcode"] = "SW19 1AA"
reg = build_place_registry(_df(rows))
names = [n for n, _ in reg["outcode:sw19"].authorities]
assert names == ["Merton", "Wandsworth"] # largest first
assert dict(reg["outcode:sw19"].authorities)["Wandsworth"] == 7
def test_the_redirect_target_stays_a_single_authority():
# parent_authority and authorities do different jobs: a 301 needs one
# target, the page needs the truth.
rows = (_town(26, "London", "Merton", start=300000)
+ _town(7, "London", "Wandsworth", start=400000))
for r in rows:
r["postcode"] = "SW19 1AA"
reg = build_place_registry(_df(rows))
assert reg["outcode:sw19"].parent_authority == "Merton"
def test_a_stray_authority_below_the_share_threshold_is_not_named():
# GIAS carries postcode errors — EN6 lists two Shropshire schools among
# fourteen in Hertfordshire. Printing those as though real would be worse
# than omitting them.
rows = (_town(30, "Barnet", "Hertfordshire", start=300000)
+ _town(1, "Barnet", "Shropshire", start=400000))
for r in rows:
r["postcode"] = "EN6 1AA"
reg = build_place_registry(_df(rows))
assert [n for n, _ in reg["outcode:en6"].authorities] == ["Hertfordshire"]
def test_a_sentinel_authority_is_never_named():
rows = (_town(20, "London", "Merton", start=300000)
+ _town(6, "London", "Does not apply", start=400000))
for r in rows:
r["postcode"] = "SW19 1AA"
reg = build_place_registry(_df(rows))
assert [n for n, _ in reg["outcode:sw19"].authorities] == ["Merton"]
def test_a_place_always_names_at_least_one_authority():
# Even when every authority is below the share threshold, the page has to
# say where the place is.
rows = []
for i, la in enumerate(["A", "B", "C", "D", "E", "F", "G"]):
rows += _town(1, "Fragmented", la, start=300000 + i * 100)
reg = build_place_registry(_df(rows))
place = reg.get("town:fragmented")
assert place is not None
assert len(place.authorities) == 1
def test_every_qualifying_authority_is_named_with_no_cap():
"""An earlier cut stopped at three, dropping the fourth silently.
That truncation bit exactly where the information matters most — a
genuinely fragmented place — and nothing recorded it.
"""
rows = []
for i, la in enumerate(["Hackney", "Lambeth", "Westminster", "Lewisham"]):
rows += _town(3, "Fourway", la, start=300000 + i * 100)
reg = build_place_registry(_df(rows))
assert len(reg["town:fourway"].authorities) == 4
def test_the_redirect_target_is_the_authority_named_first():
"""They were computed separately — mode() against value_counts() — and on
an exact tie pandas does not guarantee the two agree."""
rows = (_town(26, "London", "Merton", start=300000)
+ _town(7, "London", "Wandsworth", start=400000))
for r in rows:
r["postcode"] = "SW19 1AA"
place = build_place_registry(_df(rows))["outcode:sw19"]
assert place.parent_authority == place.authorities[0][0]
def test_a_place_never_redirects_to_a_sentinel_authority():
# Deriving the parent from `authorities` inherits its sentinel filter.
rows = (_town(6, "Someplace", "Does not apply", start=300000)
+ _town(5, "Someplace", "Essex", start=400000))
reg = build_place_registry(_df(rows))
assert reg["town:someplace"].parent_authority == "Essex"
def test_spellings_of_one_place_are_merged_not_overwritten():
"""GIAS spells the same place several ways, and they share a URL.
"London" (1,819 schools) and "LONDON" (12) both slugify to `london`.
Grouping by raw value let the later group overwrite the earlier one, so
the page could have shown twelve schools instead of 1,819 — silently, and
depending on row order.
"""
rows = (_town(6, "Weston-super-Mare", "North Somerset", start=300000)
+ _town(5, "Weston-Super-Mare", "North Somerset", start=400000))
reg = build_place_registry(_df(rows))
assert len(reg["town:weston-super-mare"].urns) == 11
def test_the_merged_place_takes_its_most_common_spelling():
rows = (_town(9, "Newcastle-under-Lyme", "Staffordshire", start=300000)
+ _town(5, "NEWCASTLE-UNDER-LYME", "Staffordshire", start=400000))
reg = build_place_registry(_df(rows))
assert reg["town:newcastle-under-lyme"].name == "Newcastle-under-Lyme"
def test_a_phase_page_needs_results_not_merely_publishable_schools():
"""/schools/kent/primary published with none of its five rows scored.
The threshold counted schools that were publishable — a result OR an
Ofsted grade — while the page exists for its results column. Forty-four
phase pages were majority-blank; one had no results at all.
"""
rows = _town(MIN_SCHOOLS, "Kent", "Kent")
for r in rows:
r["rwm_expected_pct"] = np.nan # Ofsted only, no results
reg = build_place_registry(_df(rows))
assert "town:kent" in reg # the place still publishes
assert not reg["town:kent"].publishes_phase("primary")
def test_a_phase_page_publishes_once_enough_schools_carry_a_result():
rows = _town(MIN_SCHOOLS, "Beccles", "Suffolk")
reg = build_place_registry(_df(rows))
assert reg["town:beccles"].publishes_phase("primary")
def test_a_publishing_phase_page_still_lists_its_unscored_schools():
"""The threshold gates whether the page exists; it does not filter rows.
A parent looking up a school by name has to find it whether or not it
published results.
"""
scored = _town(MIN_SCHOOLS, "Beccles", "Suffolk", start=300000)
unscored = _town(2, "Beccles", "Suffolk", start=400000)
for r in unscored:
r["rwm_expected_pct"] = np.nan
reg = build_place_registry(_df(scored + unscored))
place = reg["town:beccles"]
assert place.publishes_phase("primary")
assert len(place.phase_urns["primary"]) == MIN_SCHOOLS + 2
def test_the_secondary_threshold_counts_its_own_metric():
# A town full of scored primaries must not thereby publish a secondary page.
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
reg = build_place_registry(_df(rows))
assert not reg["town:brentwood"].publishes_phase("secondary")
def test_an_outcode_publishes_no_phase_variants():
"""There is no /schools/near/[outcode]/[phase] route, by design.
Nobody searches "primary schools in SW11", so the spec gives outcodes no
phase variants. The registry computed them anyway, and the place page —
which links whatever phases the registry reports — put two 404s on every
outcode page in the site.
This is the single rule now: a kind with no phase route reports no phases,
so neither the page nor the sitemap can offer one.
"""
rows = [{"urn": 500000 + i, "school_name": f"SW11 School {i}",
"town": "London", "local_authority": "Wandsworth",
"postcode": "SW11 1AA"} for i in range(MIN_SCHOOLS + 3)]
reg = build_place_registry(_df(rows))
place = reg["outcode:sw11"]
assert place.phase_urns == {}
assert not place.publishes_phase("primary")
assert not place.publishes_phase("secondary")
def test_an_authority_still_publishes_phase_variants():
"""Authorities keep theirs — "primary schools in Kent" is a real query,
and /schools/authority/[la]/[phase] is the route that serves it."""
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Maidstone", "Kent")))
assert reg["authority:kent"].publishes_phase("primary")
+139
View File
@@ -0,0 +1,139 @@
"""Tests for the places API (spec 2026-08-21)."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy",
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
"ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
}
return pd.DataFrame([
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
"rwm_expected_pct": 50.0 + i}
for i in range(6)
])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_registry_lists_each_published_place(client):
body = client.get("/api/places").json()
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
assert ("town", "brentwood") in slugs
assert ("authority", "essex") in slugs
assert ("outcode", "cm13") in slugs
def test_registry_carries_a_count_per_place(client):
body = client.get("/api/places").json()
town = next(p for p in body["places"] if p["slug"] == "brentwood")
assert town["count"] == 6
def test_place_detail_returns_its_schools_alphabetically(client):
"""A place page is read by someone looking for a school they can name.
Scanning for it is what the order should serve, so the list is A-Z.
/api/rankings is where the league-table ordering lives.
"""
body = client.get("/api/places/town/brentwood").json()
assert body["place"]["name"] == "Brentwood"
names = [s["school_name"] for s in body["schools"]]
assert names == sorted(names, key=str.lower)
def test_place_ordering_ignores_case(client):
body = client.get("/api/places/town/brentwood").json()
names = [s["school_name"] for s in body["schools"]]
# A capitalised name must not sort ahead of every lowercase one.
assert names == sorted(names, key=str.lower)
def test_the_rankings_endpoint_still_ranks_by_metric(client):
# Alphabetical is a place-page decision, not a site-wide one.
body = client.get("/api/rankings?metric=rwm_expected_pct&phase=primary").json()
scores = [r["rwm_expected_pct"] for r in body.get("rankings", [])
if r.get("rwm_expected_pct") is not None]
assert scores == sorted(scores, reverse=True)
def test_place_detail_carries_the_local_average(client):
body = client.get("/api/places/town/brentwood").json()
# 50..55 inclusive
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
def test_phase_filter_narrows_the_school_list(client):
body = client.get("/api/places/town/brentwood?phase=secondary").json()
assert body["schools"] == []
def test_unknown_place_404s(client):
assert client.get("/api/places/town/atlantis").status_code == 404
def test_unknown_kind_404s(client):
assert client.get("/api/places/planet/mars").status_code == 404
def _straddling_df() -> pd.DataFrame:
"""Eight schools in CM13: six in Essex, which has a page, and two in an
authority too small to have one.
Two, not one: the registry ignores an authority holding a single school in
a place, because GIAS carries occasional postcode errors."""
df = _schools_df()
extra = df.iloc[:2].copy()
extra["urn"] = [200000, 200001]
extra["school_name"] = ["Scilly School 0", "Scilly School 1"]
extra["local_authority"] = "Isles Of Scilly"
return pd.concat([df, extra], ignore_index=True)
@pytest.fixture()
def straddling_client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _straddling_df)
monkeypatch.setattr(app_module, "load_latest_school_data", _straddling_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_an_outcode_reports_no_phases_because_it_has_no_phase_route(client):
body = client.get("/api/places/outcode/cm13").json()
assert body["place"]["phases"] == []
def test_an_authority_reports_the_phases_it_publishes(client):
body = client.get("/api/places/authority/essex").json()
assert body["place"]["phases"] == ["primary"]
def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_client):
"""Two English authorities — City of London and the Isles of Scilly — hold
fewer than the five schools a page needs, so they have no page.
Naming them is still right: the page says where the place is. Linking them
would not be. A null slug is what tells the page to print the name plainly
rather than invent a URL that 404s.
"""
body = straddling_client.get("/api/places/outcode/cm13").json()
by_name = {a["name"]: a for a in body["place"]["authorities"]}
assert by_name["Essex"]["slug"] == "essex"
assert by_name["Isles Of Scilly"]["slug"] is None
+147
View File
@@ -0,0 +1,147 @@
"""The rate-limit bucket must be the caller, not the proxy in front of them.
`get_remote_address` reads request.client.host. In staging and production the
backend has no published ports and its only caller is the Next proxy, so that
host is the Next container — one bucket for every browser user on the site.
Measured before this fix: 70 concurrent requests, 60 served and 10 refused.
"""
from starlette.datastructures import Headers
from backend.app import client_key
class _Req:
"""Enough of a Request for the key function: headers and a client host."""
def __init__(self, headers: dict, host: str = "10.0.0.9"):
self.headers = Headers(headers)
self.client = type("C", (), {"host": host})()
self.scope = {"type": "http", "client": (host, 0),
"headers": [(k.lower().encode(), v.encode())
for k, v in headers.items()]}
def test_cloudflare_header_wins():
# Cloudflare sets CF-Connecting-IP and overwrites any client-supplied
# value, so it is trustworthy in a way a parsed XFF chain is not.
assert client_key(_Req({"cf-connecting-ip": "203.0.113.7"})) == "203.0.113.7"
def test_forwarded_for_is_the_fallback_and_takes_the_first_entry():
# Left-most is the original client; everything after it is proxies.
assert client_key(
_Req({"x-forwarded-for": "203.0.113.7, 10.0.0.2"})) == "203.0.113.7"
def test_remote_address_is_the_last_resort():
assert client_key(_Req({}, host="10.0.0.9")) == "10.0.0.9"
def test_cloudflare_header_beats_forwarded_for():
key = client_key(_Req({"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "198.51.100.1"}))
assert key == "203.0.113.7"
def test_two_callers_get_two_buckets():
# The whole point: one user exhausting their limit must not refuse another.
a = client_key(_Req({"cf-connecting-ip": "203.0.113.7"}))
b = client_key(_Req({"cf-connecting-ip": "203.0.113.8"}))
assert a != b
def test_whitespace_is_stripped():
# "a, b" split on comma leaves a leading space on every entry but the
# first; an unstripped key silently creates a second bucket per client.
assert client_key(_Req({"x-forwarded-for": " 203.0.113.7 ,10.0.0.2"})) \
== "203.0.113.7"
# ---------------------------------------------------------------------------
# The ceiling that header rotation cannot raise.
# ---------------------------------------------------------------------------
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def api(monkeypatch):
from backend import app as app_module
from backend.config import settings
monkeypatch.setattr(settings, "global_rate_limit_per_minute", 5)
monkeypatch.setattr(app_module, "_global_window", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def _ceiling_req(path: str, host: str):
"""Enough of a Request for exempt_from_ceiling: a path and a peer host."""
return type("R", (), {
"url": type("U", (), {"path": path})(),
"client": type("C", (), {"host": host})(),
})()
def _get(client, path="/api/flags", cf=None):
headers = {"cf-connecting-ip": cf} if cf else {}
return client.get(path, headers=headers)
def test_rotating_the_cloudflare_header_cannot_buy_unlimited_requests(api):
"""The attack the per-client keying opened up.
client_key trusts CF-Connecting-IP, and nothing in this process can tell an
edge-set header from an attacker-set one — that distinction can only be
made at Cloudflare, with Authenticated Origin Pulls or an origin firewall.
A caller reaching the origin directly can therefore mint a fresh
rate-limit bucket per request and evade per-client limits entirely.
Per-client fairness is still the right default; this is the backstop that
bounds what evading it can achieve. Without it, correct keying would be a
net regression against abuse compared with the shared bucket it replaced.
"""
codes = [_get(api, cf=f"203.0.113.{i}").status_code for i in range(8)]
assert codes.count(200) == 5
assert codes.count(429) == 3
def test_the_ceiling_says_which_limit_was_hit(api):
# Distinguishable from slowapi's per-client 429, or an operator reading
# logs cannot tell "one noisy client" from "the origin is saturated".
for i in range(5):
_get(api, cf=f"203.0.113.{i}")
refused = _get(api, cf="203.0.113.99")
assert refused.status_code == 429
assert "capacity" in refused.json()["detail"].lower()
assert refused.headers.get("retry-after")
def test_traffic_below_the_ceiling_is_untouched(api):
codes = [_get(api, cf=f"203.0.113.{i}").status_code for i in range(5)]
assert codes == [200] * 5
def test_the_container_healthcheck_is_exempt(api):
"""The healthcheck runs `curl http://localhost:80/api/data-info` inside the
container. If the ceiling could starve it, saturation would fail the
healthcheck, restart the container, and turn a load spike into an outage
loop — the ceiling has to protect the origin, not kill it.
"""
from backend.app import exempt_from_ceiling
assert exempt_from_ceiling(_ceiling_req("/api/data-info", "127.0.0.1"))
assert exempt_from_ceiling(_ceiling_req("/api/data-info", "::1"))
# Everyone else is counted.
assert not exempt_from_ceiling(_ceiling_req("/api/data-info", "10.0.0.9"))
def test_the_ceiling_ignores_non_api_paths():
# Sitemaps and robots.txt are served by this app too, and a crawler
# fetching them must not be refused because the API is busy.
from backend.app import exempt_from_ceiling
assert exempt_from_ceiling(_ceiling_req("/sitemap.xml", "10.0.0.9"))
assert exempt_from_ceiling(_ceiling_req("/robots.txt", "10.0.0.9"))
+306
View File
@@ -0,0 +1,306 @@
"""Tests for sitemap generation (spec 2026-08-20, workstream W1).
The sitemap is built from the in-memory school DataFrame, so these inject a
small frame via monkeypatch rather than touching a database.
"""
import numpy as np
import pandas as pd
import pytest
def _schools_df() -> pd.DataFrame:
"""Two schools: one with results, one with neither results nor Ofsted."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Primary",
"year": 202425,
"ofsted_date": None,
}
return pd.DataFrame(
[
{**base, "urn": 100001, "school_name": "Alpha Primary",
"rwm_expected_pct": 62.0, "attainment_8_score": np.nan,
"ofsted_grade": 2.0},
{**base, "urn": 100002, "school_name": "Ghost Primary",
"rwm_expected_pct": np.nan, "attainment_8_score": np.nan,
"ofsted_grade": np.nan},
]
)
@pytest.fixture()
def sitemap(monkeypatch) -> str:
"""The sitemap index."""
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return app_module.build_sitemap()
@pytest.fixture()
def schools_child(monkeypatch) -> str:
"""The first school child sitemap, where school URLs actually live."""
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return app_module.build_sitemaps()["schools-1.xml"]
@pytest.fixture()
def static_child(monkeypatch) -> str:
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return app_module.build_sitemaps()["static.xml"]
def test_every_loc_uses_the_www_host(sitemaps):
# The apex 301s to www. A <loc> that redirects burns a crawl per URL.
# Checked across every file, index included, not just one.
#
# A child can legitimately be empty — this fixture holds two schools and no
# town clearing the threshold — so the presence check applies only to files
# that carry URLs. The absence check applies to all of them.
for name, xml in sitemaps.items():
assert "https://schoolcompare.co.uk" not in xml, name
if "<loc>" in xml:
assert "https://www.schoolcompare.co.uk" in xml, name
def test_school_with_results_is_listed(schools_child):
assert "/school/100001-alpha-primary" in schools_child
def test_school_with_no_results_and_no_ofsted_is_omitted(schools_child):
# Nothing for a search result to say about it. Submitting it spends crawl
# budget and drags the corpus-wide quality signal down.
#
# Asserted against the child, not the index: the index carries no school
# URLs at all, so it would pass this trivially and prove nothing.
assert "/school/100002" not in schools_child
def test_no_invented_priority_or_changefreq(sitemaps):
# Google ignores both. They were noise dressed as signal.
for name, xml in sitemaps.items():
assert "<priority>" not in xml, name
assert "<changefreq>" not in xml, name
def test_ofsted_date_becomes_lastmod(monkeypatch):
from backend import app as app_module
import datetime
def _df():
base = _schools_df()
base.loc[base["urn"] == 100001, "ofsted_date"] = datetime.date(2024, 3, 14)
return base
monkeypatch.setattr(app_module, "load_school_data", _df)
xml = app_module.build_sitemaps()["schools-1.xml"]
assert "<lastmod>2024-03-14</lastmod>" in xml
def test_no_lastmod_invented_when_date_unknown(monkeypatch):
# An always-now lastmod is a claim Google learns to distrust. Absent
# honestly means unknown.
from backend import app as app_module
def _df():
df = _schools_df()
df["ofsted_date"] = None
return df
monkeypatch.setattr(app_module, "load_school_data", _df)
# The child only. The index legitimately carries a lastmod, because there
# it means "when this sitemap file changed", which we do know.
xml = app_module.build_sitemaps()["schools-1.xml"]
assert "<lastmod>" not in xml
def test_static_routes_are_listed(static_child):
for path in ("/", "/rankings", "/compare", "/admissions"):
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static_child
@pytest.fixture()
def sitemaps(monkeypatch) -> dict:
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return app_module.build_sitemaps()
def test_index_lists_each_child(sitemaps):
index = sitemaps["sitemap.xml"]
assert "<sitemapindex" in index
assert "https://www.schoolcompare.co.uk/sitemaps/static.xml" in index
assert "https://www.schoolcompare.co.uk/sitemaps/schools-1.xml" in index
def test_index_carries_no_url_elements(sitemaps):
# A sitemap index holds <sitemap> entries only; mixing in <url> is invalid.
assert "<url>" not in sitemaps["sitemap.xml"]
def test_index_does_not_list_itself(sitemaps):
assert "<loc>https://www.schoolcompare.co.uk/sitemap.xml</loc>" not in sitemaps["sitemap.xml"]
def test_static_child_holds_the_static_routes(sitemaps):
static = sitemaps["static.xml"]
for path in ("/", "/rankings", "/compare", "/admissions"):
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static
def test_school_child_holds_the_schools(sitemaps):
assert "/school/100001-alpha-primary" in sitemaps["schools-1.xml"]
def test_children_are_chunked_under_the_limit(monkeypatch):
# Sitemaps cap at 50,000 URLs per file. Chunk at 10,000 so a child stays
# small enough to eyeball in Search Console.
from backend import app as app_module
import pandas as _pd
rows = [
{"urn": 200000 + i, "school_name": f"School {i}", "year": 202425,
"rwm_expected_pct": 60.0, "attainment_8_score": None,
"ofsted_grade": 2.0, "ofsted_date": None}
for i in range(10_001)
]
monkeypatch.setattr(app_module, "load_school_data", lambda: _pd.DataFrame(rows))
maps = app_module.build_sitemaps()
assert maps["schools-1.xml"].count("<url>") == 10_000
assert maps["schools-2.xml"].count("<url>") == 1
def test_build_sitemap_still_returns_the_index(sitemap):
# lifespan and the admin endpoint call build_sitemap(); keep it working.
assert "<sitemapindex" in sitemap
def test_school_with_results_in_an_earlier_year_is_still_listed(monkeypatch):
"""Regression: The Mallard Academy (150367).
Real KS2 results 2015-16 to 2018-19, then null rows from 2022-23 onward
because the school stopped reporting. The first cut tested the latest
year's row alone and dropped it, along with ~220 others, even though its
detail page shows all four years of results.
"""
from backend import app as app_module
import pandas as _pd
base = {"local_authority": "Testshire", "school_type": "Academy",
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
"attainment_8_score": np.nan, "urn": 150367,
"school_name": "Mallard Academy"}
df = _pd.DataFrame([
{**base, "year": 201819, "rwm_expected_pct": 67.0},
{**base, "year": 202324, "rwm_expected_pct": np.nan},
{**base, "year": 202425, "rwm_expected_pct": np.nan},
])
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
xml = app_module.build_sitemaps()["schools-1.xml"]
assert "/school/150367-mallard-academy" in xml
def test_school_with_no_results_in_any_year_is_still_omitted(monkeypatch):
"""The fix must not turn into "list everything"."""
from backend import app as app_module
import pandas as _pd
base = {"local_authority": "Testshire", "school_type": "Academy",
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
"attainment_8_score": np.nan, "rwm_expected_pct": np.nan,
"urn": 100002, "school_name": "Ghost Primary"}
df = _pd.DataFrame([{**base, "year": y} for y in (202324, 202425)])
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
assert "/school/100002" not in app_module.build_sitemaps()["schools-1.xml"]
def _places_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy",
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
"ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA",
}
return pd.DataFrame([
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
"rwm_expected_pct": 60.0}
for i in range(6)
])
@pytest.fixture()
def place_sitemaps(monkeypatch) -> dict:
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _places_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return app_module.build_sitemaps()
def test_place_children_are_listed_in_the_index(place_sitemaps):
index = place_sitemaps["sitemap.xml"]
assert "/sitemaps/places-1.xml" in index
assert "/sitemaps/outcodes-1.xml" in index
def test_town_and_authority_urls_use_their_own_namespaces(place_sitemaps):
xml = place_sitemaps["places-1.xml"]
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood</loc>" in xml
assert "<loc>https://www.schoolcompare.co.uk/schools/authority/essex</loc>" in xml
def test_outcode_urls_live_in_their_own_child(place_sitemaps):
assert "/schools/near/cm13" in place_sitemaps["outcodes-1.xml"]
assert "/schools/near/cm13" not in place_sitemaps["places-1.xml"]
def test_place_urls_carry_no_priority_or_changefreq(place_sitemaps):
for name in ("places-1.xml", "outcodes-1.xml"):
assert "<priority>" not in place_sitemaps[name]
assert "<changefreq>" not in place_sitemaps[name]
def test_phase_variants_are_submitted_where_the_phase_clears_the_threshold(place_sitemaps):
# "primary schools in beccles" is the query shape the baseline showed, so
# each variant is its own page and has to be submitted. Emitting only the
# bare place URL left ~950 of them reachable by nothing.
xml = place_sitemaps["places-1.xml"]
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood/primary</loc>" in xml
def test_a_phase_below_its_own_threshold_is_not_submitted(place_sitemaps):
# The fixture is six primaries and no secondaries.
xml = place_sitemaps["places-1.xml"]
assert "/schools/brentwood/secondary" not in xml
def test_outcodes_get_no_phase_variants(place_sitemaps):
# Nobody searches "primary schools in CM13"; the routes do not exist.
xml = place_sitemaps["outcodes-1.xml"]
assert "/primary" not in xml and "/secondary" not in xml
def test_authority_phase_variants_are_submitted_in_their_own_namespace(place_sitemaps):
"""302 of these were already in the sitemap, and every one 404'd.
The spec gives authorities a phase route; the plan built the bare
authority route and dropped it. Nothing noticed because the sitemap was
written from the registry, which was right, while the routes were written
by hand. This test fails if the URL ever leaves the sitemap; the e2e
journey fails if the route ever leaves the app.
"""
xml = place_sitemaps["places-1.xml"]
assert ("<loc>https://www.schoolcompare.co.uk"
"/schools/authority/essex/primary</loc>") in xml
# And never in the town namespace, which is a different set of schools.
assert "/schools/essex/primary" not in xml
+157
View File
@@ -0,0 +1,157 @@
"""Tests for school autosuggest (spec 2026-08-26)."""
from backend import data_loader
class _FakeDocs:
def __init__(self, hits, explode=False):
self._hits = hits
self._explode = explode
self.last_params = None
def search(self, params):
self.last_params = params
if self._explode:
raise RuntimeError("typesense is down")
return {"hits": [{"document": d} for d in self._hits]}
class _FakeClient:
def __init__(self, hits, explode=False):
self.docs = _FakeDocs(hits, explode)
self.collections = {"schools": type("C", (), {"documents": self.docs})()}
_HIT = {
"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school",
}
def _use(monkeypatch, client):
monkeypatch.setattr(data_loader, "_get_typesense_client", lambda: client)
def test_returns_the_fields_a_suggestion_needs(monkeypatch):
# Local authority is not decoration: there are many schools called
# "St Mary's", and a list without it cannot be chosen between.
_use(monkeypatch, _FakeClient([_HIT]))
out = data_loader.suggest_schools_typesense("breck")
assert out == [{
"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school",
}]
def test_a_missing_optional_field_becomes_an_empty_string(monkeypatch):
# phase and school_type are optional in the Typesense schema. A missing
# key must not KeyError in the keystroke path.
_use(monkeypatch, _FakeClient([{"urn": 1, "school_name": "X",
"local_authority": "Y", "postcode": "Z"}]))
out = data_loader.suggest_schools_typesense("x")
assert out[0]["phase"] == "" and out[0]["school_type"] == ""
def test_typesense_unavailable_gives_no_suggestions_rather_than_raising(monkeypatch):
_use(monkeypatch, None)
assert data_loader.suggest_schools_typesense("anything") == []
def test_a_typesense_error_gives_no_suggestions_rather_than_raising(monkeypatch):
_use(monkeypatch, _FakeClient([], explode=True))
assert data_loader.suggest_schools_typesense("anything") == []
def test_the_limit_is_passed_through_and_clamped(monkeypatch):
client = _FakeClient([])
_use(monkeypatch, client)
data_loader.suggest_schools_typesense("x", limit=500)
assert client.docs.last_params["per_page"] == 20
def _client(monkeypatch, rows, *, blow_up_dataframe=False):
from fastapi.testclient import TestClient
from backend import app as app_module
monkeypatch.setattr(app_module, "suggest_schools_typesense",
lambda q, limit=8: rows)
if blow_up_dataframe:
def _boom():
raise AssertionError("the suggest path must not load the DataFrame")
monkeypatch.setattr(app_module, "load_school_data", _boom)
monkeypatch.setattr(app_module, "load_latest_school_data", _boom)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_endpoint_returns_suggestions(monkeypatch):
body = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck").json()
assert body["suggestions"][0]["school_name"] == "Brecknock Primary School"
def test_the_endpoint_never_touches_the_dataframe(monkeypatch):
"""The whole reason this is not a mode of /api/schools.
That endpoint filters and sorts 25,000 rows of pandas per query, holding
the GIL. Per keystroke, that is the cost this endpoint exists to avoid.
"""
res = _client(monkeypatch, [_HIT], blow_up_dataframe=True).get("/api/suggest?q=breck")
assert res.status_code == 200
assert res.json()["suggestions"]
def test_a_one_character_query_returns_nothing_and_does_not_error(monkeypatch):
# The keystroke path never errors on ordinary input.
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=b")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_a_blank_query_returns_nothing_and_does_not_error(monkeypatch):
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_typesense_down_is_an_empty_list_not_a_500(monkeypatch):
res = _client(monkeypatch, []).get("/api/suggest?q=breck")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_the_response_is_cacheable(monkeypatch):
# Prefix queries repeat enormously across users, and school names change
# once a year. Without this the endpoint pays full price every keystroke.
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck")
assert "s-maxage" in res.headers.get("cache-control", "")
assert res.headers.get("etag")
def test_a_malformed_urn_does_not_raise(monkeypatch):
"""The docstring promises "never raises"; the parsing loop sat outside the
try, so int(None) or int("abc") would have turned a keystroke into a 500.
Typesense declares urn as int32, so this should be unreachable — but the
contract is what the caller relies on, and a search index is a separate
system that can be reindexed by something other than this code.
"""
_use(monkeypatch, _FakeClient([{"urn": None, "school_name": "X",
"local_authority": "Y", "postcode": "Z"}]))
assert data_loader.suggest_schools_typesense("x") == []
def test_a_malformed_row_does_not_discard_the_good_ones(monkeypatch):
# One bad document must not blank the whole dropdown.
_use(monkeypatch, _FakeClient([
{"urn": "not-a-number", "school_name": "Bad", "local_authority": "Y",
"postcode": "Z"},
_HIT,
]))
out = data_loader.suggest_schools_typesense("x")
assert [r["urn"] for r in out] == [100010]
def test_a_hit_with_no_document_does_not_raise(monkeypatch):
_use(monkeypatch, _FakeClient([{}]))
assert data_loader.suggest_schools_typesense("x") == []
+70 -5
View File
@@ -8,8 +8,31 @@ from backend import data_loader
from backend.data_loader import get_supplementary_data_batch
def _sort_key(criterion):
"""(column name, descending) for a SQLAlchemy order_by argument.
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
.element.
"""
name = getattr(criterion, "key", None)
if name is not None:
return name, False
element = getattr(criterion, "element", None)
name = getattr(element, "key", None)
return name, "DESC" in str(criterion).upper()
class _FakeQuery:
"""Records that a query ran and serves canned rows filtered by an in-list."""
"""Records that a query ran and serves canned rows filtered by an in-list.
order_by is honoured rather than ignored. The batch loader picks a row per
URN by position — first for "latest Ofsted", last for "latest cut-off
distance" — which is only correct because the database returned them
sorted. A double that drops the ORDER BY makes those picks depend on
fixture insertion order instead, so the test would pass with the sort
reversed or removed and prove nothing about the query.
"""
def __init__(self, recorder, model_name, rows):
self._rec = recorder
@@ -19,7 +42,20 @@ class _FakeQuery:
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
def order_by(self, *criteria):
for crit in reversed(criteria): # reversed = stable multi-key sort
name, descending = _sort_key(crit)
if not name:
continue
values = [getattr(r, name, None) for r in self._rows]
# Only sort on plainly comparable values. Some fixtures stand dates
# up as namespace objects, which raise on <; leaving those in their
# given order matches what the real query would produce for them.
if not all(isinstance(v, (int, float, str)) for v in values):
continue
self._rows = sorted(
self._rows, key=lambda r: getattr(r, name), reverse=descending
)
return self
def all(self):
@@ -69,6 +105,13 @@ def _adm_row(urn, year):
)
def _dist_row(urn, year, distance_m, route_count=1):
return types.SimpleNamespace(
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
)
def test_one_query_per_table_and_latest_row_per_urn():
rows = {
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
@@ -78,6 +121,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
_ofsted_row(2, "2021-06-01", 1),
],
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
# URN 1 has three years of cut-offs; only the most recent is served.
# Deliberately not in year order — the ordering is the query's job.
"FactAdmissionDistance": [
_dist_row(1, 2026, 529.47),
_dist_row(1, 2024, 772.49),
_dist_row(1, 2025, 1421.05),
_dist_row(2, 2023, 2029.38, route_count=4),
],
"FactPupilCharacteristics": [],
"FactDeprivation": [],
"FactFinance": [],
@@ -85,10 +136,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
session = _FakeSession(rows)
out = get_supplementary_data_batch(session, [1, 2])
# Exactly one query per table — five total, regardless of two URNs.
# Exactly one query per table — six total, regardless of two URNs.
assert sorted(session.queries) == [
"FactAdmissions", "FactDeprivation", "FactFinance",
"FactOfstedInspection", "FactPupilCharacteristics",
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
]
# Latest Ofsted kept per URN
@@ -100,6 +151,18 @@ def test_one_query_per_table_and_latest_row_per_urn():
assert out[1]["admissions"]["year"] == 202627
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
# Cut-off distance: the latest year only. Earlier years stay in the mart
# but are held back as a paid feature, and this API is public — serving
# them here would hand them to anyone reading the response. The fixture
# rows are deliberately out of order, so "latest" only comes out right if
# the query's ORDER BY is doing the work.
assert out[1]["admission_distance"]["year"] == 2026
assert "admission_distance_history" not in out[1]
assert out[1]["admission_distance"]["distance_m"] == 529.47
# route_count travels with the figure — the page needs it to say the
# distance is the furthest of several bands rather than the only one.
assert out[2]["admission_distance"]["route_count"] == 4
# Empty tables degrade to the null block, not a crash
assert out[1]["census"] is None and out[1]["deprivation"] is None
@@ -109,3 +172,5 @@ def test_single_wrapper_matches_batch(monkeypatch):
single = data_loader.get_supplementary_data(session, 5)
assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == []
assert single["admission_distance"] is None
assert "admission_distance_history" not in single
+9
View File
@@ -16,6 +16,8 @@
# ADMIN_API_KEY — Backend admin API key
# TYPESENSE_API_KEY — Typesense admin API key
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
# UNLEASH_API_TOKEN — Unleash *client* token, environment: development
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
@@ -55,6 +57,12 @@ services:
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
# Unset means every feature flag is False — the correct dark state for an
# environment with no Unleash, not a failure.
UNLEASH_URL: ${UNLEASH_URL:-}
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
volumes:
- unleash_cache:/app/.unleash
depends_on:
sc_database:
condition: service_healthy
@@ -212,3 +220,4 @@ volumes:
postgres_data:
typesense_data:
airflow_logs:
unleash_cache:
+73
View File
@@ -0,0 +1,73 @@
# Portainer Stack Definition for School Compare — UNLEASH (feature flags)
#
# Deploy as a *separate* Portainer stack ("schoolcompare-unleash"), alongside
# the production and staging stacks. It deliberately belongs to neither: a
# staging redeploy must not be able to disturb production's flag state, and a
# production redeploy must not disturb staging's.
#
# One instance serves both environments. Open-source Unleash ships with
# `development` and `production` environments and environment-scoped client
# tokens, so the same flag holds independent state in each — which is what
# lets a feature be on in staging, where the E2E journeys exercise it, while
# production stays dark.
#
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
# UNLEASH_DB_PASSWORD — PostgreSQL password for the Unleash database
# UNLEASH_ADMIN_PASSWORD — initial admin password for the Unleash UI
# UNLEASH_IP — macvlan IP for the Unleash server (default 10.0.1.152)
services:
# ── PostgreSQL (Unleash's own; nothing else uses it) ──────────────────
unleash_db:
container_name: sc_unleash_postgres
image: postgres:16-alpine
environment:
POSTGRES_USER: unleash
POSTGRES_PASSWORD: ${UNLEASH_DB_PASSWORD}
POSTGRES_DB: unleash
volumes:
- unleash_postgres_data:/var/lib/postgresql/data
networks:
- unleash
healthcheck:
test: ["CMD-SHELL", "pg_isready -U unleash"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
# ── Unleash server (UI + client API on 4242) ──────────────────────────
unleash:
container_name: sc_unleash
image: unleashorg/unleash-server:6
environment:
DATABASE_URL: postgres://unleash:${UNLEASH_DB_PASSWORD}@unleash_db:5432/unleash
DATABASE_SSL: "false"
INIT_ADMIN_API_TOKENS: ""
UNLEASH_DEFAULT_ADMIN_PASSWORD: ${UNLEASH_ADMIN_PASSWORD}
depends_on:
unleash_db:
condition: service_healthy
networks:
unleash: {}
macvlan:
ipv4_address: ${UNLEASH_IP:-10.0.1.152}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
networks:
unleash:
driver: bridge
macvlan:
external:
name: macvlan
volumes:
unleash_postgres_data:
+9
View File
@@ -7,6 +7,8 @@
# ADMIN_API_KEY — Backend admin API key
# TYPESENSE_API_KEY — Typesense admin API key
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
# UNLEASH_API_TOKEN — Unleash *client* token, environment: production
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
services:
@@ -44,6 +46,12 @@ services:
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
# Unset means every feature flag is False — the correct dark state for an
# environment with no Unleash, not a failure.
UNLEASH_URL: ${UNLEASH_URL:-}
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
volumes:
- unleash_cache:/app/.unleash
depends_on:
sc_database:
condition: service_healthy
@@ -201,3 +209,4 @@ volumes:
postgres_data:
typesense_data:
airflow_logs:
unleash_cache:
+4
View File
@@ -36,6 +36,10 @@ services:
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
# Unset means every feature flag is False — the correct dark state for an
# environment with no Unleash, not a failure.
UNLEASH_URL: ${UNLEASH_URL:-}
UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}
volumes:
- ./data:/app/data:ro
depends_on:
+100
View File
@@ -150,3 +150,103 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge.
## Rate limiting, and the Cloudflare gap
Two independent limits protect the API:
- **Per client**, via slowapi, keyed on `CF-Connecting-IP` (falling back to
`X-Forwarded-For`, then the peer address). 60/minute by default;
`/api/suggest` gets 120/minute because typing is bursty.
- **Globally**, via `GlobalRateLimitMiddleware`: a fixed 60-second window over
all `/api/` traffic, `GLOBAL_RATE_LIMIT_PER_MINUTE` (default 3000),
independent of any client identity. Requests from `127.0.0.1` are exempt so
the container healthcheck cannot be starved into a restart loop.
### Open: the origin must only accept Cloudflare
`CF-Connecting-IP` is only meaningful for requests that actually reached the
origin through Cloudflare, and **the application cannot verify that they did**.
Anything able to reach the origin directly can set that header freely and, by
rotating it, mint a fresh rate-limit bucket per request — defeating per-client
limits on every endpoint.
The global ceiling bounds the damage to total origin capacity. It does not fix
the underlying gap, and nothing in the code can. Closing it needs one of:
- **Authenticated Origin Pulls** — Cloudflare presents a client certificate the
origin requires, so non-Cloudflare traffic is refused at TLS.
- **An origin firewall** restricted to Cloudflare's published IP ranges.
Until one is in place, treat per-client limits as protection against accidents
and ordinary load, not against a determined caller.
## Feature flags (Unleash)
Flag state lives in a self-hosted Unleash instance, deployed as its own
Portainer stack from `docker-compose.portainer.unleash.yml`. It is separate
from the application stacks on purpose — redeploying staging must not be able
to disturb production's flags.
The flags themselves are declared in `backend/flags.py`. Unleash holds the
state; the registry holds the list. A flag in the UI that is not in the
registry is orphaned and nothing reads it.
### First-time setup
1. Deploy the stack in Portainer. Set `UNLEASH_DB_PASSWORD`,
`UNLEASH_ADMIN_PASSWORD` and (optionally) `UNLEASH_IP`.
2. Log in to the UI at `http://<UNLEASH_IP>:4242` as `admin`.
3. Create one **client** API token per environment:
- `schoolcompare-staging`, environment **development**
- `schoolcompare-prod`, environment **production**
Client tokens, not admin tokens — the backend only reads.
4. Put each token in the matching Portainer stack's `UNLEASH_API_TOKEN`
variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
5. Redeploy the application stacks.
### Adding a flag to Unleash
**Unleash does not create flags by itself.** The SDK reads definitions from the
server and never registers anything, and metrics for a flag the server has
never heard of are discarded. So a flag declared in `backend/flags.py` will be
evaluated on every request, stay `False` forever, and never appear in the UI
until someone creates it there by hand.
For each flag in the registry, create one in Unleash with:
- **Name** — character for character what `backend/flags.py` declares.
snake_case, no hyphens or spaces. A typo produces a flag that looks correct
in the UI and is read by nothing.
- **Type** — Release. No strategies, constraints or variants: these are plain
on/off switches, by design.
### Turning a feature on
Toggle the flag in the environment matching the stack you mean: **development**
for staging, **production** for prod. The token in each stack is scoped to one
environment, so toggling the other one has no visible effect.
The SDK refreshes every 15 seconds, so the API reflects the change almost at
once; the pages follow on their own schedule, below.
A flip reaches school pages within about five minutes and place pages within
the hour. Next's ISR does the propagating — it revalidates a route at the
*lowest* `revalidate` among that route's fetches, which is 300s for
`/school/[slug]` and 3600s for the place pages. There is no webhook, and
adding one would only be worth it if flips ever needed to be instant.
### When Unleash is unreachable
Every flag evaluates to `False` and the site serves as though nothing were
switched on. That is deliberate — an unfinished feature staying hidden is the
safe direction — but it means a *released* feature disappears if a backend
container cold-starts with an empty cache while Unleash is down. The SDK's
disk cache is on a named volume so restarts keep last-known state, and flags
are removed from the code within 90 days (enforced by a test), which bounds
how long any feature is exposed to this.
If `UNLEASH_URL` is unset, every flag is `False` and no connection is
attempted. That is the correct behaviour for local development and CI, and it
means the test suites need no flag server.
+85
View File
@@ -0,0 +1,85 @@
# Remote branch cleanup, 2026-08-20
# Restore any branch with: git push origin <sha>:refs/heads/<name>
## Deleted: fully merged into main (content is in main)
75677f4252b759ef895e7d5f7c19f8f1745bdb59 add-contact-form-footer
fa1abff642683dfd26ba88a295a0a6710d77147a chore/byline-removal-and-audit-figure
95081d38bdf87764ef5d298676c25fae4cd3b792 chore/remove-parent-view
6877abedebfc1c7d95f1f6ebe68945c62be528ab chore/staged-prod-promotion
090d5f7bec824e083d3252e2c6e636686016304d ci/frontend-checks-speedup
8c3a5cc4e9f551f0190d85357ce7741ad87f3a4c design/cohort-identity
955659580067ca8b81bd77e31e4e2554103f094d feat/allow-analytics-iframe-embed
6828f6cd4417284ea3eb6f088fa20945b8b40ed3 feat/compare-chips-two-per-row
d5cd0abfee226885119665da5d2aa8288b59217f feat/compare-data-foundation
6dd9b04b50bee146682da87efad8fc8b526251c5 feat/compare-frontend-rebuild
96d5fcf5b07b6f175b48e9b20fcb765a320a907f feat/detail-header-details-reveal
f1388ff5bd0af1409823a1e047b7ba84246a0f70 feat/gias-sixth-form-flag
eddf74745f86c9c6d9eb07d867246ff9cc90dc20 feat/hero-artwork-v2
3015c37bac6dc28db58c80fdb9235942c25b83d7 feat/hero-byline
8e763e39d17a4964cf558e51c03f044186371f6d feat/info-popover-tooltip
88c653215d520ab6e902c9de55bf27d86eeb90c3 feat/last-distance-offered
a72323874f7aebdb2e64b6d64a5febd61152d09d feat/last-distance-offered-full
c9a1892bfb0370e0672e5849cba294ddfabe3c65 feat/latest-cutoff-only
4e8df006d75d8be2a1d8529ddf855c445854cba1 feat/near-me-by-search
45ab479062c6a1639facad636fc0cc0cf0fd9155 feat/proposed-to-close-schools
3bf2e8f262cbe058fda6de6f8ea3e050224a51b9 feat/school-detail-visualisations
1f80571b1ff217dc92b660a936c02b5f49d07f0b feat/umami-heatmap-recorder
609bb923d96aa5730131463ea35d5efdd404bf96 feature/ingest-independent-schools
94151c58ea38a9256d66d15161293486a500c7a2 fix/admissions-section-height
79246edc22961c2beb3520437e9d064d3b809d10 fix/annual-dag-ks4-national-selector
59ac9c10b97e0ef1143f57fea06b324e72ac3d4a fix/chart-marker-contrast
9f8dba227c95706ca3527bd48d381e7622cc0a5e fix/compare-chart-refetch-resilience
e74d3882ce78a141fa1a57daa3102d7a58852dc3 fix/compare-expert-fixes
80176cac4db4820e76ea2a156c7a2974bee2f203 fix/compare-final-review-mustfix
f579630fab6c456e26a6984a3e8eebdbe3184202 fix/compare-mockup-drift
dc85254ad2ddf134b4434065d4762cef374d2220 fix/compare-null-year-blanks-chart
43a2c4a6bc539b621f31655aec05ef319a25f343 fix/compare-refresh-and-fetch
d677b5453365b72c81d6df2de62b1fa0d05d684d fix/daily-dag-cache-invalidation
f6bb037c471553e8195b5a8b147467ce0d07a688 fix/detail-all-through
17bd4d5a5eb0b12ca79b97db587f14f7d671e85b fix/detail-chart-truthfulness
4e6be0ce65647410b4ff74f8b763207c28920c26 fix/detail-inclusion-admissions
fdda52ff0af3fae03a4b059a655973cdbb269f91 fix/detail-ofsted-correctness
e36125b24aba254a8d15c5c33b4d2a296e691995 fix/detail-provenance-anchoring
b31e71ac884df6507f567adc046c9fc52d9d310a fix/detail-report-card-render-date
32f8a02862be6a1d49f4c3928b17fcf15d4c94cc fix/detail-trend-chart-taller
e65688d600a86818fe21ae4c61ba27e5b6ec8d7c fix/e2e-brand-assertions
3adea73ee04cdedfab54b0351878b297f72756ad fix/e2e-compare-chips-phase
06e4898c30feaedc471f97aba28ddb0d61379f4d fix/e2e-compare-samephase
9abd020967670a855e80fe5a908c8048a3aa9f14 fix/e2e-distance-locator
acec8135e1ec7c9c3c255e5b23733a0ef862b590 fix/e2e-rankings-year-pick
5944d88f0b1517ef1ef56af1b62270d2cf28e717 fix/expert-signoff-mustfixes
2433101fa08be5df6f170d41790512ffe823d33e fix/font-cascade-and-map-palette
74ca76d150deec6725259d9637ea86d7bb90c683 fix/gias-legacy-fallback
bdaa05cd542f563ef74c45307cd8f7fc465193c9 fix/hero-fallback-and-sharp
d52d384cf23d282b44e9251176f8f3402d808600 fix/hero-map-ios-fullscreen
4043270a77bbe4edb18207fa5f1d94d4747fe12f fix/hero-mobile-and-wording
22e9eb2d48b0d6623e88fd67cb6ca8e5e4074583 fix/homepage-education-accuracy
8d50afef1e8a2b621b7344eadf475b0d609ad7a2 fix/leaflet-specificity-and-font-assertion
b2b2cad5acf534ae7a667d3fb2be15efff37c4a7 fix/list-map-report-card-signal
dc21e80a5e9eebd13aaab84735642eb77cef35e6 fix/mobile-cell-name-size
e5f7f4c959f024c073472122d858333a0f24866c fix/mobile-compare-polish
a00cbe916182d1e04661c750a1ce2e7ad27a68ae fix/mobile-sort-select-overflow
2fd997bfe640c419a6713e85df008463de7f56e8 fix/modal-keyboard-viewport
3e7705756776a0c44d27966dfc972023f1f38b69 fix/ofsted-link-text
ce422e64363e2b03c186ef316832b6de15502d67 fix/promote-status-token
4522cbf64560db1e1cd519e119aa466b42cd16a1 fix/proposed-to-close-copy
15da060e4af37fbae919e0edf25f108266de2585 fix/rankings-admissions-accuracy
6c872ce726f210433354ca38dc5314bc6a467534 fix/rankings-year-validation
1c1df7796194af3d47f8e5ac0a0fbe6f323700e7 fix/report-card-chip-alignment
b2dc4d0779ced02709429afaa5985acf4abda794 fix/results-map-ios-fullscreen
95a5783da1fc994df76cb97238b55596dce4cd8f fix/runtime-api-proxy
8a9ba30cc24e29653a72c03c0e817684b7db7c07 fix/sats-per-level-national
536832a524fc4f9ed858f047e00b9a4d9d429c46 fix/school-detail-nan-500
fef83b3bf244a9bf3cb4afa75dbc433d8725014f fix/schoolbar-sticky-offset
b0c5b6bb57c879477da12c37b15cff950c29ebd3 fix/secondary-anchors-button-affordance
261403bcd2b01aa4f26ee212e26302fc0f769bf9 fix/special-note-full-width
ea5249a2ea6faf6bfa5a1522644387ba59770ec8 fix/standardise-distance-units
e4565e9f158721d4df6b918f2b065de82851f8d9 fix/trends-chart-height
3aad5101a842539105022f9059e85c224fc5973a fix/welsh-establishment-leak
315f1feede70bdf3101d2fdd305b3d06e037fdac perf/batch-supplementary
d2dc78aeb599e16b7ef5019be2b360b08df463bc perf/compare-loading
e098ad4bd1130152705788d4773837b7d1e7112e perf/server-client-split
## Deleted: superseded by PR #110 (content preserved on feat/seo-crawl-hygiene-main)
786ec80dd4de4a3cb674a89e35b3b5e461639289 feat/seo-crawl-hygiene
a5ac0bcd1b37bc10dcbce88f8601d01bf7b3eaaf feat/england-only-corpus
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -19,7 +19,7 @@
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
@@ -57,7 +57,7 @@
- Severity guess: P2
- **F6. Mobile hero omits the value proposition shown on desktop**
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
@@ -0,0 +1,309 @@
# SEO Programme — Design
Date: 2026-08-20
Status: awaiting review
## Problem
schoolcompare ranks second for "school compare" — an exact match for the
brand and the domain. It ranks poorly for "compare schools", "school
comparison" and "schools near me". The first is a naming artefact and
transfers to nothing; the rest are the queries that actually carry parent
demand.
The cause is structural, not editorial. The site publishes five route
families:
/ /compare /rankings /admissions /school/[slug]
Location intent has no landing page at all. Every competitor outranking us
on those queries wins with programmatic location pages:
| Competitor | URL pattern |
|---------------|--------------------------------------------|
| School Guide | `/best-schools-in/manchester` |
| Locrating | `/the-best-primary-schools-in-Manchester_…`|
| FindMySchool | `/best-primary-schools/manchester` |
| Snobe | `/best-primary-schools/manchester` |
| School Atlas | `/guides/best-primary-schools-manchester` |
"Schools near me" is a local-intent query. Google resolves it against the
user's coordinates and serves pages that are *about a place*. A national
homepage cannot win it. No title or description change fixes this; only
pages Google can localise will.
## Baseline (measured 2026-08-20, production API)
| Measure | Value |
|--------------------------------------------|---------|
| Unique schools | 27,229 |
| URLs in sitemap.xml | 27,232 |
| Schools with 2024/25 performance data | 21,266 |
| Schools with **no** current performance data| ~5,963 (22%) |
| Welsh establishments (all metrics null) | 1,569 |
| Overseas / offshore establishments | 467 |
| Static URLs in sitemap | 3 |
| Routes setting a canonical | 1 of 5 |
Three findings from that table drive the plan.
**We submit ~6,000 thin pages to Google.** `build_sitemap()`
(`backend/app.py:73`) enumerates every URN regardless of whether the school
has any data. Welsh establishments return `school_type: "Welsh
establishment"` with every performance metric, Ofsted grade and phase field
null. Overseas and offshore establishments ("BFPO Overseas Establishments",
"Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are
in the local-authority list too. At 22% of the submitted corpus this is a
site-wide quality signal problem and a crawl-budget waste, not a rounding
error.
W1 item 4 removes 2,036 of those — every non-England establishment — taking
the corpus to 25,193. The 3,927 that remain are English schools with no
current data: mostly newly opened, special, nursery or alternative provision.
Those are a template problem, not a corpus problem, and item 5 handles them
separately.
**The homepage is its own competitor.** `app/page.tsx` accepts eleven search
params (`search`, `local_authority`, `school_type`, `phase`, `page`,
`postcode`, `radius`, `sort`, `gender`, `admissions_policy`,
`has_sixth_form`) and sets no canonical. Every filter combination is a
crawlable near-duplicate of the single page we are asking to rank for
"compare schools".
**School pages are near-orphans.** Reachable from the sitemap and from site
search, but almost nothing links to them contextually, so they accrue no
internal authority.
Also noted: the sitemap emits invented `priority` values and no `lastmod`.
Google ignores `priority` and `changefreq` entirely; `lastmod` is the field
it does read, and we omit it.
## Keyword clusters
Ranked by judgement of UK parent search behaviour and by the competitive
SERP evidence above. Google Search Console is connected, so cluster
priorities are to be re-derived from measured impressions before build
starts (see Workstream 0).
**C1 — Head "compare" terms.** compare schools · school comparison · school
comparison tool · compare school performance · compare primary schools ·
compare secondary schools · compare two schools
**C2 — League tables and rankings.** primary school league tables ·
secondary school league tables · school league tables 2026 · SATs results by
school · GCSE results by school · KS2 league tables · Progress 8 rankings ·
best primary schools in [town] · top 10 primary schools in [LA]
**C3 — Local / near me.** schools near me · primary schools near me ·
secondary schools near me · best schools near me · good schools near me ·
schools in [town] · primary schools in [LA] · schools near [postcode] ·
[postcode] school catchment
**C4 — Individual school long tail.** [school] ofsted · [school] SATs
results · [school] catchment area · [school] reviews · [school] URN
**C5 — Admissions.** primary school admissions 2027 · national offer day
2027 · school application deadline · school admissions appeal ·
oversubscription criteria · distance criteria school admissions · didn't get
first choice school · school admissions [LA]
**C6 — Metric explainers.** what is a good SATs score · what is Progress 8 ·
what is Attainment 8 · expected standard KS2 meaning · scaled score
explained · Ofsted grades explained · Ofsted report cards · pupil premium
explained
**C7 — Head to head.** [school A] vs [school B] · academy vs community
school · grammar school vs comprehensive · faith school vs community school
## Workstreams
### W0 — Measure before touching anything
Export a Google Search Console baseline: impressions, clicks, average
position and CTR by query and by page, for the trailing 16 months. Bucket
queries into C1–C7. This sets the counterfactual — without it, no later
claim about lift is defensible, because school-search traffic is strongly
seasonal (results day in December, offer day in March/April).
Re-rank C1–C7 against measured impressions and adjust the sequence below if
the data disagrees with the judgement calls.
### W1 — Crawl hygiene and index sanity
Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a
corpus that is 22% thin would compound the existing problem.
1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions`
currently set none.
2. The homepage canonicalises to `/` regardless of search params.
3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter
space with no standalone value.
4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the
service/overseas schools are removed from the corpus at the mart boundary,
not hidden at the view layer. `dim_school` and `dim_location` both exclude
`TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service
children's education, Welsh establishment, British schools overseas —
listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`.
That removes 2,036 establishments and 29 local authorities, and because
`build_sitemap()` reads the same marts, it drops them from the sitemap in
the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS
refresh reintroduces them or if the two models drift apart.
5. Prune the remaining thin pages: exclude any school with no performance data
**and** no Ofsted record. Distinct from item 4 — these are English schools
with nothing yet to show, so the fix may be a better template rather than
removal.
6. Rebuild the sitemap as a sitemap **index**: one child per page family,
real `lastmod` from the data-load timestamp, `priority` and `changefreq`
dropped.
### W2 — The location layer
The dominant lever. `dim_location` already carries `town`, `county`,
`local_authority_name`, `parliamentary_constituency`, `latitude`,
`longitude` and `postcode`, so no new ingestion is required.
Routes:
/schools/[la] e.g. /schools/manchester
/schools/[la]/primary
/schools/[la]/secondary
/best-primary-schools/[town]
/best-secondary-schools/[town]
/schools/near/[outcode] e.g. /schools/near/m20
/schools/near-me geolocating hub
**Thin-page threshold: generate a town or outcode page only where at least
five schools have current performance data.** Below that, 301 to the parent
LA page. This is the single most important constraint in the workstream —
it is what separates a location layer from index bloat.
Each page must earn its place with content a parent would actually use, not
a template shell:
- H1 matching the query intent ("Best primary schools in Manchester")
- Counts framed usefully: "137 primary schools, 9 rated Outstanding"
- A ranked table of the top 20 on the headline metric
- Local average against the England average
- Ofsted grade distribution
- Map
- Links to neighbouring towns and to the parent LA
- An FAQ block (feeds `FAQPage` in W4)
- Links to every school page in scope — this is what de-orphans W1's corpus
Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold
≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**,
comfortably inside a sitemap index and well under the per-file 50,000 limit.
### W3 — Make rankings indexable
`/rankings` is driven entirely by query params, so Google indexes
approximately one page where there should be hundreds.
/rankings/[phase]/[metric]
/rankings/[phase]/[metric]/[la]
The interactive filter UI stays; its state moves into real paths. Param
forms canonicalise to the clean path. This is the direct play for C2.
### W4 — Structured data and internal linking
- Replace the bare `EducationalOrganization` on school pages with `School`,
and populate it properly.
- `BreadcrumbList` site-wide.
- `ItemList` on every rankings and location page.
- `FAQPage` on admissions and on location pages.
- New school-page modules: "Other schools in [town]", "Nearby schools",
"Compare with similar schools". Each links out to W2 and W3 pages, which
is what circulates authority instead of stranding it.
Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus
exists, and fabricating one would be both useless and a policy violation.
### W5 — Admissions expansion
One static page currently carries an entire cluster.
/admissions/[la] per-authority deadlines and offer day
/admissions/appeals
/admissions/national-offer-day
`school admissions [LA]` is high-intent and highly seasonal; per-authority
pages are the natural unit.
### W6 — Explainer content
/guides/progress-8
/guides/attainment-8
/guides/sats-scaled-scores
/guides/ofsted-grades
/guides/expected-standard
Each links into the corresponding W3 rankings page. Cheap to build, and it
is what gives the metric vocabulary enough topical weight to support C1–C4.
### W7 — Head-to-head pages
/compare/[school-a]-vs-[school-b]
C7 is uncontested and native to the product. It is also the easiest way to
destroy everything W1 fixes: 27,229 schools generate 370 million pairs.
**Curated pairs only** — same town, both with current data, both with real
search demand — capped in the low thousands. Gated behind evidence that W2
is indexing cleanly.
### W8 — Metadata rewrite for C1
Current homepage title is `schoolcompare | Compare every school in England`,
which spends the most valuable position on the brand. Rewrite the homepage,
rankings and compare titles and descriptions around C1 phrasing. Small
change, and the cheapest item in the programme.
## Sequencing
W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly)
W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin
compounds the problem rather than diluting it.
This spec is a programme, not a single implementation plan. Each workstream
gets its own plan and its own PR; W2 will likely need several. Only W0 and W1
are ready to plan against today — the rest should be re-read after W0's
Search Console baseline lands, because that data may reorder them.
## Testing
Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in
the same PR. Each workstream adds:
- W1: canonical present and correct on every route; `/compare?urns=` carries
`noindex`; sitemap excludes a known dataless URN.
- W2: a known LA, town and outcode page renders with the expected school
count; a below-threshold town redirects to its LA.
- W3: a clean rankings path renders; the param form canonicalises to it.
- W4: JSON-LD parses and validates against the declared types.
## Risks
**Index bloat.** The failure mode of every programmatic SEO programme. The
five-school threshold, the W1 prune and the W7 gate are the three controls.
**Helpful-content exposure.** Google's stance on templated location pages
has hardened. The mitigation is that each page carries genuinely local
computed data — real counts, real distributions, real local-vs-national
comparison — rather than a name substituted into boilerplate.
**Build cost.** School pages already use ISR with a 7-day revalidate and
`PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same
treatment; full static generation of 32,000 pages is likely impractical in
CI.
**Seasonality.** Results day and offer day dominate the traffic curve.
Judging the programme on a mid-summer window would misread it in either
direction. W0's baseline must be year-on-year, not month-on-month.
## Open questions
1. Catchment areas are Locrating's moat and a strong C3 driver
(`[postcode] school catchment`). `fact_admissions` carries admission
distances. Is deriving approximate catchment a later workstream, or out
of scope?
@@ -0,0 +1,278 @@
# W2: The Location Layer — Design
Date: 2026-08-21
Status: awaiting review
Supersedes: workstream W2 in `2026-08-20-seo-programme-design.md`
Scope note: this covers four page families in one spec. Splitting them — towns
and authorities first, outcodes and localities after — was proposed and
declined in favour of building the layer in one pass. The decomposition
argument was that the curated locality seed needs human review and would hold
up 783 pages of measured demand behind it; that risk is accepted here, and the
implementation plan should sequence the seed early enough that review time
does not become the critical path.
## Problem
Location intent is the largest unserved demand the site has. In the 16-month
Search Console baseline it draws **874 impressions, one click, average
position 49.5**. The site does not compete.
Unlike named-school queries — which the same baseline showed to be
navigational and unwinnable, since a parent typing "audley junior school"
wants that school's own website — location queries have no incumbent owner.
Nobody owns "primary schools in Brentwood" the way a school owns its name.
The cause is structural: the site has no page about a place. Every competitor
ranking above it does.
## What the demand actually looks like
Every location query in the baseline is **town or district level**. Not one is
an administrative area:
| Query | Impressions | Position |
|-------|-------------|----------|
| colleges in solihull | 112 | 51.2 |
| schools in ramsey | 64 | 42.5 |
| schools in crosby | 57 | 47.7 |
| primary schools in beccles | 44 | 40.9 |
| private schools in battersea | 41 | 71.9 |
| secondary schools in brentwood | 37 | 56.1 |
| secondary schools in canary wharf | 30 | 35.9 |
Three patterns follow directly, and they drive the whole design.
**Towns, not authorities.** The superseded W2 put `/schools/[la]` first and
towns second. The data inverts that. Brentwood appears four times in different
phrasings; Beccles twice. Both are towns, not authorities.
**Phase is part of the query**, not a filter applied afterwards: "primary
schools in beccles", "secondary schools in brentwood", "colleges in solihull".
**London is searched by district** — Battersea, Canary Wharf — and the GIAS
`town` field cannot serve it at all.
## Measured sizing
Counted against the live corpus of 25,185 schools, not estimated.
| Family | Viable (≥5 schools) | Below threshold |
|--------|--------------------|-----------------|
| Towns | **783** | 907 → redirect to authority |
| Outcodes | **1,760** | 305 |
| Local authorities | 154 | — |
| London localities | ~100–150 (curated) | — |
With phase variants — 783 town pages plus roughly 700 primary and 250
secondary variants, 154 authorities across three variants, 1,760 outcodes and
the curated localities — the total lands near **4,000 pages**. Phase variants
need their own threshold: there are 17,426 primaries but only 4,456 secondaries nationally,
so most towns will support a primary page and not a secondary one.
## Two design problems this spec exists to solve
### 1. Town and authority names collide, and neither contains the other
67 viable towns share a name with a local authority. The obvious fix — let the
authority absorb the town, since it sounds like a superset — **does not work**:
| Place | Schools in the town | Schools in the authority |
|-------|--------------------|-----------------------|
| Bedford | 104 | 86 |
| Birmingham | 520 | 518 |
| Derby | 157 | 119 |
| Doncaster | 152 | 145 |
The authority is the larger set in only 43 of the 67. Postal towns cross
authority boundaries, so these are overlapping sets that happen to share a
name. Publishing both into one namespace produces near-duplicate pages, which
is the specific failure that sinks programmatic SEO.
**Resolution: two namespaces.**
```
/schools/[place] towns and London localities
/schools/[place]/primary
/schools/[place]/secondary
/schools/authority/[la] local authorities
/schools/authority/[la]/primary
/schools/authority/[la]/secondary
/schools/near/[outcode]
```
Outcodes carry no phase variants: nobody searches "primary schools in SW11",
so the variants would be pages without demand.
Every collision disappears by construction. `/schools/[place]` keeps the clean
URL for the pattern that carries the demand; authorities get a namespace whose
purpose is genuinely different — admissions are authority-run, and the
authority page is the one that can speak to catchment policy and LA averages.
A place page and an authority page of the same name must each say plainly
which set of schools they cover, or they read as duplicates to a reader even
when they differ in fact.
### 2. London has no locality field
`town` collapses **1,819 London schools into the single value "London"**. A
page listing all of them is useless, and borough pages do not help because
people search "Battersea", not "Wandsworth".
No single field solves it:
| Search term | `parliamentary_constituency` | postcodes.io `admin_ward` |
|-------------|------------------------------|---------------------------|
| Battersea | **Battersea** ✓ | Northcote / Wandsworth Town ✗ |
| Canary Wharf | Poplar and Limehouse ✗ | **Canary Wharf** ✓ |
| Vauxhall | Vauxhall and Camberwell Green ✗ | **Vauxhall** ✓ |
And neither covers Clapham, Shoreditch or Peckham, which are postal and
colloquial rather than administrative.
**Resolution: a curated seed mapping locality to outcodes.**
```
pipeline/transform/seeds/locality_outcodes.csv
locality_slug,locality_name,outcodes,region
battersea,Battersea,"SW11|SW8",London
canary-wharf,Canary Wharf,"E14",London
clapham,Clapham,"SW4|SW9",London
```
This needs **no new ingestion** — the corpus already has postcodes. It puts
the fuzzy, contested part of the problem in a reviewable file rather than in
derived logic, which suits it: locality boundaries are a judgement, not a
fact. The repo already uses dbt seeds for curated reference data
(`la_code_names.csv`, `gias_code_names.csv`), so this follows an established
pattern.
The seed generalises past London. Any colloquial place — Jesmond, Chorlton,
Clifton — can be defined by its outcodes without a schema change.
**Constraint:** a locality slug may not collide with a viable town slug. The
place registry enforces this and fails the build rather than silently
shadowing a town.
## Architecture
### The place registry
One module owns the question "what places do we publish, and what is in each".
Everything else reads from it: the pages, the sitemap, the internal links.
```
backend/places.py
Place = { kind: "town"|"locality"|"authority"|"outcode",
slug, name, urn_list, parent_authority | None }
build_place_registry(df) -> dict[str, Place]
place_schools(slug, phase=None) -> list[School]
```
Built once at startup from the same DataFrame the sitemap uses, and rebuilt by
the existing `/api/admin/regenerate-sitemap` path after a pipeline run.
Registry construction is where the threshold, the collision rules and the
seed's uniqueness constraint are enforced — in one place, testable without a
browser or a database.
### API
```
GET /api/places the registry: slug, kind, name, count
GET /api/places/{slug}?phase= aggregate + ranked schools for one place
```
`/api/places` is what the sitemap and the internal-link modules enumerate.
### Routes
Next App Router, ISR with the same 7-day revalidate the school pages use.
`generateStaticParams` gated behind an env flag, matching
`PRERENDER_SCHOOLS`, because 3,900 more routes cannot be statically built in
CI on every deploy.
## What each page must contain
A place page that is a name substituted into a template is the thing Google's
helpful-content stance exists to demote. Each page carries computed local
facts that exist nowhere else on the site:
- **H1** matching the query: "Primary schools in Brentwood"
- **Counts framed usefully**: "29 schools, 4 rated Outstanding"
- **A ranked table** of the top 20 on the phase's headline metric —
`rwm_expected_pct` for primary, `attainment_8_score` for secondary, and for
an unphased place page the metric matching whichever phase it holds more of
- **The local average against the England average** — the one number a parent
cannot get from a list
- **Ofsted grade distribution** for the place
- **A map**
- **Links to neighbouring places** and to the parent authority
- **An FAQ block**, feeding `FAQPage` structured data
- **A link to every school page in scope** — this is what finally de-orphans
the 23,000 school pages the original spec identified as near-orphans
## Thin-page controls
Three, and they are the difference between a location layer and index bloat:
1. **Five schools with current data minimum.** Below it, 301 to the parent
authority. This drops 907 towns and 305 outcodes.
2. **Per-phase thresholds.** A town with 30 primaries and 2 secondaries
publishes a primary page and no secondary page.
3. **No page without a local average.** If a place has too few schools with
results to compute one, it has nothing to say that a list does not, and it
falls back to the authority.
## Sitemap
Two new children in the existing index: `/sitemaps/places-{n}.xml` and
`/sitemaps/outcodes-{n}.xml`. Per-family children are why the index was built
in W1 — Search Console reports coverage per submitted sitemap, so indexation
of the location layer is measurable separately from the school pages.
## Testing
Per `CLAUDE.md`, user-facing behaviour extends `e2e/tests/journeys.spec.ts` in
the same PR.
**Unit (registry, no DB):** threshold enforcement; a sub-threshold town
resolves to its authority; a locality slug colliding with a town fails the
build; Bedford's town and authority pages hold different URN sets; per-phase
thresholds.
**Backend:** `/api/places` shape; `/api/places/{slug}` aggregate correctness
against a fixture; unknown slug 404s.
**e2e:** a known town, authority, locality and outcode page each render with
the expected count; a below-threshold town 301s; every place page declares a
canonical and appears in the sitemap; `/schools/bedford` and
`/schools/authority/bedford` both resolve and state which set they cover.
## Risks
**Index bloat** is the failure mode of every programmatic SEO programme. The
three controls above are the answer, and the per-family sitemap is how we find
out early if they were not enough.
**Helpful-content exposure.** Templated location pages are exactly what
Google's stance targets. The mitigation is that every page carries real
computed local data — counts, distributions, local-versus-national comparison
— rather than a name dropped into boilerplate. If indexation of the places
sitemap stalls below roughly half, that is the signal to stop and rethink
rather than to add more pages.
**Build cost.** ~4,000 additional ISR routes on top of 23,000 school pages.
The env-flag gate on `generateStaticParams` keeps CI viable.
**Curation drift.** The locality seed is hand-maintained and will go stale as
places change. It is small and reviewable, and a dbt test asserts every seed
outcode matches at least one school so a typo fails the pipeline rather than
publishing an empty page.
## Out of scope
Catchment-area estimation. It is a strong driver for this cluster and
`fact_admissions` carries the distances, but it is a modelling problem with
real accuracy risk and deserves its own design.
@@ -0,0 +1,294 @@
# Feature Flags — Design
**Date:** 2026-08-23
**Status:** approved for planning
**First consumer:** the last-distance-offered feature (`admission_distance`)
## Goal
Let work merge to `main` and deploy to production without becoming visible,
so that releasing a feature stops being the same event as deploying it.
The site has no way to do this today. A feature is either on `main` and live,
or it is on a branch. That forces long-lived branches for anything not ready,
and it makes every promotion to production an all-or-nothing decision about
everything queued behind it.
This is a **ship-dark** capability, not a kill switch. Flags are expected to
flip on the order of once a month, by a person, deliberately. Nothing here is
designed for flipping something off in seconds under pressure, and nothing
here does percentage rollouts, user targeting or A/B tests — the site has no
user identity to target.
## Decision: Unleash
Flag state is held in a self-hosted [Unleash](https://www.getunleash.io/)
instance (Apache-2.0), not in the repository.
A lighter option was considered and rejected by the project owner: a typed
registry in each runtime with environment-variable overrides set in the
Portainer stack files, which would have needed no new container and kept flag
state in git. The argument for Unleash is that it provides a UI and an audit
log without a deploy, and that flags are expected to become an ongoing
operational tool rather than an occasional one.
Two consequences follow from choosing a service, and this design exists mostly
to handle them:
1. **Flag state lives outside the repository.** `main` is no longer the whole
truth about what is switched on. The registry in §2 exists to bound that.
2. **A flag can change without a deploy**, so nothing else clears the caches
that a deploy would have cleared. §4 establishes how long a flip takes to
become visible, and why that is short enough to need no extra mechanism.
Also considered: Flagsmith (heavier — Django, Postgres and Redis), GrowthBook
(requires MongoDB), and Flipt v2 (the closest conceptual fit, git-native, but
now under the Fair Core Licence — source-available, not OSI open source).
## 1. Topology
A third Portainer stack, `docker-compose.portainer.unleash.yml`, holding
`unleashorg/unleash-server` and its own PostgreSQL 16. It is on the macvlan so
both application stacks can reach it, and it belongs to neither of them — a
staging redeploy must not be able to disturb production's flag state, and vice
versa.
One instance serves both environments. Open-source Unleash ships with
`development` and `production` environments and environment-scoped client
tokens, so the same flag holds independent state in each: staging's FastAPI
carries a `development` token, production's carries a `production` one.
That property is what makes ship-dark testable. A feature can be **on in
staging and off in production** for as long as it takes, which means the `e2e/`
journeys exercise it against staging while production stays unchanged.
## 2. The registry
Unleash supplies flag *state* and the toggle UI. It does not supply the list of
flags. `backend/flags.py` declares every flag the code knows about:
```python
@dataclass(frozen=True)
class Flag:
name: str # identical in the registry, in Unleash, and in JSON
description: str # one line: what turning this on reveals
added: date # for the staleness test in §8
```
**Every flag defaults to `False`.** There is no per-flag default field, because
a flag that defaults on is not a ship-dark flag — it is a kill switch, and this
design does not offer one. A single unconditional default also means the
fallback path has no branching to get wrong.
Three reasons the registry is not optional:
- The Unleash SDK evaluates an unknown flag to `False`. Without a registry that
is an *undeclared* false — indistinguishable from a typo in a flag name.
- `/api/flags` needs a key set to return when Unleash is unreachable. It cannot
enumerate flags it has never heard of.
- A flag present in the Unleash UI but absent from the registry is orphaned,
and should be visibly so rather than quietly authoritative.
**Naming.** One string, used unchanged as the registry key, the Unleash flag
name, and the JSON key in `/api/flags`. It is snake_case, matching the API's
existing convention (`admission_distance`, `rwm_expected_pct`) and the mirrored
types in `nextjs-app/lib/types.ts`. No case transformation anywhere, so there
is no mapping layer to get wrong.
## 3. Read paths
### Backend
`backend/flags.py` wraps `UnleashClient` behind `is_enabled(name: str) -> bool`.
Fail-closed is the default rather than something added: the Python SDK
evaluates every flag to `False` until it has synchronised with the server. An
unfinished feature therefore stays hidden when Unleash is unreachable, which is
the correct direction for ship-dark.
The SDK's fcache directory is mounted on a named volume so a container restart
during an Unleash outage keeps last-known state rather than reverting a
released feature to dark. The registry default remains `False`, so the worst
case is a feature disappearing, never one appearing.
### Frontend
`nextjs-app/lib/flags.ts` exposes `getFlags(): Promise<Flags>`, a single
server-side fetch of `/api/flags` returning a typed record. Server components
only — no flag value reaches the browser bundle, and `package.json` gains no
Unleash dependency. The Unleash client library stays entirely inside the
service that already owns every other piece of data the frontend renders.
The cost, named plainly: a purely front-end flag must still be declared in a
Python file. It is a flat data edit rather than programming, and the return is
one list, so nobody has to ask which service knows about a given flag.
### `/api/flags` must not be publicly reachable
`nextjs-app/app/api/[...path]/route.ts` proxies **everything** under `/api/` to
FastAPI. Left alone, `https://www.schoolcompare.co.uk/api/flags` would return
`{"admission_distance": false, ...}` — publishing the name and state of every
unreleased feature, which defeats the purpose of shipping dark.
The proxy therefore gains a denylist, and `flags` is on it: a request for a
denied path returns 404 rather than being forwarded. Next's own `getFlags()` is
unaffected because it calls `FASTAPI_URL` directly across the Docker network
and never transits the public proxy.
This is a general hole rather than a flags-specific one — the proxy will
forward any future internal endpoint too — so the denylist is written as a
named constant with a comment saying what belongs on it.
## 4. Propagation
**Time-based revalidation is sufficient. There is no webhook.**
An earlier draft of this section specified two Unleash webhooks and a
`revalidateTag('flags')` purge, on the premise that pages cache for seven days.
That premise was wrong, and checking it removed the most complex part of the
design.
Next uses the **lowest** `revalidate` among a route's fetches to set the
revalidation frequency of the whole route — the segment-level
`export const revalidate` does not override a lower value inside it. Measured
against this codebase:
| Page family | Segment | Lowest fetch | Effective |
|---|---|---|---|
| `/school/[slug]` | 604800 | `fetchSchoolDetails` at 300 | **5 minutes** |
| `/schools/*` | 604800 | `fetchNationalAverages` at 3600 | **1 hour** |
The Unleash SDK polls every 15 seconds, so a flip reaches school pages within
about five minutes and place pages within the hour, unaided. Flags flip
monthly, by hand, deliberately. That is fast enough.
What this removes: two webhook integrations, a `/api/revalidate-flags` route, a
shared-secret-in-a-query-string scheme, an idempotency requirement against
duplicate and out-of-order delivery, and a rule that every fetch in
`nextjs-app/lib/` carry a cache tag. None of it has to be built, maintained, or
kept correct as new fetches are added.
**If instant flips are ever wanted**, the webhook is the way to add them, and it
is purely additive — nothing in this design has to change first.
### Two constraints this leaves behind
**Never flag content on a `force-static` page.** `app/admissions/page.tsx`
declares `export const dynamic = 'force-static'`, so it is baked at build time
and never revalidates. A flag gating anything on such a page would not take
effect until the next deploy, silently. If a flag ever needs to reach one, that
page must first move to ISR.
**A route-family flag still needs the sitemap rebuilt.** The sitemap is held in
memory and rebuilt only at startup or via `POST /api/admin/regenerate-sitemap`.
No flag in scope touches the sitemap (§6), so this is deferred with the route
case rather than solved now — but a route flag must not ship without it, or the
sitemap will advertise URLs that `notFound()`.
## 5. What "off" means, per surface
| Surface | Off |
|---|---|
| Route | `notFound()`, **and** absent from the sitemap, **and** absent from nav |
| UI element | Not rendered; surrounding page byte-identical to today |
| API field | Key **absent**, not `null` |
| API endpoint | 404, not 403 |
The three parts of the route rule move together or not at all. Submitting URLs
to Google that return 404 is the bug fixed in PR #124, and a flag is a new way
to reintroduce it.
An API field is withheld **at the source**, never rendered-but-hidden. The
precedent is already set in this codebase by commit `c9a1892`: `/api/schools/`
is public and unauthenticated, so leaving a withheld field in the payload hands
the record to anyone who opens the network tab.
## 6. First consumer: `admission_distance`
The last-distance-offered feature is merged to `main` and live on staging.
Production has never received it: `/api/schools/100010` on production carries
no `admission_distance` key, and no Distance section renders.
It needs **exactly one gate** — `backend/app.py:809`, where the field is
attached to the school payload:
```python
"admission_distance": (
supplementary.get("admission_distance")
if flags.is_enabled("admission_distance") else None
),
```
The frontend follows with no change. `DistanceSection` already returns `null`
when `admission_distance?.distance_m == null`, and `PrimarySchoolSections`
already conditions the admissions block on `(admissions || admissionDistance)`.
The off-state is the commonest state on the site — only 57 local authorities
publish cut-off distances at all — so it is well covered by construction.
The flag does not touch the sitemap: school pages exist either way.
Intended lifecycle: default off, so production receives the code dark on the
next promotion; on in the `development` environment so staging keeps testing
it; flipped on in `production` when the owner chooses.
**This flag exercises two of the three surfaces** in §5 — API field and UI
element. No route case ships with it. The route rule is specified but unproven
until a route-shaped flag exists, and should be treated as such.
## 7. Testing
**Backend unit.** The registry is well-formed; an unknown flag evaluates
`False`; `/api/flags` returns every declared flag with its default when the
SDK is unreachable; `admission_distance` is absent from the school payload when
the flag is off and present when on.
**Frontend unit.** `getFlags()` returns declared defaults when `/api/flags`
fails, rather than throwing and taking the page with it.
**E2E.** Journeys read `/api/flags` and gate flag-dependent assertions on it,
matching the `test.skip` shape the suite already uses.
One trap to avoid, worth stating because the existing distance journeys walk
straight into it: they already skip when no school has a published figure, so
with the flag off they would skip silently and the suite would go green. The
gate must be explicit — **if `/api/flags` reports `admission_distance` on, then
a school with a cut-off must be found**, converting a silent skip into a real
assertion.
## 8. Lifecycle
A flag is temporary scaffolding, and the failure mode of every flag system is
accumulation.
The registry records the date each flag was added, and a backend test fails any
flag older than **90 days**. Removing a flag means deleting the registry entry,
the branches that read it, and the flag in the Unleash UI.
Unleash SDK usage metrics stay enabled, so the UI shows which flags are still
being evaluated — the evidence needed to retire one safely.
## 9. Risks
**Production gains a homelab dependency.** If Unleash is unreachable when a
production container cold-starts with an empty cache, every flag evaluates
`False` and any feature currently switched on disappears. The fcache volume
covers restarts; the 90-day lifecycle rule bounds how long any feature is
exposed to this. It is a real regression risk and the reason flags must be
retired rather than left on indefinitely.
**Flag state is not in git.** `main` no longer tells you what production is
showing. The registry lists what *can* be flagged; only the Unleash UI says
what *is*. This is inherent to the choice of a service.
**A large promotion backlog exists.** Production is running the
pre-SEO-programme build — no place pages, and a sitemap still declaring the
apex host. The first promotion after this work ships that entire backlog. The
flag isolates the distance feature from it and nothing else.
## Out of scope
- Percentage rollouts, user targeting, A/B testing, and Unleash strategies
beyond simple on/off. Flags are booleans.
- Pipeline and dbt flags. Airflow and dbt are not flag consumers.
- Client-side flag evaluation. Flags are server-side only.
- Automatic flag removal. The staleness test reports; a person deletes.
@@ -0,0 +1,294 @@
# School Autosuggest — Design
**Date:** 2026-08-26
**Status:** approved for planning
**Depends on:** the feature-flag layer (PR #125, merged)
## Goal
Suggest schools by name as someone types in the site's main search box, so a
parent who knows the school they want reaches it in one step instead of
searching, scanning a result list, and clicking.
Scope is **schools only**. Places and postcodes were considered and excluded —
see *Out of scope*.
## The finding that shapes everything
The site's rate limiter does not do what it looks like it does.
`limiter = Limiter(key_func=get_remote_address)` with `60/minute` reads
`request.client.host`. In staging and production the backend has no published
ports and sits on the internal `backend` network, so its only caller is the
Next proxy — and `request.client.host` is therefore **the Next container**, for
every browser user on the site.
Measured against staging: 70 concurrent requests to `/api/schools` returned
**60 × 200 and 10 × 429**. One machine consumed the whole site's budget for
that minute.
Autosuggest is the worst possible feature to build on that. One person typing
"st marys primary" produces six to eight debounced requests; **eight concurrent
searchers would 429 the site.** The compare modal's search-as-you-type already
shares this bucket, so the exposure exists today — autosuggest makes it
certain.
Fixing the keying is therefore part of this work, not a follow-up.
## 1. Rate-limit keying
Both environments sit behind Cloudflare (`server: cloudflare`, `cf-ray` present
on staging and production). Cloudflare sets `CF-Connecting-IP` on every request
to the origin and **overwrites any client-supplied value**, which makes it
trustworthy in a way a parsed `X-Forwarded-For` chain is not.
```python
def client_key(request: Request) -> str:
"""Rate-limit bucket: the real caller, not the proxy in front of them."""
cf = request.headers.get("cf-connecting-ip")
if cf:
return cf.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return get_remote_address(request)
```
`nextjs-app/app/api/[...path]/route.ts` already forwards every inbound header
except `host` and `connection`, so `CF-Connecting-IP` reaches the backend with
no proxy change.
**This header is trustworthy only for traffic that actually passed through
Cloudflare, and nothing in the application can verify that it did.** An earlier
draft of this section claimed Cloudflare "replaces the header, so a browser
cannot forge it", and that only the `X-Forwarded-For` fallback was forgeable.
That was wrong. Cloudflare does overwrite the header *on requests it handles* —
but a caller reaching the origin directly sets whatever it likes, and this
process cannot distinguish an edge-set header from an attacker-set one. Both
headers are equally forgeable in that scenario.
The consequence is sharper than a weakened defence. An attacker rotating
`CF-Connecting-IP` per request mints a fresh rate-limit bucket every time and
evades per-client limits entirely — including on the DataFrame-heavy
`/api/schools`. Against abuse that is *worse* than the shared bucket it
replaced, which at least capped everyone at 60/minute together.
Two mitigations, and they are not interchangeable:
1. **The real fix is at Cloudflare** — Authenticated Origin Pulls, or an origin
firewall that refuses connections not from Cloudflare's ranges. Only the
edge can vouch for its own header. This is infrastructure work and is not
part of this change; it is the thing that makes the header mean anything.
2. **The ceiling in §1.1 bounds what evading the keying can achieve** while
that remains open. It does not make the header trustworthy — it makes
trusting it survivable.
The backend being unreachable from outside the Docker network is a real second
layer, but it depends on the ingress path in front of the frontend, which this
design does not control and should not assume.
### 1.1 The ceiling, which is back
The shared bucket was acting as an accidental global throttle on a
single-process uvicorn backend that filters a 25,000-row DataFrame in-process.
Correct per-user keying removes it: the origin becomes reachable at 60/min *per
user* rather than 60/min in total, and — per above — at an unbounded rate by
anyone willing to rotate a header.
An earlier draft dropped the in-app ceiling, arguing it belonged at Cloudflare.
That argument assumed the keying was sound. It is not, so the ceiling is
load-bearing rather than redundant, and it ships here:
`GlobalRateLimitMiddleware` counts all `/api/` requests in a fixed 60-second
window against `global_rate_limit_per_minute` (3000), independent of any client
identity, and refuses with a 429 that names capacity rather than the client —
an operator has to be able to tell "one noisy client" from "the origin is
saturated". It is registered last so it is outermost: a ceiling that applies
after the expensive work has run is not a ceiling.
slowapi cannot express this. `default_limits` and `application_limits` are both
evaluated with the same `key_func`, making them per-client rather than global,
and `application_limits` only apply with `SlowAPIMiddleware` installed, which
this app does not use. Hence the explicit middleware — about thirty lines, and
obviously correct, which is what a backstop needs to be.
Requests from `127.0.0.1` are exempt. The container healthcheck runs
`curl http://localhost:80/api/data-info` from inside the container, and
starving it would fail the check, restart the container, and turn a load spike
into an outage loop. The exemption keys on the peer address, never the `Host`
header, which the caller sets.
3000/minute is an estimate, not a measurement, and worth revisiting against
real traffic.
### Per-user limits
Per-user fairness and origin protection are different jobs, and this design now
does both separately: the ceiling above for the origin, and per-route limits
for fairness. Conflating them is what produced the original behaviour, where
one bucket served the whole internet.
The existing 60/minute default is unchanged, and `/api/suggest` gets
120/minute. Both are estimates rather than measurements, and are a starting
point to revisit once the keying is correct enough for real per-user traffic to
be visible — which it was not before, because everyone shared one bucket.
## 2. `GET /api/suggest`
A dedicated endpoint, not a mode of `/api/schools`.
The existing search path calls Typesense for URNs and then filters, ranks and
sorts the full in-memory DataFrame — a pandas pass per keystroke, holding the
GIL and blocking other requests in the same worker. Suggestions need none of
it: `urn`, `school_name`, `phase`, `school_type`, `local_authority`,
`postcode` and `ofsted_rating` are all already in the Typesense document
(`pipeline/scripts/sync_typesense.py`).
```
GET /api/suggest?q=<query>&limit=8
→ 200 {"suggestions": [
{"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school"}
]}
```
- **Under two characters** returns `{"suggestions": []}` with 200. The
keystroke path never returns an error for ordinary input.
- **Typesense unavailable** returns `{"suggestions": []}` with 200. There is
deliberately **no DataFrame fallback**: the substring scan `/api/schools`
falls back to is precisely the cost this endpoint exists to avoid, and a
silent 25,000-row scan per keystroke is worse than no suggestions.
- **`limit` is clamped** to 20. It is a public endpoint.
- **Rate limit `120/minute`** per client, not the default 60. A 200 ms
debounce tops out near 5 requests/second while someone is actively typing,
but averages far below that across a real search; 120 leaves headroom for
bursts while still bounding one client.
- **Local authority is part of the payload, not decoration.** There are many
schools called "St Mary's"; a suggestion list without the authority is
unusable for exactly the queries autosuggest is meant to serve.
### Caching
`CACHE_RULES` gains `("/api/suggest", (60, 3600, 86400))`. Prefix queries
repeat enormously across users and school names change once a year.
The client fetch must **not** use `cache: "no-store"`. The compare modal does,
and copying that pattern would throw away both the browser cache and the ETag
304s the existing `CacheAndETagMiddleware` already provides.
Both environments currently report `cf-cache-status: DYNAMIC` — Cloudflare
ignores the `Cache-Control` the API already sends, because it does not cache
dynamic paths by default. **A Cloudflare Cache Rule for `/api/suggest*` would
let the edge absorb most of this traffic and never reach the origin.** That is
a dashboard change, it is optional, and nothing here depends on it.
## 3. The combobox
This is an ARIA combobox, not a text input with a list underneath.
**Files.** `FilterBar.tsx` is already long. The work splits three ways:
`hooks/useSchoolSuggest.ts` owns fetching, debouncing and cancellation;
`components/SuggestList.tsx` owns rendering and ARIA; `FilterBar.tsx` wires
them to the existing input and form.
**Fetching.** 200 ms debounce; minimum two characters; an `AbortController`
cancels the superseded request on every keystroke. Cancellation is not an
optimisation — without it, a slow response for `"st"` can land after the fast
one for `"st marys"` and replace a correct list with a stale one.
**Suppressed during postcode entry.** The box takes a school name *or* a
postcode, and `isValidPostcode` already distinguishes them. Suggestions do not
appear once the value parses as a postcode.
**Keyboard.** `ArrowDown`/`ArrowUp` move the active option, `Escape` closes and
keeps the typed text, `Tab` closes. `Enter` **with an option active** navigates
to that school's page. `Enter` **with none active** submits the free-text
search exactly as it does today — the existing behaviour is preserved, not
replaced.
**ARIA.** `role="combobox"` with `aria-expanded` and `aria-controls` on the
input, `aria-activedescendant` pointing at the active option, `role="listbox"`
on the list and `role="option"` on each row.
**Both instances get it.** `HomeView` renders `FilterBar` twice — hero and
sticky — from one component, so there is one implementation.
## 4. Behind a flag
Flag `school_autosuggest`, declared in `backend/flags.py`, default off.
This is the most-used control on the site and the first change to it in a
while. `app/page.tsx` is an async server component, so it reads the flag and
threads it to `FilterBar` through `HomeView` — two prop hops, explicit, no
client-side flag read.
Off means the input behaves exactly as it does today: no listener, no fetch, no
markup. Not a rendered-then-hidden dropdown.
The rate-limit keying is **not** flagged. It is a correctness fix that should
apply whether or not autosuggest is on, and flagging it would mean shipping a
known-wrong limiter into production deliberately.
## 5. Analytics
`search_submitted` already carries `via: 'input'`. Selecting a suggestion fires
it with `via: 'suggestion'` plus the chosen `urn`, so the obvious question —
does this actually help, or do people ignore it — has an answer in the data
rather than an opinion.
## 6. Testing
**Backend.** `client_key` prefers `CF-Connecting-IP`, falls back through
`X-Forwarded-For` to the remote address, and two different values get two
different buckets. `/api/suggest` returns matches, returns empty below two
characters, returns empty and 200 when Typesense is unavailable, and clamps
`limit`. That it never touches the DataFrame is asserted by making
`load_school_data` raise and requiring the endpoint to answer anyway.
**Frontend.** The hook debounces, aborts superseded requests, and drops a
late-arriving response for a stale query. The list renders the ARIA
attributes. Keyboard navigation moves the active option; `Enter` on an option
navigates; `Enter` on none submits the search.
**E2E.** With the flag on, typing a known school name shows it and selecting it
lands on that school's page. With the flag off, no combobox markup exists.
Gated on the flag the same way the distance journeys are — read the observable
effect, since `/api/flags` is denied to the public.
## 7. Risks
**Removing the accidental throttle.** Covered in §1. Correct per-user keying
means the origin is reachable at 60/minute *per user* where it was 60/minute
in total, and no in-app global cap replaces it — that job goes to Cloudflare,
which is not done as part of this change. Until it is, a determined caller
with many source addresses can put more load on a single-process origin than
they can today. Against this site's traffic that is a theoretical risk rather
than a live one, but it is a real one and it is the price of the fix.
**Cloudflare bypass — the open one.** If the origin is reachable without
passing through Cloudflare, `CF-Connecting-IP` is attacker-controlled, and
rotating it per request defeats per-client limits on every endpoint. The
ceiling in §1.1 bounds the damage to the origin's total capacity; it does not
restore per-client fairness under attack, and it cannot. Closing this properly
means Authenticated Origin Pulls or an origin firewall restricted to
Cloudflare's published ranges — infrastructure work, outside this change, and
the single most valuable follow-up here.
**Typesense becomes user-visible.** Today a Typesense outage degrades search to
a slow substring match. With autosuggest it also means the dropdown silently
stops appearing. That is the correct failure — quiet, not broken — but it makes
Typesense health worth monitoring in a way it was not before.
## Out of scope
- **Place suggestions.** The 2,646 town, authority and outcode pages are a
strong candidate and would route people onto the pages W2 built, but they
live in the place registry rather than Typesense, so it is a second index and
a ranking rule for comparing two kinds of result. Worth its own change.
- **Postcode completion.** Would put postcodes.io in the keystroke path, with
its own latency and rate limits.
- **The compare modal.** It already has search-as-you-type. Converting it to
this component is a reasonable follow-up, not part of this.
- **Recent or popular searches.** No storage for either, and no evidence yet
that they are wanted.
File diff suppressed because it is too large. Load diff
+591
View File
@@ -0,0 +1,591 @@
<title>Last distance offered — detail page mockup</title>
<style>
:root{
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
--accent-coral:#e07256; --accent-coral-dark:#b04a2e;
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
--serif:'Playfair Display',Georgia,'Iowan Old Style',serif;
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
}
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;
padding:44px 20px 110px;font-variant-numeric:tabular-nums}
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
.pagehead p + p{margin-top:8px}
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
/* ---- card shell mirrors SchoolDetailView .card ---- */
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
box-shadow:var(--shadow);padding:26px 26px 24px}
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;
color:var(--text-muted);padding:6px 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
/* ---- tiles ---- */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
.tile.accent{background:var(--coral-bg)}
.tile.accent .num{color:var(--accent-coral-dark)}
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
.tile.newtile .num{color:var(--accent-coral-dark)}
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
/* ---- verdict banner ---- */
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
.verdict.hard{background:var(--coral-bg)}
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
/* ---- chart ---- */
.chartwrap{overflow-x:auto}
.chart{display:block;width:100%;min-width:460px;height:auto}
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
.keyrow span{display:inline-flex;align-items:center;gap:7px}
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
.kdot.line{background:var(--accent-coral)}
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
/* ---- table ---- */
.tblwrap{overflow-x:auto;margin-top:4px}
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
thead th:first-child{text-align:left}
tbody td{text-align:right;padding:11px 10px;border-bottom:1px solid var(--border)}
tbody td:first-child{text-align:left;font-weight:600}
tbody tr:last-child td{border-bottom:none}
tbody tr.now{background:var(--bg-secondary)}
td.miss{color:var(--text-muted);font-weight:400}
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
/* ---- map ---- */
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
.mapsvg{display:block;width:100%;height:auto}
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
.maplegend span{display:inline-flex;align-items:center;gap:7px}
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
/* ---- postcode check ---- */
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
.checkform{display:flex;gap:8px;flex-wrap:wrap}
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
.checkform button:hover{background:#9c3f26}
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
/* ---- notes / disclosure ---- */
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
.note .i{flex:none;width:17px;height:17px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);
font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
.disclosure{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;
font-weight:600;font-size:14px;color:var(--accent-teal)}
.disclosure>summary::-webkit-details-marker{display:none}
.disclosure>summary:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
.disclosure .chev{transition:transform .2s ease}
.disclosure[open]>summary .chev{transform:rotate(90deg)}
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
.disclosure .body b{color:var(--text-primary)}
/* ---- viewport stack (keeps card height stable across views) ---- */
.viewport{display:grid}
.viewport>.view{grid-area:1/1}
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
/* ---- edge cases ---- */
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
.case .body strong{color:var(--text-primary)}
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
/* ---- phone ---- */
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
.phone .sectionTitle{font-size:18px}
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
.phone .tile{padding:11px 12px}
.phone .tile .num{font-size:22px}
.phone .chart{min-width:0}
.phone .chartwrap{overflow:visible}
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
<div class="wrap">
<div class="pagehead">
<h1>Last distance offered — school detail page</h1>
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
view on the map. Data covers one to ten years depending on the school and local authority, so every
screen here is built around partial coverage rather than assuming a full run.</p>
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
</div>
<!-- ============ 1. ADMISSIONS CARD ============ -->
<div class="step">
<h2>1. A distance tile joins the admissions tiles</h2>
<span class="where">SchoolDetailView · #admissions</span>
</div>
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
third view, <code>Distance</code>.</p>
<div class="card">
<div class="cardhead">
<h2 class="sectionTitle">Admissions</h2>
<div class="seg" role="group" aria-label="Admissions view">
<button type="button" aria-pressed="true" data-view="year">This year</button>
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
</div>
</div>
<p class="sectionSub">Reception entry, September 2025.</p>
<div class="viewport">
<!-- view: this year -->
<div class="view" id="v-year">
<dl class="tiles">
<div class="tile">
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
</div>
<div class="tile">
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
</div>
<div class="tile accent">
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
<dt class="lbl">Got their first choice</dt>
</div>
<div class="tile newtile">
<span class="newflag">New</span>
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
<dt class="lbl">Last distance offered</dt>
</div>
</dl>
<div class="note">
<span class="i" aria-hidden="true">i</span>
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
It is not a fixed catchment — it moves every year with the number of applications.</span>
</div>
</div>
<!-- view: distance -->
<div class="view" id="v-dist" hidden>
<div class="verdict hard">
<span class="vic" aria-hidden="true">↓</span>
<div>
<div class="vhead">The catchment has halved in nine years</div>
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
</div>
</div>
<div class="chartwrap">
<svg class="chart" viewBox="0 0 700 250" role="img"
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
<!-- grid -->
<g stroke="#e5dfd5" stroke-width="1">
<line x1="46" y1="30" x2="686" y2="30"/>
<line x1="46" y1="80" x2="686" y2="80"/>
<line x1="46" y1="130" x2="686" y2="130"/>
<line x1="46" y1="180" x2="686" y2="180"/>
</g>
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
<g class="axtxt" text-anchor="end">
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
</g>
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
<!-- gap segments (dashed = no figure published) -->
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
<path d="M114 92 L182 110"/>
<path d="M318 122 L386 132.5"/>
</g>
<!-- solid series -->
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="46,75 114,92"/>
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="182,110 250,102 318,122"/>
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
points="386,132.5 454,142.5 522,152.5"/>
<!-- points -->
<g fill="#e07256" stroke="#fff" stroke-width="2">
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
<circle cx="454" cy="142.5" r="5"/>
</g>
<!-- latest, emphasised -->
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
<!-- "no cut-off needed" marker, 2022 -->
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
<!-- endpoint labels -->
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
<!-- year axis -->
<g class="axtxt" text-anchor="middle">
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
</g>
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
</svg>
</div>
<div class="keyrow">
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
</div>
<div class="tblwrap" style="margin-top:22px">
<table>
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
<thead>
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
</thead>
<tbody>
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
</tbody>
</table>
</div>
<details class="disclosure">
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
<div class="body">
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
in a given year. Move one large sibling cohort and the figure shifts.</span>
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
authorities use walking routes, which are always longer for the same home.</span>
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
</div>
</details>
</div>
</div>
</div>
<!-- ============ 2. MAP ============ -->
<div class="step">
<h2>2. The cut-off drawn on the map</h2>
<span class="where">SchoolHeroMap · catchment layer</span>
</div>
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
<div class="card">
<div class="cardhead" style="margin-bottom:14px">
<h2 class="sectionTitle">Where the last place went</h2>
<div class="seg" role="group" aria-label="Map years">
<button type="button" aria-pressed="true">2025 only</button>
<button type="button" aria-pressed="false">Last 5 years</button>
</div>
</div>
<figure class="mapfig">
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
<rect width="700" height="400" fill="#eef2ec"/>
<!-- park -->
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
<!-- water -->
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
<!-- street grid -->
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
</g>
<g stroke="#fff" stroke-width="3.5" opacity=".8">
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
</g>
<!-- building blocks -->
<g fill="#e4e2dc" opacity=".85">
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
</g>
<!-- historic rings (hairline) -->
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
<circle cx="300" cy="200" r="150"/>
<circle cx="300" cy="200" r="126"/>
<circle cx="300" cy="200" r="106"/>
<circle cx="300" cy="200" r="90"/>
</g>
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
<!-- current ring -->
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
<!-- school pin -->
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
<!-- your home -->
<g transform="translate(246,158)">
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
</g>
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
</svg>
</figure>
<div class="maplegend">
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
</div>
<div class="checkbox">
<div class="checkhead">Would you have got in?</div>
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
<form class="checkform" onsubmit="return false">
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
<button type="submit">Check</button>
</form>
<div class="result">
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
figure, and in 2022 every applicant was offered a place.</div>
<div class="yearstrip">
<span class="yr in">2016 ✓</span>
<span class="yr in">2017 ✓</span>
<span class="yr none">2018 –</span>
<span class="yr in">2019 ✓</span>
<span class="yr in">2020 ✓</span>
<span class="yr in">2021 ✓</span>
<span class="yr in">2022 ✓</span>
<span class="yr in">2023 ✓</span>
<span class="yr in">2024 ✓</span>
<span class="yr in">2025 ✓</span>
</div>
</div>
<div class="note">
<span class="i" aria-hidden="true">!</span>
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
</div>
</div>
</div>
<!-- ============ 3. COVERAGE STATES ============ -->
<div class="step">
<h2>3. Coverage states</h2>
<span class="where">Partial data is the normal case</span>
</div>
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
<div class="cases">
<div class="case">
<h3>4+ years</h3>
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
</div>
<div class="case">
<h3>2–3 years</h3>
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
<strong>"Only 3 years available"</strong> sits under the table.</div>
</div>
<div class="case">
<h3>1 year</h3>
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
the whole story.</div>
</div>
<div class="case">
<h3>Never oversubscribed</h3>
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
in each of the last 6 years, so no distance cut-off was needed.</div>
</div>
<div class="case">
<h3>No data at all</h3>
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
</div>
<div class="case">
<h3>Not distance-ranked</h3>
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
practice, so a distance figure would mislead.</div>
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
</div>
</div>
<!-- ============ 4. MOBILE ============ -->
<div class="step">
<h2>4. Mobile</h2>
<span class="where">≤ 640 px</span>
</div>
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
<div class="phonerow">
<div class="phone">
<div class="phonebody">
<div class="card">
<div class="cardhead" style="margin-bottom:6px">
<h2 class="sectionTitle">Admissions</h2>
</div>
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
<div class="seg" style="margin-bottom:14px">
<button type="button" aria-pressed="true">Year</button>
<button type="button" aria-pressed="false">Trend</button>
<button type="button" aria-pressed="false">Distance</button>
</div>
<dl class="tiles">
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
</dl>
<div class="tile newtile" style="margin-top:8px">
<span class="newflag">New</span>
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
<span class="sub">≈ 500 m · 6 min walk</span></dd>
<dt class="lbl">Last distance offered, 2025</dt>
</div>
</div>
<div class="card">
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
<span class="vic" aria-hidden="true">↓</span>
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
</div>
<div class="chartwrap">
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
<g stroke="#e5dfd5" stroke-width="1">
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
</g>
<g class="axtxt" text-anchor="end" font-size="9">
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
</g>
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
</g>
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
<g class="axtxt" font-size="9" text-anchor="middle">
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
</g>
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
</svg>
</div>
<details class="disclosure" style="margin-top:10px">
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
</details>
</div>
</div>
</div>
<div class="phonenote">
<h3>Mobile decisions</h3>
<ul>
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
</ul>
</div>
</div>
</div>
<script>
// Segmented control on the admissions card swaps the stacked views.
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
btn.addEventListener('click', function () {
var group = btn.closest('.seg');
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
['v-year', 'v-dist'].forEach(function (id) {
document.getElementById(id).hidden = id !== target;
});
});
});
</script>
@@ -0,0 +1,31 @@
/**
* The /api/* proxy is public. Anything it forwards is on the internet.
*
* @jest-environment node
*/
// The docblock above is load-bearing. jest.config.js sets jsdom globally, and
// NextRequest/NextResponse need the Web Fetch API globals that only the node
// environment provides — under jsdom this suite fails on import, not on an
// assertion.
import { NextRequest } from 'next/server';
import { GET } from '@/app/api/[...path]/route';
function request(path: string) {
return new NextRequest(`http://localhost:3000/api/${path}`);
}
describe('public API proxy', () => {
it('refuses to forward internal-only paths', async () => {
// /api/flags names every unreleased feature and its state. Forwarding it
// publishes the thing shipping dark exists to keep quiet.
const res = await GET(request('flags'), { params: Promise.resolve({ path: ['flags'] }) });
expect(res.status).toBe(404);
});
it('does not deny a path that merely starts with the same letters', async () => {
// A prefix match would take /api/flagship down with /api/flags.
const res = await GET(
request('flagship'), { params: Promise.resolve({ path: ['flagship'] }) });
expect(res.status).not.toBe(404);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { metadata as homeMetadata } from '@/app/page';
import { metadata as rankingsMetadata } from '@/app/rankings/page';
import { metadata as admissionsMetadata } from '@/app/admissions/page';
import { generateMetadata as compareMetadata } from '@/app/compare/page';
describe('canonical URLs', () => {
it('the homepage canonicalises to the bare root', () => {
// page.tsx reads eleven search params. Without this, every filter
// combination is a crawlable near-duplicate of the one page we want to
// rank for "compare schools".
expect(homeMetadata.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/');
});
it('rankings canonicalises to the bare path', () => {
expect(rankingsMetadata.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/rankings');
});
it('admissions canonicalises to the bare path', () => {
expect(admissionsMetadata.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/admissions');
});
});
describe('/compare indexability', () => {
it('the bare compare page is indexable and canonical to itself', async () => {
// This is the landing page for the "compare schools" head term.
const meta = await compareMetadata({ searchParams: Promise.resolve({}) });
expect(meta.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/compare');
expect(meta.robots).toBeUndefined();
});
it('a comparison of specific schools is noindex, follow', async () => {
// ~317 million pairs before triples. Indexing the parameter space would
// swamp everything else in the corpus.
const meta = await compareMetadata({
searchParams: Promise.resolve({ urns: '100001,100002' }),
});
expect(meta.robots).toEqual({ index: false, follow: true });
});
it('a parameterised comparison still canonicalises to the bare path', async () => {
// follow:true plus a canonical means the outbound links to each school
// page still pass value even though this URL is not indexed.
const meta = await compareMetadata({
searchParams: Promise.resolve({ urns: '100001,100002' }),
});
expect(meta.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/compare');
});
});
/*
* W8 — snippet copy for the C1 cluster.
*
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
* page one and converting at a tenth of the normal rate: "compare school
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
* query from the same neighbourhood. The SERP is dominated by the DfE's own
* "Compare school performance" service, so the job of this copy is to say
* what that service does not offer, without losing intent match on the title.
*
* These tests guard the mechanics that make a snippet work — length, intent
* keyword, differentiator, no brand-first — not the exact wording, which
* should stay free to iterate.
*/
// Google truncates titles near 60 characters and descriptions near 155.
const TITLE_MAX = 60;
const DESC_MIN = 110;
const DESC_MAX = 155;
type Meta = { title?: unknown; description?: unknown };
const titleOf = (m: Meta): string => {
const t = m.title as string | { absolute?: string } | undefined;
return typeof t === 'string' ? t : (t?.absolute ?? '');
};
describe('C1 snippet copy', () => {
const pages: Array<[string, Meta, RegExp]> = [
['home', homeMetadata as Meta, /compare schools/i],
['rankings', rankingsMetadata as Meta, /league table/i],
['admissions', admissionsMetadata as Meta, /admission/i],
];
for (const [name, meta, intent] of pages) {
it(`${name}: title carries the search intent and fits the SERP`, () => {
const t = titleOf(meta);
expect(t).toMatch(intent);
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
});
it(`${name}: title does not open with the brand`, () => {
// The measured 0.43% CTR came from a brand-first title. The most
// valuable pixels go to the thing the searcher typed.
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
});
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
const d = meta.description as string;
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
});
}
it('the homepage description names what gov.uk does not publish', () => {
// Admissions distance is the one fact the DfE service has no equivalent
// for. If it ever leaves this description, the snippet is competing with
// gov.uk on gov.uk's own ground.
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
});
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
// Two pages chasing one phrase is how a site competes with itself.
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
expect(m.title).toMatch(/comparison tool/i);
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
});
});
it('no C1 page claims a school count that will drift', () => {
// The corpus moves with every data refresh; this repo has already shipped
// one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
for (const [, meta] of pages) {
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
}
});
});
@@ -0,0 +1,42 @@
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
jest.mock('@/lib/places', () => ({
...jest.requireActual('@/lib/places'),
fetchPlace: jest.fn(async (kind: string, slug: string) =>
slug === 'atlantis' ? null : ({
place: { kind, slug, name: 'Brentwood', count: 29,
parent_authority: 'Essex' },
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
})),
fetchPlaces: jest.fn(async () => []),
}));
describe('place page metadata', () => {
it('titles the page the way the place is searched', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
});
it('canonicalises to its own path on the www host', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
expect(m.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
});
it('opts out of the layout template, which would double the brand', () => {
// The root layout appends '| schoolcompare' to a plain string title, and
// these titles already carry it — every place page shipped reading
// '... | schoolcompare | schoolcompare' until this was made absolute.
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
.then((m) => {
expect(typeof m.title).toBe('object');
expect((m.title as { absolute: string }).absolute)
.not.toMatch(/schoolcompare.*schoolcompare/);
});
});
it('an unknown place gets a not-found title rather than inventing one', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
expect(m.title).toMatch(/not found/i);
});
});
@@ -0,0 +1,152 @@
/**
* The postcode check.
*
* This is the one place on the site that answers a question about a specific
* family rather than about a school, so the tests here are mostly about what it
* refuses to say — and that matters more now than it did, because there is only
* one year to answer with. A run of years used to soften a single close call;
* nothing does now, so the "too close to call" band is the whole safety margin.
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
jest.mock('@/components/LeafletCutoffMapInner', () => ({
__esModule: true,
default: () => <div data-testid="cutoff-map" />,
}));
const mockGeocode = jest.fn();
jest.mock('@/lib/api', () => ({
...jest.requireActual('@/lib/api'),
geocodePostcode: (pc: string) => mockGeocode(pc),
}));
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
});
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
function northOf(metres: number) {
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
}
const renderPanel = (c = cutoff(800)) =>
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
async function check(postcode: string) {
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
}
beforeEach(() => mockGeocode.mockReset());
describe('CutoffMapPanel', () => {
it('renders nothing without a figure to compare against', () => {
const { container } = renderPanel(cutoff(null));
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when the school has no coordinates', () => {
const { container } = render(
<CutoffMapPanel
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
cutoff={cutoff(800)}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it('rejects a malformed postcode without calling the geocoder', async () => {
renderPanel();
await check('not a postcode');
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
expect(mockGeocode).not.toHaveBeenCalled();
});
it('names the year in the verdict, so the figure is never free-floating', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel(cutoff(800, 2026));
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/inside the/);
expect(result).toHaveTextContent(/September 2026/);
});
it('reports a home clearly beyond the cut-off', async () => {
mockGeocode.mockResolvedValue(northOf(5000));
renderPanel(cutoff(800));
await check('SE23 3NA');
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
});
it('declines to call a result that sits inside the measurement error', async () => {
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
// which a postcode centroid cannot resolve. With only one year published
// there is nothing else to fall back on, so this must not read as a pass.
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
renderPanel(cutoff(800));
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/too close/);
expect(result).toHaveTextContent(/measurement error/);
// Explanation is supporting text, not part of the bold verdict line.
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
.not.toMatch(/measurement error/);
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
});
it('surfaces a postcode the geocoder cannot find', async () => {
mockGeocode.mockResolvedValue(null);
renderPanel();
await check('ZZ99 9ZZ');
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
});
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel();
await check('SE23 3NA');
await screen.findByRole('status');
mockGeocode.mockRejectedValue(new Error('network'));
await check('SE23 3NB');
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
});
it('keeps the map behind a request until there is a reason to show it', async () => {
renderPanel();
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
mockGeocode.mockResolvedValue(northOf(200));
await check('SE23 3NA');
await screen.findByRole('status');
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
});
it('can also show the map without a postcode, on request', () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
});
it('states its limits before it is used, not with the answer', () => {
renderPanel();
const caveat = screen.getByText(/Distance is the last criterion applied/);
expect(caveat).toBeInTheDocument();
expect(caveat).toHaveTextContent(/not a catchment boundary/);
expect(caveat).toHaveTextContent(/walking route/);
});
});
@@ -0,0 +1,110 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterBar } from '@/components/FilterBar';
const push = jest.fn();
let searchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => searchParams,
}));
const FILTERS = {
local_authorities: [], school_types: [], years: [], phases: [],
genders: [], admissions_policies: [],
};
const realFetch = global.fetch;
beforeEach(() => {
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ suggestions: [{
urn: 100010, school_name: 'Brecknock Primary School',
local_authority: 'Camden', postcode: 'NW1 1AA',
phase: 'Primary', school_type: 'Community school' }] }),
})) as unknown as typeof fetch;
push.mockClear();
searchParams = new URLSearchParams();
});
afterEach(() => { global.fetch = realFetch; });
describe('FilterBar autosuggest', () => {
it('is a combobox only when the flag is on', () => {
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
rerender(<FilterBar filters={FILTERS} autosuggest />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('makes no request while the flag is off', async () => {
// Off means off: no listener, no fetch, no markup.
render(<FilterBar filters={FILTERS} autosuggest={false} />);
await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i),
'brecknock');
expect(global.fetch).not.toHaveBeenCalled();
});
it('shows suggestions and navigates when one is chosen', async () => {
render(<FilterBar filters={FILTERS} autosuggest />);
await userEvent.type(screen.getByRole('combobox'), 'brecknock');
const option = await screen.findByRole('option', { name: /Brecknock/ });
await userEvent.click(option);
expect(push).toHaveBeenCalledWith(
expect.stringContaining('/school/100010'));
});
it('suppresses suggestions once the value is a postcode', async () => {
// The box takes a name OR a postcode; suggestions must get out of the way.
//
// fireEvent.change, not userEvent.type: typing sets "N", "NW", "NW1"... and
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
// the settled value is the assertion, so set it in one go.
render(<FilterBar filters={FILTERS} autosuggest />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } });
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
expect(global.fetch).not.toHaveBeenCalled();
});
it('Enter with no active option still submits the free-text search', async () => {
// The existing behaviour is preserved, not replaced.
render(<FilterBar filters={FILTERS} autosuggest />);
const input = screen.getByRole('combobox');
await userEvent.type(input, 'brecknock{Enter}');
// updateURL pushes inside startTransition, so the call is not synchronous.
await waitFor(() => expect(push).toHaveBeenCalledWith(
expect.stringContaining('search=brecknock')));
});
});
describe('FilterBar autosuggest does not reopen over results', () => {
it('stays shut when the input arrives pre-filled from the URL', async () => {
/*
* The results-page bar renders with the search term already in the input.
* Opening on that would drop the dropdown on top of the results the search
* just produced — which is exactly what happened: the first result became
* unclickable, because the list sat over it and swallowed the pointer.
*
* Suggestions answer typing, not the presence of a value.
*/
searchParams = new URLSearchParams('search=brecknock');
render(<FilterBar filters={FILTERS} autosuggest />);
expect(screen.getByRole('combobox')).toHaveValue('brecknock');
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
expect(global.fetch).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('closes the dropdown when the search is submitted', async () => {
render(<FilterBar filters={FILTERS} autosuggest />);
const input = screen.getByRole('combobox');
await userEvent.type(input, 'brecknock');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
await userEvent.type(input, '{Enter}');
await waitFor(() =>
expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
});
});
@@ -0,0 +1,348 @@
import { render, screen } from '@testing-library/react';
import { PlaceView } from '@/components/places/PlaceView';
import type { PlaceDetail } from '@/lib/places';
const detail: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
parent_authority: 'Essex', phases: ['primary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
ofsted_grade: 1, phase: 'Primary' } as never,
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
ofsted_grade: 3, phase: 'Primary' } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
describe('PlaceView', () => {
it('leads with an H1 that matches how the place is searched', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByRole('heading', { level: 1 }))
.toHaveTextContent(/primary schools in brentwood/i);
});
it('states the count so the page says something before the table', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
});
it('compares the local average against England, which a list cannot', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
});
it('links every school in scope, which is what de-orphans them', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
});
it('links to the parent authority so the place sits in a hierarchy', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByRole('link', { name: /Essex/i }))
.toHaveAttribute('href', '/schools/authority/essex');
});
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
});
it('links to neighbouring places so the page is not a dead end', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
expect(screen.getByRole('link', { name: /Romford/ }))
.toHaveAttribute('href', '/schools/romford');
});
it('says nothing about an average it does not have', () => {
render(<PlaceView detail={{ ...detail, averages:
{ rwm_expected_pct: null, attainment_8_score: null } }}
phase="primary" englandAverage={61} neighbours={[]} />);
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
});
});
describe('PlaceView structured data', () => {
function jsonLd() {
const { container } = render(<PlaceView detail={detail} phase="primary"
englandAverage={61} neighbours={[]} />);
const el = container.querySelector('script[type="application/ld+json"]');
return JSON.parse(el!.textContent!);
}
it('declares the page as a ranked list, not prose', () => {
const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']);
expect(types).toContain('ItemList');
expect(types).toContain('BreadcrumbList');
});
it('gives every listed school an absolute URL on the canonical host', () => {
const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
expect(list.itemListElement).toHaveLength(2);
for (const item of list.itemListElement) {
expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//);
}
});
});
describe('PlaceView phase variants', () => {
it('links the phase variants that exist', () => {
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: /Primary schools in Brentwood/i }))
.toHaveAttribute('href', '/schools/brentwood/primary');
});
it('links no variant for a phase below its own threshold', () => {
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i }))
.not.toBeInTheDocument();
});
it('does not link sideways from a variant page to itself', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i }))
.not.toBeInTheDocument();
});
});
describe('PlaceView presentation', () => {
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
// one primary-only measure for a list that also holds secondaries.
const mixed: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
rwm_expected_pct: null, attainment_8_score: 47 } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
};
it('gives each phase its own table rather than one column of blanks', () => {
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
expect(screen.getByText('82%')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
});
it('names the measure in plain words, not jargon', () => {
// The first cut said "RWM expected", which appears nowhere else on the site.
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
});
it('says a missing result is unpublished rather than showing a bare dash', () => {
const noResult: PlaceDetail = {
...mixed,
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
rwm_expected_pct: null, attainment_8_score: null } as never],
};
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
expect(screen.getByText('Not published')).toBeInTheDocument();
});
it('styles school links to the site convention rather than browser default', () => {
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
neighbours={[]} />);
const link = container.querySelector('a[href^="/school/"]');
expect(link?.className).toBeTruthy();
});
it('a phased page shows one table and no phase headings', () => {
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
.not.toBeInTheDocument();
});
});
describe('PlaceView table alignment', () => {
const aligned: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
parent_authority: 'Essex', phases: ['primary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
it('aligns the measure heading and its values with the same class', () => {
// They were aligned by two different selectors whose specificity did not
// match: `.table th:last-child` (0,2,1) won and went right, while `.num`
// (0,1,0) lost to `.table td` (0,1,1) and stayed left. Sharing one class
// is what makes them impossible to drift apart.
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
neighbours={[]} />);
const th = container.querySelectorAll('th')[1];
const td = container.querySelectorAll('tbody td')[1];
expect(th.className).toBeTruthy();
expect(td.className).toBe(th.className);
});
it('leaves the school-name column unclassed so it takes the spare width', () => {
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
neighbours={[]} />);
expect(container.querySelectorAll('th')[0].className).toBe('');
});
});
describe('PlaceView authorities', () => {
const straddling: PlaceDetail = {
place: { kind: 'outcode', slug: 'sw19', name: 'SW19', count: 33,
parent_authority: 'Merton', phases: ['primary'],
authorities: [
{ name: 'Merton', slug: 'merton', count: 26 },
{ name: 'Wandsworth', slug: 'wandsworth', count: 7 },
] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
it('names every authority the place straddles, not just the largest', () => {
// SW19 is mostly Merton but partly Wandsworth. Naming one asserts
// something false about a quarter of outcodes.
render(<PlaceView detail={straddling} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: 'Merton' }))
.toHaveAttribute('href', '/schools/authority/merton');
expect(screen.getByRole('link', { name: 'Wandsworth' }))
.toHaveAttribute('href', '/schools/authority/wandsworth');
});
it('joins them readably rather than as a bare list', () => {
// Asserted on the summary line's whole text: a loose /and/ matcher also
// hits "Wandsworth".
const { container } = render(<PlaceView detail={straddling}
englandAverage={61} neighbours={[]} />);
const summary = container.querySelector('header p');
expect(summary?.textContent).toContain('Merton and Wandsworth');
});
it('falls back to the single parent when the field is absent', () => {
// A cached API response predating the authorities field must not blank
// the line entirely.
const legacy = { ...straddling,
place: { ...straddling.place, authorities: undefined } };
render(<PlaceView detail={legacy} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: 'Merton' })).toBeInTheDocument();
});
});
describe('PlaceView list ordering', () => {
const detail3: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
parent_authority: 'Essex', phases: ['primary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 40, attainment_8_score: null } as never,
{ urn: 2, school_name: 'Beta Primary', phase: 'Primary',
rwm_expected_pct: 90, attainment_8_score: null } as never,
],
averages: { rwm_expected_pct: 65, attainment_8_score: null },
};
it('renders schools in the order the API sent them, not by score', () => {
// The API sorts alphabetically now; the component must not re-sort.
render(<PlaceView detail={detail3} englandAverage={61} neighbours={[]} />);
const links = screen.getAllByRole('link', { name: /Primary$/ });
expect(links.map((l) => l.textContent))
.toEqual(['Alpha Primary', 'Beta Primary']);
});
it('declares the list as ascending rather than implying a ranking', () => {
// An ItemList carrying `position` reads as a ranking unless it says
// otherwise, and the table is A-Z.
const { container } = render(<PlaceView detail={detail3} englandAverage={61}
neighbours={[]} />);
const ld = JSON.parse(
container.querySelector('script[type="application/ld+json"]')!.textContent!);
const list = ld['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
expect(list.itemListOrder).toBe('https://schema.org/ItemListOrderAscending');
});
});
describe('PlaceView phase links', () => {
const authority: PlaceDetail = {
place: { kind: 'authority', slug: 'barnet', name: 'Barnet', count: 156,
parent_authority: null, phases: ['primary', 'secondary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
it('keeps an authority phase link in the authority namespace', () => {
// The link was built as `/schools/${slug}/${phase}` for every kind, so an
// authority page pointed into the town namespace. For 87 of 151
// authorities that 404'd; for the other 64 it silently landed on the town
// page of the same name — a different set of schools, and exactly the
// duplicate the two namespaces exist to prevent. Barnet is one of the 64.
render(<PlaceView detail={authority} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: /^Primary schools in Barnet$/ }))
.toHaveAttribute('href', '/schools/authority/barnet/primary');
expect(screen.getByRole('link', { name: /^Secondary schools in Barnet$/ }))
.toHaveAttribute('href', '/schools/authority/barnet/secondary');
});
it('still uses the bare namespace for a town', () => {
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: /^Primary schools in Brentwood$/ }))
.toHaveAttribute('href', '/schools/brentwood/primary');
});
it('offers no phase link when the place publishes none', () => {
// Outcodes are the case: no phase route exists for them, so the registry
// reports no phases and the nav does not render.
const outcode = { ...detail,
place: { ...detail.place, kind: 'outcode', slug: 'cm13', name: 'CM13',
phases: [] } };
render(<PlaceView detail={outcode} englandAverage={61} neighbours={[]} />);
expect(screen.queryByRole('navigation', { name: 'By phase' }))
.not.toBeInTheDocument();
});
});
describe('PlaceView unlinkable authorities', () => {
const withUnpublished: PlaceDetail = {
place: { kind: 'outcode', slug: 'tr21', name: 'TR21', count: 8,
parent_authority: 'Cornwall', phases: [],
authorities: [
{ name: 'Cornwall', slug: 'cornwall', count: 6 },
{ name: 'Isles Of Scilly', slug: null, count: 2 },
] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
it('names an authority with no page without linking it', () => {
// City of London and the Isles of Scilly hold fewer schools than a page
// needs. Saying where the place is stays right; linking there would 404.
const { container } = render(<PlaceView detail={withUnpublished}
englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('link', { name: 'Cornwall' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Isles Of Scilly' }))
.not.toBeInTheDocument();
expect(container.querySelector('header p')?.textContent)
.toContain('Isles Of Scilly');
});
});
@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import { SuggestList, suggestOptionId } from '@/components/SuggestList';
const ROWS = [
{ urn: 1, school_name: "St Mary's Primary", local_authority: 'Camden',
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Voluntary aided school' },
{ urn: 2, school_name: "St Mary's Primary", local_authority: 'Barnet',
postcode: 'EN5 2AA', phase: 'Primary', school_type: 'Community school' },
];
describe('SuggestList', () => {
it('is a listbox of options', () => {
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(2);
});
it('shows the local authority, which is what tells two schools apart', () => {
// Both rows are "St Mary's Primary". Without the authority the list is
// unusable for exactly the query autosuggest exists to serve.
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getByText('Camden')).toBeInTheDocument();
expect(screen.getByText('Barnet')).toBeInTheDocument();
});
it('marks only the active option selected', () => {
render(<SuggestList id="s" suggestions={ROWS} activeIndex={1}
onPick={() => {}} onHover={() => {}} />);
const options = screen.getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'false');
expect(options[1]).toHaveAttribute('aria-selected', 'true');
});
it('gives each option the id the input will point at', () => {
// aria-activedescendant on the input has to name a real element id, or
// a screen reader announces nothing as the user arrows through.
render(<SuggestList id="s" suggestions={ROWS} activeIndex={0}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getAllByRole('option')[0]).toHaveAttribute(
'id', suggestOptionId('s', 0));
});
it('renders nothing when there is nothing to suggest', () => {
const { container } = render(<SuggestList id="s" suggestions={[]}
activeIndex={-1} onPick={() => {}} onHover={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,45 @@
import { render } from '@testing-library/react';
import { TrackPlaceView } from '@/components/places/TrackPlaceView';
const trackMock = jest.fn();
jest.mock('@/lib/analytics', () => ({
track: (...args: unknown[]) => trackMock(...args),
getNavigationSource: () => 'search',
}));
describe('TrackPlaceView', () => {
beforeEach(() => trackMock.mockClear());
it('reports which kind of location page was viewed', () => {
/*
* `kind` is the reason this event exists. Whether to keep investing in the
* location layer turns on which *sort* of page earns engagement — towns,
* authorities or postcode districts — and a bare pageview cannot say,
* because all four families share the /schools/ prefix.
*/
render(<TrackPlaceView kind="authority" slug="kent" count={412} />);
expect(trackMock).toHaveBeenCalledWith('place_viewed', {
kind: 'authority', slug: 'kent', phase: 'all',
school_count: 412, from: 'search',
});
});
it('names the phase when the page is a phase variant', () => {
render(<TrackPlaceView kind="town" slug="brentwood" count={29} phase="primary" />);
expect(trackMock).toHaveBeenCalledWith('place_viewed',
expect.objectContaining({ phase: 'primary' }));
});
it('fires once, not once per render', () => {
const { rerender } = render(
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
rerender(<TrackPlaceView kind="town" slug="brentwood" count={29} />);
expect(trackMock).toHaveBeenCalledTimes(1);
});
it('renders nothing', () => {
const { container } = render(
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,78 @@
import fs from 'fs';
import path from 'path';
/**
* Guards against light-theme-only CSS.
*
* The site themes entirely through tokens redefined under
* `@media (prefers-color-scheme: dark)`. A hardcoded colour therefore does not
* fail loudly — it renders perfectly in the theme it was written for and
* quietly wrongly in the other, which nobody sees unless they happen to be in
* dark mode when they look.
*
* Both rules below are drawn from real defects in SchoolHeroMap.module.css,
* found by eye rather than by any test:
*
* - the map's fade to the header ramped through hardcoded white and landed on
* `var(--bg-card)`. Invisible in light; a bright band across the full width
* of a near-black card in dark.
* - the controls floating over the map paired a hardcoded white background
* with `color: var(--text-primary)`, which resolves to #E9EEF0 in dark —
* near-white text on a near-white button.
*/
const COMPONENTS = path.join(__dirname, '..', '..', 'components');
function stylesheets(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return stylesheets(full);
return entry.name.endsWith('.module.css') ? [full] : [];
});
}
/** Innermost `selector { body }` pairs. Nested at-rules never match as rules,
* because their body contains braces. */
function rules(css: string): Array<{ selector: string; body: string }> {
return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
selector: m[1].trim().split('\n').pop()!.trim(),
body: m[2],
}));
}
const HARDCODED_WHITE_BG = /background[^;]*(?:255,\s*255,\s*255|#fff\b|#ffffff\b)/i;
const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/;
const files = stylesheets(COMPONENTS);
describe('dark-theme safety', () => {
it('finds stylesheets to check', () => {
expect(files.length).toBeGreaterThan(0);
});
it('never pairs a hardcoded white background with a themed text colour', () => {
const offenders = files.flatMap((file) =>
rules(fs.readFileSync(file, 'utf8'))
.filter((r) => HARDCODED_WHITE_BG.test(r.body) && THEMED_COLOR.test(r.body))
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
// Either the surface follows the theme and so should the text, or it does
// not and the text must be literal too. Mixing them is how near-white text
// ends up on a near-white button.
expect(offenders).toEqual([]);
});
it('never fades to a themed colour through a hardcoded one', () => {
const offenders = files.flatMap((file) =>
rules(fs.readFileSync(file, 'utf8'))
.filter((r) => /linear-gradient/.test(r.body)
&& /var\(--bg-(card|primary|secondary)\)/.test(r.body)
&& /255,\s*255,\s*255|#fff\b/i.test(r.body))
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
// A gradient that lands on a token has to be made of that token, or the
// ramp and its destination disagree in one theme. Use the matching
// `--*-rgb` token for the transparent stops.
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,108 @@
import fs from 'fs';
import path from 'path';
/**
* The hero search and the results filter bar are the same component in two
* costumes. `.filterBar` is the card — background, border, shadow, padding —
* and `.heroMode` strips all of it so the search sits directly on the hero
* panel.
*
* Both selectors have specificity (0,1,0), so **source order decides**, and
* `.heroMode` only wins because it is declared immediately after. Any later
* bare `.filterBar` rule — which in practice means one inside a media query —
* silently wins instead, and the hero grows a card's padding back.
*
* That is exactly what happened: `@media (max-width: 768px) { .filterBar {
* padding: 0.875rem } }` re-added 14px in hero mode, indenting the search box,
* the hint and the location link 14px past the headline above them and costing
* the search field 28px of width on a 390px screen. The two rules directly
* below it in the same block were correctly written as
* `.filterBar:not(.heroMode)`; this one was missed, and nothing caught it
* because the result is a plausible-looking layout rather than a broken one.
*/
const CSS = path.join(__dirname, '..', '..', 'components', 'FilterBar.module.css');
/** Properties `.heroMode` resets. A later bare `.filterBar` rule setting any
* of these puts the card back on the hero. */
const RESET_BY_HERO_MODE = [
'background', 'border', 'border-radius', 'box-shadow', 'padding',
];
/**
* Comments are stripped before anything is parsed.
*
* A `{` or `}` inside a comment would otherwise desynchronise the brace walk
* below and the rule regex alike, and the selector text captured for each rule
* would carry the preceding comment along with it.
*/
function withoutComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, '');
}
/**
* The individual selectors in a rule's prelude.
*
* Split on commas, because a selector list is a list: `.filterBar, .other { }`
* applies to `.filterBar` just as surely as `.filterBar { }` does, and an
* earlier version of this guard compared the whole prelude against the literal
* string '.filterBar' — so writing the regression as a comma list, or across
* two lines, would have walked straight past it.
*/
function selectorsOf(prelude: string): string[] {
return prelude.split(',').map((sel) => sel.trim().replace(/\s+/g, ' '))
.filter(Boolean);
}
function mediaQueryBodies(css: string): string[] {
const bodies: string[] = [];
const re = /@media[^{]*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(css)) !== null) {
// Walk braces from the opening one to find this at-rule's whole body.
let depth = 1;
let i = m.index + m[0].length;
const start = i;
while (i < css.length && depth > 0) {
if (css[i] === '{') depth++;
else if (css[i] === '}') depth--;
i++;
}
bodies.push(css.slice(start, i - 1));
}
return bodies;
}
describe('FilterBar hero-mode scoping', () => {
const css = withoutComments(fs.readFileSync(CSS, 'utf8'));
it('confirms heroMode still resets the card, which is what makes this matter', () => {
const hero = css.match(/\.heroMode\s*\{([^}]*)\}/);
expect(hero).not.toBeNull();
expect(hero![1]).toMatch(/padding:\s*0/);
});
it('never re-applies card styling to the hero from inside a media query', () => {
const offenders: string[] = [];
for (const body of mediaQueryBodies(css)) {
for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
// Only a *bare* .filterBar is dangerous, and it is dangerous wherever
// it appears in a selector list. Scoped variants
// (`.filterBar:not(.heroMode)`) and descendants are fine.
const selectors = selectorsOf(rule[1]);
if (!selectors.includes('.filterBar')) continue;
for (const prop of RESET_BY_HERO_MODE) {
if (new RegExp(`(^|[;\\s])${prop}\\s*:`).test(rule[2])) {
offenders.push(`${rule[1].trim()} sets ${prop}`);
}
}
}
}
// Fix by scoping the rule as `.filterBar:not(.heroMode)`, the way the
// neighbouring rules in the same block already are.
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,191 @@
/**
* Last distance offered, rendered on both detail templates.
*
* The figure is the one number on these pages that a parent may act on — it is
* easy to read as "we live inside the catchment, we will get a place". These
* tests pin the things that stop it being read that way: the year is always
* present, the caveat is always present, and the blanket "not available"
* sentence appears only when it is actually true.
*/
import { screen, fireEvent } from '@testing-library/react';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
import type { SchoolAdmissionDistance } from '@/lib/types';
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
year: 2025,
distance_m: 500,
route_count: 1,
la_name: 'Camden',
distance_unit_raw: 'miles',
...over,
});
describe('primary detail page', () => {
it('shows the figure with the year it belongs to', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
});
it('keeps the figure and its metric support readable as two numbers', () => {
// They are flex children with a CSS gap and nothing between them in the
// text layer, which read as "0.48 miles770 m" to a screen reader and to any
// text matcher. Cheap to lose again, so pinned.
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49 }) });
const tile = document.querySelector('[class*="admissionsTileDistance"]')!;
expect(tile.textContent).toMatch(/0\.48 miles\s+770 m/);
expect(tile.textContent).not.toMatch(/miles\d/);
});
it('never shows the figure without saying it is not a catchment', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
});
it('flags that a banded school\'s figure is the widest of several routes', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
});
it('renders nothing distance-related when the LA publishes none', () => {
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
});
it('carries the figure even with no EES admissions row', () => {
// The two sources are independent; this school has a cut-off and no
// admissions figures. Before this feature the section did not render at all.
renderSchoolDetail({
...primaryFixture,
admissions: null,
admissionsHistory: [],
admissionDistance: cutoff({ distance_m: 1421.05 }),
});
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
});
});
describe('secondary detail page', () => {
it('shows the figure with the year it belongs to', () => {
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
});
it('drops the blanket "not available" line once a distance exists', () => {
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
});
it('names the authority that would hold the data when there is none', () => {
// The old copy asserted "Historical distance cut-off data is not available
// for this school" on every secondary page, including the ones whose
// council does publish it.
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
});
});
// ── The Distance section ───────────────────────────────────────────────
describe('Distance section', () => {
it('appears for a school with a figure and coordinates', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
expect(container.querySelector('#distance')).toBeInTheDocument();
expect(screen.getByText('How far away are you?')).toBeInTheDocument();
});
it('stays away when the school has no coordinates to measure from', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
schoolInfo: { ...primaryFixture.schoolInfo, latitude: null, longitude: null },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
expect(container.querySelector('#distance')).not.toBeInTheDocument();
});
it('stays away when no figure has been published', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: null,
});
expect(container.querySelector('#distance')).not.toBeInTheDocument();
});
it('shows no year-by-year record — that is held back as a paid feature', () => {
// The page must not leak the history through a table, a chart or a strip of
// per-year verdicts. The API no longer sends it either; this guards the
// render side so a future component cannot quietly put it back.
renderSchoolDetail({
...primaryFixture,
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
// Scoped to the section: the page has other tables (the history section's).
const section = document.querySelector('#distance')!;
expect(section.querySelector('table')).toBeNull();
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
expect(screen.queryByText(/Not published/)).not.toBeInTheDocument();
expect(screen.queryByText(/too few to read as a trend/)).not.toBeInTheDocument();
});
});
describe('secondary Distance section', () => {
it('renders on the secondary template too', () => {
const { container } = renderSecondarySchoolDetail({
...secondaryFixture,
schoolInfo: { ...secondaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 3472.96, year: 2026 }),
});
expect(container.querySelector('#distance')).toBeInTheDocument();
});
it('explains a selective school by how it admits rather than as missing data', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
admissionDistance: null,
});
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
});
it('reads a consistently undersubscribed school as good news', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
admissionDistance: null,
admissionsHistory: [
{ year: 2022, oversubscribed: false },
{ year: 2023, oversubscribed: false },
{ year: 2024, oversubscribed: false },
],
});
expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
const realFetch = global.fetch;
function mockFetch(rows: unknown[], delayMs = 0) {
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
if (delayMs) {
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, delayMs);
init?.signal?.addEventListener('abort', () => {
clearTimeout(t);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
});
}
return { ok: true, json: async () => ({ suggestions: rows }) };
}) as unknown as typeof fetch;
}
const ROW = {
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
};
describe('useSchoolSuggest', () => {
beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
it('does not fetch below the minimum query length', () => {
mockFetch([ROW]);
renderHook(() => useSchoolSuggest('b', true));
act(() => { jest.advanceTimersByTime(500); });
expect(global.fetch).not.toHaveBeenCalled();
});
it('does not fetch at all when disabled', () => {
// The flag being off must mean no request, not a hidden dropdown.
mockFetch([ROW]);
renderHook(() => useSchoolSuggest('brecknock', false));
act(() => { jest.advanceTimersByTime(500); });
expect(global.fetch).not.toHaveBeenCalled();
});
it('debounces rather than firing per keystroke', () => {
mockFetch([ROW]);
const { rerender } = renderHook(
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
rerender({ q: 'bre' });
rerender({ q: 'brec' });
act(() => { jest.advanceTimersByTime(199); });
expect(global.fetch).not.toHaveBeenCalled();
act(() => { jest.advanceTimersByTime(2); });
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('opens with results once they arrive', async () => {
mockFetch([ROW]);
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
act(() => { jest.advanceTimersByTime(200); });
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
expect(result.current.open).toBe(true);
});
it('close() hides the list without clearing the query', async () => {
mockFetch([ROW]);
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
act(() => { jest.advanceTimersByTime(200); });
await waitFor(() => expect(result.current.open).toBe(true));
act(() => { result.current.close(); });
expect(result.current.open).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { getNavigationSource } from '@/lib/analytics';
/** jsdom's document.referrer is read-only; redefining it is the way in. */
function referrer(url: string) {
Object.defineProperty(document, 'referrer', { value: url, configurable: true });
}
const ORIGIN = 'http://localhost';
describe('getNavigationSource', () => {
afterEach(() => referrer(''));
it('attributes a visit from a location page to the place layer', () => {
/*
* The one this was added for.
*
* W2 published ~3,900 location pages whose entire purpose is to funnel
* search traffic onto school pages. Before this case existed they fell
* through to 'direct' — so the location layer's contribution was not
* merely missing from the funnel, it was being counted in the bucket you
* read as "typed the URL". The measurement that decides whether W2 worked
* was confidently reporting the wrong answer.
*/
referrer(`${ORIGIN}/schools/barnet`);
expect(getNavigationSource()).toBe('place');
});
it.each([
['/schools/authority/kent', 'authority'],
['/schools/near/sw11', 'outcode'],
['/schools/brentwood/primary', 'phase variant'],
])('covers %s (%s)', (path) => {
referrer(`${ORIGIN}${path}`);
expect(getNavigationSource()).toBe('place');
});
it('still calls a school page "detail", one character away', () => {
// /school/ and /schools/ differ by one letter and mean different things.
// A prefix test written in the wrong order silently merges them.
referrer(`${ORIGIN}/school/100010-brecknock-primary-school`);
expect(getNavigationSource()).toBe('detail');
});
it.each([
['/', 'search'],
['/rankings', 'rankings'],
['/compare?urns=1,2', 'compare'],
])('leaves %s attributed as %s', (path, expected) => {
referrer(`${ORIGIN}${path}`);
expect(getNavigationSource()).toBe(expected);
});
it('treats an external referrer as direct', () => {
// Umami records the real referrer on the pageview; this field is only
// about internal navigation.
referrer('https://www.google.com/search?q=schools+in+barnet');
expect(getNavigationSource()).toBe('direct');
});
it('treats no referrer as direct', () => {
referrer('');
expect(getNavigationSource()).toBe('direct');
});
});
+34
View File
@@ -0,0 +1,34 @@
import { getFlags } from '@/lib/flags';
// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach
// to — assign it and restore the original afterwards. This is the first test
// here to mock fetch; later ones should follow this shape.
const realFetch = global.fetch;
function mockFetch(impl: () => Promise<unknown>) {
global.fetch = jest.fn(impl) as unknown as typeof fetch;
}
describe('getFlags', () => {
afterEach(() => { global.fetch = realFetch; });
it('returns the flags the API reports', async () => {
mockFetch(async () => ({
ok: true,
json: async () => ({ admission_distance: true }),
}));
await expect(getFlags()).resolves.toEqual({ admission_distance: true });
});
it('returns no flags rather than throwing when the API is down', async () => {
// A page that cannot read flags must render everything dark, not 500.
// Fail-closed is the same direction as the backend's default.
mockFetch(async () => { throw new Error('ECONNREFUSED'); });
await expect(getFlags()).resolves.toEqual({});
});
it('returns no flags rather than throwing on a non-200', async () => {
mockFetch(async () => ({ ok: false, status: 503 }));
await expect(getFlags()).resolves.toEqual({});
});
});
@@ -0,0 +1,182 @@
/**
* Last distance offered — formatting and the caveats attached to the figure.
*
* The assertions about the route note and the year are not cosmetic. A cut-off
* shown without its year, or a banded school's widest cut-off shown as if it
* were the only one, tells a parent something false about their chances of a
* place — so both are pinned here rather than left to the component.
*/
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
import {
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
} from '@/components/school/lastDistanceOffered';
import type { SchoolAdmissionDistance } from '@/lib/types';
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
year: 2025,
distance_m: 500,
route_count: 1,
la_name: 'Camden',
distance_unit_raw: 'miles',
...over,
});
describe('formatCutoffDistance', () => {
it('leads with miles, the unit councils publish in', () => {
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
});
it('switches to kilometres for the support figure above a kilometre', () => {
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
});
it('stays in miles at short range, where it used to swap to metres', () => {
// The swap made a single number easier to read and a comparison harder:
// "69 m away — inside the cut-off of 0.17 miles" asked the reader to
// convert between units to check a claim we had already made for them.
expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' });
expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/);
});
it('describes a distance too short for two decimal places', () => {
// Rather than a flat "0.00 miles", which reads as no distance at all.
expect(formatMiles(5)).toBe('under 0.01 miles');
expect(formatMiles(0)).toBe('under 0.01 miles');
expect(formatMiles(20)).toBe('0.01 miles');
});
it('returns null rather than a zero cut-off', () => {
// 0.0 miles appears in the source where a school filled on a higher
// criterion. Rendered as "0.00 miles" it would read as the opposite.
expect(formatCutoffDistance(0)).toBeNull();
expect(formatCutoffDistance(null)).toBeNull();
expect(formatCutoffDistance(undefined)).toBeNull();
expect(formatCutoffDistance(Number.NaN)).toBeNull();
});
});
describe('formatEntryYear', () => {
it('names the intake, not the academic year', () => {
// formatAcademicYear would render 2025 as "2025/26", which reads as a
// school year rather than the September a child started.
expect(formatEntryYear(2025)).toBe('September 2025');
expect(formatEntryYear(null)).toBe('');
});
});
describe('describeCutoff', () => {
it('always carries the entry year alongside the figure', () => {
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
expect(d).not.toBeNull();
expect(d!.primary).toBe('0.48 miles');
expect(d!.entryYear).toBe('September 2024');
});
it('says nothing about routes for a school with one', () => {
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
});
it('warns that a banded school\'s figure is the widest of several', () => {
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
expect(note).toContain('4 admission routes');
expect(note).toContain('shorter cut-off');
});
it('is null when there is nothing publishable', () => {
expect(describeCutoff(null)).toBeNull();
expect(describeCutoff(undefined)).toBeNull();
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
});
});
// ── "Would we have got in?" ────────────────────────────────────────────
describe('compareToCutoff', () => {
it('never states the two figures in different units', () => {
/*
* The reported defect: "69 m away — inside the September 2026 cut-off of
* 0.17 miles". Both numbers are correct and the sentence is still useless,
* because checking it means converting one of them.
*
* Swept across the range where the old formatter switched units, so a
* future readability tweak to one figure cannot reintroduce the mismatch
* in the other.
*/
const mixed: string[] = [];
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
const hasMetres = /\d\s?m\b/.test(headline);
const milesCount = (headline.match(/miles/g) ?? []).length;
// Two figures, both in miles, and no metric reading anywhere near them.
if (hasMetres || milesCount !== 2) {
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
}
}
}
expect(mixed).toEqual([]);
});
it('reads back the reported case in one unit', () => {
expect(compareToCutoff(69, 270, 2026).headline)
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
});
it('calls a clearly nearer home inside, and names the year', () => {
const r = compareToCutoff(300, 800, 2026);
expect(r.verdict).toBe('inside');
expect(r.headline).toContain('September 2026');
});
it('calls a clearly further home beyond', () => {
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
});
it('refuses to call a result inside the measurement error, either way', () => {
// A postcode centroid covers several addresses, so a margin this fine is
// noise. With one published year there is no other year to fall back on,
// which makes this band the only thing standing between a parent and a
// place they do not have.
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
// The explanation is not welded to the headline, so it does not run at
// headline weight in the result block.
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
});
it('treats the band as exclusive at its edge', () => {
// Exactly on the boundary is still too close; one metre past it is not.
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
});
});
describe('describeCutoffAbsence', () => {
it('explains a selective school by how it admits, not as missing data', () => {
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
expect(s).toContain('entrance test');
expect(s).not.toContain('has not published');
});
it('reads a consistently undersubscribed school as good news', () => {
const s = describeCutoffAbsence({
localAuthority: 'Camden',
admissionsHistory: [
{ year: 2022, oversubscribed: false },
{ year: 2023, oversubscribed: false },
{ year: 2024, oversubscribed: false },
],
});
expect(s).toContain('has not needed a distance cut-off');
});
it('otherwise names the authority that would hold the figure', () => {
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
.toContain('Camden has not published');
});
});
@@ -60,7 +60,7 @@ describe('buildNavItems', () => {
it('omits sections with no data', () => {
const flags = computeSchoolFlags(specialFixture);
const ids = buildNavItems(flags, {
ofsted: null, admissions: null, yearlyDataLength: 1,
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).not.toContain('ofsted');
@@ -74,6 +74,7 @@ describe('buildNavItems', () => {
return buildNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: null,
yearlyDataLength: fixture.yearlyData.length,
}).find((n) => n.id === 'results')?.label;
};
@@ -83,11 +84,27 @@ describe('buildNavItems', () => {
expect(label(allThroughFixture)).toBe('Results');
});
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
// 3% of the schools that render have one source and not the other. The nav
// condition and the section's render condition have to agree, or the sticky
// nav links to an anchor that was never rendered.
const flags = computeSchoolFlags(specialFixture);
const ids = buildNavItems(flags, {
ofsted: null,
admissions: null,
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).toContain('admissions');
});
it('keeps the engagement-led ordering', () => {
const flags = computeSchoolFlags(primaryFixture);
const ids = buildNavItems(flags, {
ofsted: primaryFixture.ofsted,
admissions: primaryFixture.admissions,
admissionDistance: null,
yearlyDataLength: primaryFixture.yearlyData.length,
}).map((n) => n.id);
@@ -127,16 +144,29 @@ describe('buildSecondaryNavItems', () => {
const ids = buildSecondaryNavItems(flags, {
ofsted: secondaryFixture.ofsted,
admissions: secondaryFixture.admissions,
admissionDistance: null,
yearlyDataLength: secondaryFixture.yearlyData.length,
}).map((n) => n.id);
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
});
it('opens the admissions entry for a cut-off distance alone', () => {
const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, {
ofsted: null,
admissions: null,
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).toContain('admissions');
});
it('gates History on more than one year, unlike the primary page', () => {
const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, {
ofsted: null, admissions: null, yearlyDataLength: 1,
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).not.toContain('history');
+27
View File
@@ -0,0 +1,27 @@
import { SITE_URL, absoluteUrl } from '@/lib/site';
describe('SITE_URL', () => {
it('is the www host, which is the one that serves a 200', () => {
// The apex 301s to www at Cloudflare. A canonical pointing at a redirect
// is a wasted signal, so every absolute URL we emit must already be www.
expect(SITE_URL).toBe('https://www.schoolcompare.co.uk');
});
it('has no trailing slash, so joins never double up', () => {
expect(SITE_URL.endsWith('/')).toBe(false);
});
});
describe('absoluteUrl', () => {
it('joins a rooted path', () => {
expect(absoluteUrl('/rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
});
it('joins a path missing its leading slash', () => {
expect(absoluteUrl('rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
});
it('maps the site root to a bare trailing slash', () => {
expect(absoluteUrl('/')).toBe('https://www.schoolcompare.co.uk/');
});
});
@@ -31,6 +31,7 @@ export function renderSchoolDetail(fixture: any) {
const navItems = buildNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length,
});
@@ -44,6 +45,7 @@ export function renderSchoolDetail(fixture: any) {
>
<PrimarySchoolSections
{...fixture}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
@@ -57,6 +59,7 @@ export function renderSecondarySchoolDetail(fixture: any) {
const navItems = buildSecondaryNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length,
});
@@ -70,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
>
<SecondarySchoolSections
{...fixture}
admissionsHistory={fixture.admissionsHistory ?? []}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
+6 -2
View File
@@ -1,12 +1,16 @@
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
import { AdmissionsView } from '@/components/AdmissionsView';
export const dynamic = 'force-static';
export const metadata: Metadata = {
title: 'School Admissions Guide',
// Deadlines and offer days are what gets searched, and what this page is
// genuinely best at — the countdowns are live.
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
description:
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
alternates: { canonical: absoluteUrl('/admissions') },
};
export default function AdmissionsPage() {
+19
View File
@@ -26,8 +26,27 @@ function backendBase(): string {
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/*
* API paths this public proxy must not forward.
*
* Matched on the first segment, exactly — a prefix match would take
* /api/flagship down with /api/flags.
*
* `flags` is here because GET /api/flags names every unreleased feature the
* codebase knows about, along with whether it is on. Publishing that defeats
* the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
* the Docker network, which never transits this route.
*
* Anything else internal-only belongs here too.
*/
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
}
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
const headers = new Headers(req.headers);
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

-34
View File
@@ -1,34 +0,0 @@
import { ImageResponse } from 'next/og';
import { markDataUri } from '@/components/Logo';
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
// with no transparency (it composites its own rounded mask). Previously this
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
//
// Teal ground with the mark knocked out in white, matching the app icon in the
// guideline's "brand in action" row.
export const size = { width: 180, height: 180 };
export const contentType = 'image/png';
const TEAL = '#0F766E';
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: TEAL,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={markDataUri('#FFFFFF', TEAL)} width={116} height={116} alt="" />
</div>
),
size
);
}
+31 -7
View File
@@ -5,6 +5,7 @@
import { fetchComparison, fetchMetrics } from '@/lib/api';
import { ComparisonView } from '@/components/ComparisonView';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
interface ComparePageProps {
@@ -14,13 +15,36 @@ interface ComparePageProps {
}>;
}
export const metadata: Metadata = {
title: 'Compare Schools',
description:
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
keywords:
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
};
/**
* Indexability depends on the query string, so this cannot be a static export.
*
* Bare /compare is the landing page for the "compare schools" head term and
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
* keeps a canonical to the bare path, so the links out to each school page
* still count.
*/
export async function generateMetadata(
{ searchParams }: ComparePageProps,
): Promise<Metadata> {
const { urns } = await searchParams;
const base: Metadata = {
// Deliberately not the homepage's phrase. Two pages chasing "compare
// schools" is how a site competes with itself; this one takes the tool
// phrasing instead.
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
description:
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
keywords:
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
alternates: { canonical: absoluteUrl('/compare') },
};
if (!urns) return base;
return { ...base, robots: { index: false, follow: true } };
}
// Dynamic via searchParams; remove force-dynamic so internal data fetches
// can still use Next.js's per-call revalidate cache.
+46 -2
View File
@@ -28,6 +28,9 @@
--bg-primary: #FAFAF8; /* Warm White */
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
--bg-card: #FFFFFF;
/* For gradients that have to fade to the card colour. A hardcoded white
ramp reads as a bright band against a dark card. */
--bg-card-rgb: 255, 255, 255;
--surface-inverse: #0F766E;
/* ── Ink ────────────────────────────────────────────────────────── */
@@ -178,6 +181,33 @@
--step-4: 2.5rem;
--step-5: 3rem;
/* ── Section rhythm ─────────────────────────────────────────────────
The vertical gap between the bands of a page, and the gap between a
band's header and its content. Two values, not seven: the landing page
previously set its own margin on every band (24 / 32 / 16 / 48px, no
scale), which is what made a designed page read as a stack of unrelated
strips. Bands must not set their own vertical margins — the page
container owns the gap. */
--section-gap: 4rem;
--section-head-gap: 1.5rem;
/* ── Hero artwork ───────────────────────────────────────────────────
The landing hero is now a supplied raster illustration rather than a
drawn SVG, so its colours are in the file, not here. What is left is the
ground the artwork sits on and fades into.
--hero-ground is sampled from the artwork's own copy area, not from
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
seam straight down the hero.
The artwork's copy area is not one flat colour: it runs from a peach
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream. This value is sampled from
the middle of that pale run, where the headline and search actually sit;
the scrim is what covers the foliage further down. */
--hero-ground: #FEF2E1;
--hero-ground-rgb: 254, 242, 225;
/* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly
rounder than the old system's 3/6/10/16. */
@@ -207,6 +237,7 @@
--bg-primary: #111A20;
--bg-secondary: #16222A;
--bg-card: #18242C;
--bg-card-rgb: 24, 36, 44;
--surface-inverse: #E9EEF0;
--text-primary: #E9EEF0;
@@ -302,6 +333,13 @@
--medal-silver-rgb: 169, 182, 188;
--medal-bronze-rgb: 201, 144, 112;
/* The artwork is a fixed, bright raster — it cannot be re-graded by
token the way the drawn version was. The dark theme instead dims it
in CSS (see .heroArt in HomeView.module.css) and fades it into this
ground, which is the panel colour rather than the artwork's cream. */
--hero-ground: #16222A;
--hero-ground-rgb: 22, 34, 42;
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
@@ -472,7 +510,7 @@ table,
border-color: var(--action-stronger);
}
/* Secondary: brand outline — supporting actions (Add to shortlist) */
/* Secondary: brand outline — supporting actions (Add to compare) */
.btn-secondary {
background: transparent;
color: var(--brand);
@@ -493,7 +531,7 @@ table,
color: var(--text-primary);
}
/* Active toggle state — the shortlist button once a school is on the list */
/* Active toggle state — the compare button once a school is on the list */
.btn-active {
background: var(--brand-bg);
color: var(--brand);
@@ -563,6 +601,12 @@ html .leaflet-bar a:hover {
.main {
padding: 1rem;
}
/* Tighten the rhythm rather than abandon it — the ratio between the
section gap and the header gap stays the same. */
:root {
--section-gap: 2.75rem;
--section-head-gap: 1.15rem;
}
}
/* Honour the OS setting. Transitions collapse to near-instant rather than
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 30 C15 35.6 11.6 40.2 7.8 42.8" stroke="#0F766E" stroke-width="8.5" stroke-linecap="round" fill="none"/>
<circle cx="26.5" cy="18.5" r="16.5" fill="#0F766E"/>
<path d="M26.5 29.5 C26.5 25 26.6 21 27 18" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<path d="M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z" fill="#FFFFFF"/>
<path d="M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 594 B

+12 -8
View File
@@ -5,6 +5,7 @@ import { Navigation } from '@/components/Navigation';
import { Footer } from '@/components/Footer';
import { ComparisonToast } from '@/components/ComparisonToast';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import { SITE_URL } from '@/lib/site';
import './globals.css';
// Manrope carries headings and key messaging — the guideline's "friendly,
@@ -47,29 +48,32 @@ export const metadata: Metadata = {
statusBarStyle: 'default',
},
title: {
default: 'schoolcompare | Compare School Performance',
default: 'Compare Schools Side by Side | schoolcompare',
template: '%s | schoolcompare',
},
description: 'Compare primary and secondary school SATs and GCSE performance across England',
description:
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
authors: [{ name: 'schoolcompare' }],
manifest: '/manifest.json',
// No `icons` key on purpose: setting it here would override the file
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
// app/opengraph-image.tsx supplies og:image and twitter:image.
metadataBase: new URL('https://schoolcompare.co.uk'),
metadataBase: new URL(SITE_URL),
openGraph: {
type: 'website',
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
url: 'https://schoolcompare.co.uk',
title: 'Compare Schools Side by Side | schoolcompare',
description:
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
url: SITE_URL,
siteName: 'schoolcompare',
},
twitter: {
// summary_large_image now that there is an image worth showing.
card: 'summary_large_image',
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
title: 'Compare Schools Side by Side | schoolcompare',
description:
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
},
};
+15 -3
View File
@@ -1,7 +1,6 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ImageResponse } from 'next/og';
import { markDataUri } from '@/components/Logo';
/**
* The site had no og:image at all, so every link pasted into a class WhatsApp
@@ -28,10 +27,23 @@ async function font(file: string) {
return readFile(join(process.cwd(), 'assets', file));
}
/**
* The mark, as a data URI. Satori has no access to the public/ URL space, so
* the artwork is read off disk and inlined. This is the same file the header
* serves — public/brand/mark.png — so the card can never drift from the site.
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
* this has to move with it, or Satori will stretch it.
*/
async function markDataUri() {
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
return `data:image/png;base64,${png.toString('base64')}`;
}
export default async function OpengraphImage() {
const [regular, bold] = await Promise.all([
const [regular, bold, mark] = await Promise.all([
font('Manrope-Regular.ttf'),
font('Manrope-Bold.ttf'),
markDataUri(),
]);
return new ImageResponse(
@@ -62,7 +74,7 @@ export default async function OpengraphImage() {
{/* Lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={markDataUri(TEAL, SAND)} width={52} height={52} alt="" />
<img src={mark} width={47} height={56} alt="" />
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
<span style={{ color: INK }}>school</span>
<span style={{ color: TEAL }}>compare</span>
+57 -6
View File
@@ -3,8 +3,12 @@
* Main landing page with school search and browsing
*/
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { formatAcademicYear } from '@/lib/utils';
import { HomeView } from '@/components/HomeView';
import { getFlags } from '@/lib/flags';
import { HowItWorksSection } from '@/components/HowItWorksSection';
import { EditorialSection } from '@/components/EditorialSection';
@@ -24,9 +28,32 @@ interface HomePageProps {
}>;
}
export const metadata = {
title: 'Home',
description: 'Search and compare school performance across England',
/*
* `title` was the bare string 'Home', which is what the browser tab, the
* bookmark and the search result all read. `absolute` opts out of the root
* layout's "%s | schoolcompare" template so the homepage doesn't end up
* saying the brand twice.
*/
export const metadata: Metadata = {
/*
* Intent in the title, differentiator in the description.
*
* These queries are owned by the DfE's own "Compare school performance"
* service, and the old title — brand first, then a near-paraphrase of that
* service's name — gave a searcher no reason to pick us over it. It drew
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
* neighbourhood, so the ranking was never the problem.
*
* The title now matches what people type. The description carries the one
* fact gov.uk does not publish: how close you had to live to get a place.
*/
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
description:
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
// This page reads eleven search params. They filter a result set; they do
// not make a new document. Collapsing every combination onto "/" stops the
// homepage competing with itself for its own head terms.
alternates: { canonical: absoluteUrl('/') },
};
// The page reads searchParams, which makes rendering dynamic by default.
@@ -37,6 +64,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// Await search params (Next.js 15 requirement)
const params = await searchParams;
// Server-read: no flag value reaches the browser bundle. Threaded down to
// both FilterBar instances via HomeView.
const flags = await getFlags();
const autosuggest = flags.school_autosuggest === true;
// Parse search params
const page = parseInt(params.page || '1');
const radius = params.radius ? parseFloat(params.radius) : undefined;
@@ -79,14 +111,25 @@ export default async function HomePage({ searchParams }: HomePageProps) {
}
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
const total = dataInfo?.total_schools ?? null;
// `unique_schools`, not `total_schools` — the latter is not a field this
// endpoint returns, and reading it silently yielded null on every request.
const total = dataInfo?.unique_schools ?? null;
const years = dataInfo?.years_available ?? [];
return (
<HomeView
autosuggest={autosuggest}
initialSchools={schoolsData}
filters={resolvedFilters}
totalSchools={total}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={total}
localAuthorityCount={resolvedFilters.local_authorities.length}
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
/>
)}
/>
);
} catch (error) {
@@ -95,11 +138,19 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
return (
<HomeView
autosuggest={autosuggest}
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
filters={emptyFilters}
totalSchools={null}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={null}
localAuthorityCount={0}
earliestYearLabel={null}
latestYearLabel={null}
/>
)}
/>
);
}
+9 -2
View File
@@ -5,6 +5,7 @@
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
import { RankingsView } from '@/components/RankingsView';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
interface RankingsPageProps {
@@ -17,9 +18,15 @@ interface RankingsPageProps {
}
export const metadata: Metadata = {
title: 'School Rankings',
description: 'Top-ranked schools by SATs and GCSE performance across England',
// 'School Rankings' matched nothing anyone types. League tables is the
// phrase parents actually search, and it spikes each results day.
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
description:
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
// now. W3 replaces them with real indexable paths.
alternates: { canonical: absoluteUrl('/rankings') },
};
// Dynamic via searchParams; remove force-dynamic so internal data fetches
+2 -1
View File
@@ -4,6 +4,7 @@
*/
import { MetadataRoute } from 'next';
import { absoluteUrl } from '@/lib/site';
export default function robots(): MetadataRoute.Robots {
return {
@@ -14,6 +15,6 @@ export default function robots(): MetadataRoute.Robots {
disallow: ['/api/', '/_next/'],
},
],
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
sitemap: absoluteUrl('/sitemap.xml'),
};
}
+9 -3
View File
@@ -15,6 +15,7 @@ import {
} from '@/lib/schoolSections';
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
import type { NationalAverages } from '@/lib/types';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
/**
@@ -97,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
title,
description,
type: 'website',
url: `https://schoolcompare.co.uk${canonicalPath}`,
url: absoluteUrl(canonicalPath),
siteName: 'schoolcompare',
},
twitter: {
@@ -106,7 +107,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
canonical: absoluteUrl(canonicalPath),
},
};
} catch {
@@ -147,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -176,6 +177,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
const navInput = {
ofsted: ofsted ?? null,
admissions: admissions ?? null,
admissionDistance: admission_distance ?? null,
hasLocation: school_info.latitude != null && school_info.longitude != null,
yearlyDataLength: yearly_data.length,
};
const primaryNavItems = buildNavItems(primaryFlags, navInput);
@@ -228,6 +231,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
@@ -249,6 +254,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
@@ -0,0 +1,63 @@
/**
* Phase variants of a place page.
*
* Phase is part of the query — "primary schools in beccles", "secondary
* schools in brentwood" — not a filter applied afterwards, so each gets its
* own indexable path. A place with no schools of the phase has no page: the
* per-phase threshold, not an error.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ place: string; phase: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
const PHASES = ['primary', 'secondary'] as const;
type Phase = (typeof PHASES)[number];
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
async function resolve(slug: string, phase: Phase) {
return (await fetchPlace('town', slug, phase))
?? (await fetchPlace('locality', slug, phase));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { place: slug, phase } = await params;
if (!isPhase(phase)) return { title: 'Place Not Found' };
const detail = await resolve(slug, phase);
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
const { name } = detail.place;
return {
// Not "Ranked": the table is alphabetical, so the word would be a claim
// the page does not keep.
title: { absolute: `${word} Schools in ${name} | schoolcompare` },
description:
`Every ${phase} school in ${name}, with results, Ofsted grades and the local `
+ `average against England.`,
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
};
}
export default async function PlacePhasePage({ params }: Props) {
const { place: slug, phase } = await params;
if (!isPhase(phase)) notFound();
const detail = await resolve(slug, phase);
if (!detail || detail.schools.length === 0) notFound();
const national = await fetchNationalAverages().catch(() => null);
const englandAverage = phase === 'secondary'
? national?.secondary?.attainment_8_score ?? null
: national?.primary?.rwm_expected_pct ?? null;
return <PlaceView detail={detail} phase={phase}
englandAverage={englandAverage} neighbours={[]} />;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Town and locality pages.
*
* A place below the five-school threshold is not in the registry, so
* fetchPlace returns null and the request 404s rather than rendering a page
* with nothing to say.
*/
import { notFound, redirect } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ place: string }> }
// ISR: place aggregates change only when the pipeline runs.
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
// Matches the PRERENDER_SCHOOLS gate on the school route.
if (process.env.PRERENDER_PLACES !== '1') return [];
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'town' || p.kind === 'locality')
.map((p) => ({ place: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
}
async function resolve(slug: string) {
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
}
/** Other towns in the same authority — the cheapest honest definition of
* "nearby", and enough to stop each place page being a dead end. */
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
if (!detail.place.parent_authority) return [];
const all = await fetchPlaces();
return all
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
.slice(0, 12);
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { place: slug } = await params;
const detail = await resolve(slug);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
// absolute: the root layout's template appends '| schoolcompare' to a
// plain string, and this title already carries it. Without this every
// place title read '... | schoolcompare | schoolcompare'.
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
description:
`Every school in ${name}, with SATs and GCSE results, Ofsted grades, the local `
+ `average against England, and how close you had to live to get a place.`,
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
};
}
export default async function PlacePage({ params }: Props) {
const { place: slug } = await params;
const detail = await resolve(slug);
if (!detail) notFound();
// Global constraint: no page without a local average. A place with too few
// schools carrying results has nothing to say that a list does not, so it
// defers to its authority rather than publishing a thin page.
if (detail.averages.rwm_expected_pct == null
&& detail.averages.attainment_8_score == null) {
// The API's own slug, which is null when that authority is itself under
// the threshold and has no page. Re-slugifying the name here would send
// the reader to a 404 instead of telling them the place has no page.
const target = detail.place.authorities?.[0]?.slug
?? (detail.place.parent_authority
? authoritySlug(detail.place.parent_authority)
: null);
if (target) redirect(`/schools/authority/${target}`);
notFound();
}
const national = await fetchNationalAverages().catch(() => null);
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
// — not flat. Reading it flat silently yields undefined and the page renders
// with no comparison, which is the one thing that makes it not a list.
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={await neighboursOf(detail)}
/>
);
}
@@ -0,0 +1,65 @@
/**
* Phase variants of an authority page.
*
* The spec called for these; the plan built the bare authority route and
* dropped them. Nothing caught it, because the sitemap is written from the
* place registry — which was right about them all along — while the routes
* were written by hand. 302 authority phase URLs were submitted to Google and
* every one 404'd, and every authority page linked to a phase page in the
* *town* namespace, which is a different set of schools entirely.
*
* "Primary schools in Kent" is the query these serve, and it is a real one:
* admissions are authority-run, so the authority is the unit a parent thinks
* in when they have not settled on a town.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ la: string; phase: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
const PHASES = ['primary', 'secondary'] as const;
type Phase = (typeof PHASES)[number];
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { la, phase } = await params;
if (!isPhase(phase)) return { title: 'Place Not Found' };
const detail = await fetchPlace('authority', la, phase);
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
const { name } = detail.place;
return {
// "Local Authority" stays in the title for the same reason it is on the
// bare authority page: 67 town names collide with an authority name, and
// a reader landing on both needs to know which set each covers.
title: { absolute: `${word} Schools in ${name} — Local Authority | schoolcompare` },
description:
`Every ${phase} school in the ${name} local authority, with results, Ofsted `
+ `grades and the authority average against England.`,
alternates: { canonical: absoluteUrl(`/schools/authority/${la}/${phase}`) },
};
}
export default async function AuthorityPhasePage({ params }: Props) {
const { la, phase } = await params;
if (!isPhase(phase)) notFound();
const detail = await fetchPlace('authority', la, phase);
if (!detail || detail.schools.length === 0) notFound();
const national = await fetchNationalAverages().catch(() => null);
const englandAverage = phase === 'secondary'
? national?.secondary?.attainment_8_score ?? null
: national?.primary?.rwm_expected_pct ?? null;
return <PlaceView detail={detail} phase={phase}
englandAverage={englandAverage} neighbours={[]} />;
}
@@ -0,0 +1,67 @@
/**
* Local authority pages.
*
* A separate namespace from /schools/[place] because 67 town names collide
* with an authority name and neither set contains the other — Bedford the
* town holds 104 schools, Bedford the authority 86, because postal towns
* cross authority boundaries. The title says "Local Authority" so a reader
* landing on both knows which set each covers.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ la: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
// Gated like every other prerender in this app. There are only ~154
// authorities, but "few enough to always build" still means the API must be
// reachable at build time, and in CI it is not — the build fails with
// ECONNREFUSED rather than degrading. The catch is the same fallback the
// school route uses.
if (process.env.PRERENDER_PLACES !== '1') return [];
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'authority')
.map((p) => ({ la: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { la } = await params;
const detail = await fetchPlace('authority', la);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
description:
`All ${count} schools in the ${name} local authority, with SATs and GCSE results, `
+ `Ofsted grades and the authority average against England.`,
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
};
}
export default async function AuthorityPage({ params }: Props) {
const { la } = await params;
const detail = await fetchPlace('authority', la);
if (!detail) notFound();
const national = await fetchNationalAverages().catch(() => null);
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={[]}
/>
);
}
@@ -0,0 +1,62 @@
/**
* Postcode district pages.
*
* No phase variants: nobody searches "primary schools in SW11", so the
* variants would be pages without demand. These exist to catch
* "schools near <postcode>" and to give London districts a geographic page
* where the GIAS town field cannot.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ outcode: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
// 1,760 of these; same CI budget argument as the town routes.
if (process.env.PRERENDER_PLACES !== '1') return [];
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'outcode')
.map((p) => ({ outcode: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { outcode } = await params;
const detail = await fetchPlace('outcode', outcode);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
title: { absolute: `Schools near ${name} | schoolcompare` },
description:
`${count} schools in the ${name} postcode district, with results, Ofsted grades `
+ `and how close you had to live to get a place.`,
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
};
}
export default async function OutcodePage({ params }: Props) {
const { outcode } = await params;
const detail = await fetchPlace('outcode', outcode);
if (!detail) notFound();
const national = await fetchNationalAverages().catch(() => null);
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={[]}
/>
);
}
+2 -26
View File
@@ -1,32 +1,8 @@
/**
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
*
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
* baking the backend host into the build, so one image works in every
* environment. robots.ts points crawlers here.
*/
import { NextResponse } from 'next/server';
import { proxySitemap } from '@/lib/sitemapProxy';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function backendOrigin(): string {
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
return base.replace(/\/api$/, '');
}
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
} catch {
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
}
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
});
return proxySitemap('/sitemap.xml');
}
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { proxySitemap } from '@/lib/sitemapProxy';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
* validated here rather than passed through, so this route cannot be used to
* reach arbitrary backend paths.
*/
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
export async function GET(
_request: Request,
{ params }: { params: Promise<{ parts: string[] }> },
) {
const { parts } = await params;
const name = parts.join('/');
if (!CHILD.test(name)) {
return new NextResponse('Not found', { status: 404 });
}
return proxySitemap(`/sitemaps/${name}`);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+2 -2
View File
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
date: 'September',
title: 'Portal opens',
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
tool: { label: 'Compare schools side by side', href: '/compare' },
},
{
date: '31 October',
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
date: 'September',
title: 'Portal opens',
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
tool: { label: 'Compare schools side by side', href: '/compare' },
},
{
date: '15 January',
+39 -33
View File
@@ -5,15 +5,45 @@ import styles from './HomeView.module.css';
interface EditorialSectionProps {
totalSchools: number | null;
localAuthorityCount: number;
latestYearLabel: string | null;
earliestYearLabel: string | null;
}
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
export function EditorialSection({
totalSchools,
localAuthorityCount,
latestYearLabel,
earliestYearLabel,
}: EditorialSectionProps) {
/*
* The coverage line replaces a six-row "Coverage at a glance" table.
*
* That table put six numbers on the page to say one thing, and the headline
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
* DataInfoResponse declared a `total_schools` field the API does not send
* (it sends `unique_schools`). The fetch had succeeded; only that field was
* undefined, so the fallback rendered and nothing failed. Every figure here
* is now live, and any figure that isn't available is dropped rather than
* replaced with a guess.
*/
const coverage = [
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
].filter(Boolean);
return (
<section className={styles.editorial}>
<div className={styles.editorialGrid}>
// No class on the section: the page container owns the vertical rhythm
// now, and this band carries its own ground on the card inside it.
<section>
<div className={styles.editorialCard}>
<div className={styles.sectionHead}>
<p className={styles.sectionKicker}>About school data</p>
<h2 className={styles.sectionHeading}>
Making England&apos;s school performance data actually readable
</h2>
</div>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
@@ -21,38 +51,14 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
</p>
<p>
schoolcompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
average, explains what the numbers mean, and lets you compare schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
full inspection report.
</p>
</div>
<div className={styles.factbox}>
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
<div className={styles.factRow}>
<span className={styles.factKey}>Schools covered</span>
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Local authorities</span>
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Phases</span>
<span className={styles.factVal}>Primary &amp; Secondary</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Latest results year</span>
<span className={styles.factVal}>2024/25</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Historical data</span>
<span className={styles.factVal}>2016–2025</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Metrics per school</span>
<span className={styles.factVal}>40+</span>
</div>
</div>
{coverage.length > 0 && (
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
)}
</div>
</section>
);
+45 -1
View File
@@ -48,6 +48,8 @@
display: flex;
align-items: center;
gap: 0.5rem;
/* The suggestion dropdown is absolutely positioned against this box. */
position: relative;
}
/* The hero pill: hairline, soft corner, everything else sits inside it. */
@@ -411,7 +413,17 @@
/* ── Narrow ───────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.filterBar {
/*
* Scoped, like the two rules below it.
*
* The results filter bar is a card — background, border, shadow — and needs
* inner padding. The hero's search is not a card: .heroMode zeroes the
* padding, border and background so the search sits directly on the panel.
* Unscoped, this rule put 14px back, which indented the search box, the hint
* and the location link 14px past the headline they sit under, and cost the
* search field 28px of width on a 390px screen.
*/
.filterBar:not(.heroMode) {
padding: 0.875rem;
}
@@ -455,11 +467,43 @@
align-items: flex-start;
}
/* Optical alignment: the button's own 6px of padding is what makes its
label start further right than the hint above it, even once both boxes
share a left edge. Pulling the padding back off lines the text up while
keeping the tap target. */
.heroMode .nearMeBtn {
margin-left: -0.375rem;
}
.geoError {
text-align: left;
}
}
/*
* Narrow phones: the button drops below the input instead of sharing the row.
*
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
* screen it took 48% of the row and left the input 102px of text space for a
* 194px placeholder — the field showed "School name or" and stopped. At 320px
* the input was down to 32px, which is too narrow to read what you are typing.
*
* Wrapping rather than restructuring: the pill stays one element, so it keeps
* its border, shadow and :focus-within ring, and the button gets a full-width
* tap target on the way. Giving the button a 100% flex-basis is what forces
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
*/
@media (max-width: 480px) {
.heroMode .omniBoxContainer {
flex-wrap: wrap;
gap: 0.5rem;
}
.heroMode .searchButton {
flex: 1 1 100%;
width: 100%;
}
}
@media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */
.heroMode .omniIcon {
+84 -2
View File
@@ -3,8 +3,11 @@
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from "@/lib/utils";
import { isValidPostcode, schoolUrl } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
@@ -17,6 +20,8 @@ interface FilterBarProps {
onNearMe?: () => void;
geoState?: "idle" | "requesting" | "error";
geoError?: string | null;
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
autosuggest?: boolean;
}
/**
@@ -48,6 +53,7 @@ export function FilterBar({
onNearMe,
geoState = "idle",
geoError,
autosuggest = false,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
@@ -62,6 +68,59 @@ export function FilterBar({
const [omniValue, setOmniValue] = useState(initialOmniValue);
const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`;
/*
* Suggestions answer typing, not the mere presence of a value.
*
* Without this the results-page bar reopened the dropdown over the results:
* after a search the input still holds the term, so on every render the
* query was >= 2 characters and the list opened again — on top of the very
* results the search had just produced, swallowing the click on the first
* one. The E2E gate caught it as "<li role=option> intercepts pointer
* events", but a reader would just have found the page unclickable.
*/
const [hasTyped, setHasTyped] = useState(false);
// Suppressed once the value parses as a postcode: the box takes a school
// name OR a postcode, and suggesting schools during postcode entry fights
// the user rather than helping them.
const suggestEnabled = autosuggest && hasTyped && !isValidPostcode(omniValue);
const { suggestions, open, activeIndex, setActiveIndex, close } =
useSchoolSuggest(omniValue, suggestEnabled);
const pickSuggestion = (s: Suggestion) => {
setHasTyped(false);
close();
track('search_submitted', {
query: s.school_name.toLowerCase(),
via: 'suggestion',
urn: s.urn,
has_postcode: false,
filters_active: '',
filters_count: 0,
});
router.push(schoolUrl(s.urn, s.school_name));
};
const handleOmniKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex(activeIndex + 1 >= suggestions.length ? 0 : activeIndex + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex(activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1);
} else if (e.key === "Escape") {
close();
} else if (e.key === "Enter" && activeIndex >= 0) {
// Only when an option is active. With none, the event falls through to
// the form's submit handler and searches the typed text, as it does now.
e.preventDefault();
pickSuggestion(suggestions[activeIndex]);
}
};
const currentLA = searchParams.get("local_authority") || "";
const currentType = searchParams.get("school_type") || "";
const currentPhase = searchParams.get("phase") || "";
@@ -124,6 +183,9 @@ export function FilterBar({
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
// The search has been made; the suggestions that led to it are spent.
setHasTyped(false);
close();
if (!omniValue.trim()) {
updateURL({ search: "", postcode: "", radius: "" });
return;
@@ -226,9 +288,20 @@ export function FilterBar({
ref={inputRef}
type="search"
value={omniValue}
onChange={(e) => setOmniValue(e.target.value)}
onChange={(e) => { setOmniValue(e.target.value); setHasTyped(true); }}
onKeyDown={handleOmniKeyDown}
onBlur={close}
placeholder="School name or postcode"
className={styles.omniInput}
{...(autosuggest ? {
role: "combobox",
"aria-expanded": open,
"aria-controls": suggestId,
"aria-autocomplete": "list" as const,
"aria-activedescendant":
activeIndex >= 0 ? suggestOptionId(suggestId, activeIndex) : undefined,
autoComplete: "off",
} : {})}
/>
<button
type="submit"
@@ -237,6 +310,15 @@ export function FilterBar({
>
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
</button>
{autosuggest && open && (
<SuggestList
id={suggestId}
suggestions={suggestions}
activeIndex={activeIndex}
onPick={pickSuggestion}
onHover={setActiveIndex}
/>
)}
</div>
{isHero && (
<>
+25 -4
View File
@@ -47,13 +47,14 @@
.lockupMark {
display: inline-flex;
flex: 0 0 auto;
width: 34px;
/* Height drives; the artwork is taller than it is wide. */
height: 34px;
}
.lockupMark svg {
width: 100%;
.lockupMark img {
display: block;
height: 100%;
width: auto;
}
/* Same wordmark as the header, inverted: "school" in the band's foreground,
@@ -91,13 +92,33 @@
max-width: 34ch;
}
/*
* Quieter than .description, but by size only — not by contrast.
*
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
* fine margin for decorative text and the wrong one for a disclaimer, whose
* whole job is to be legible to someone checking whether this is a government
* site. --on-sunken-muted is 5.66:1 on the same band.
*/
.independence {
margin: 0;
font-size: var(--step--2);
line-height: 1.6;
color: var(--on-sunken-muted);
max-width: 38ch;
}
.sectionTitle {
margin: 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 700;
line-height: 1;
color: var(--sage);
/* --sage flips with the theme; this band does not (it is teal in both), so
the pairing only held in one of them — the dark sage measured 4.08:1 here
against a 4.5 floor, on every page. The --on-sunken-* family exists
precisely because this surface is theme-invariant. */
color: var(--on-sunken-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
+12 -2
View File
@@ -25,7 +25,7 @@ export function Footer() {
*/}
<h3 className={styles.lockup}>
<span className={styles.lockupMark}>
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" />
<LogoMark variant="onDark" size={34} />
</span>
<span className={styles.wordmark}>
school<span className={styles.wordmarkAccent}>compare</span>
@@ -35,6 +35,14 @@ export function Footer() {
<p className={styles.description}>
Compare primary and secondary schools across England.
</p>
{/*
Says once, plainly, what the landing page's value props only
imply: we publish official data, we are not an official body.
Cheap to state and expensive to be wrong about.
*/}
<p className={styles.independence}>
An independent site. Not affiliated with the Department for Education or Ofsted.
</p>
<a
href="mailto:contact@schoolcompare.co.uk"
className={styles.link}
@@ -49,7 +57,9 @@ export function Footer() {
<ul className={styles.links}>
<li><a href="/" className={styles.link}>Search schools</a></li>
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
{/* "Compare", not "shortlist" — the nav, this link and the
landing page all name the same feature the same way. */}
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
</ul>
</div>
+428 -350
View File
@@ -2,67 +2,247 @@
width: 100%;
}
/* ── Hero ──────────────────────────────────────────────────────────────────
A Sand panel: proposition and search on the left, the brand landscape
bleeding to the panel's right edge. The illustration is decorative, so on
phones it drops to a short band under the content rather than competing
with the search for the fold. */
.heroPanel {
/* ── Page rhythm ───────────────────────────────────────────────────────────
The landing page owns the vertical gap between its bands; no band sets its
own top or bottom margin. Previously each one did, and the gaps came out as
24 / 32 / 24 / 16 / 48 / 32 / 16px — no scale, which is most of the reason a
designed page read as a stack of unrelated strips. If you add a band here,
give it padding and a ground, never a margin. */
.landing {
display: flex;
flex-direction: column;
gap: var(--section-gap);
}
/* The hero and the four reasons under it are one thought, so they sit closer
to each other than to the next band. This is the only place that overrides
the page gap, and it does it by grouping rather than by re-spacing. */
.heroGroup {
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ── Section header ────────────────────────────────────────────────────────
One pattern for every band: kicker, heading, optional aside. */
.sectionHead {
display: grid;
grid-template-columns: 1.12fr 0.88fr;
align-items: stretch;
background: var(--bg-secondary);
grid-template-columns: 1fr auto;
align-items: baseline;
column-gap: 1.5rem;
margin-bottom: var(--section-head-gap);
}
.sectionKicker {
grid-column: 1 / -1;
font-size: var(--step--2);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brand-strong);
margin: 0 0 0.35rem;
}
.sectionHeading {
font-family: var(--font-display);
font-size: var(--step-3);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--text-primary);
margin: 0;
max-width: 24ch;
text-wrap: balance;
}
.sectionAside {
font-size: var(--step--1);
color: var(--text-muted);
margin: 0;
text-align: right;
}
@media (max-width: 768px) {
.sectionHead {
grid-template-columns: 1fr;
}
.sectionHeading {
font-size: var(--step-2);
max-width: none;
}
.sectionAside {
text-align: left;
margin-top: 0.4rem;
}
}
/* ── Hero ──────────────────────────────────────────────────────────────────
The artwork fills the panel and the proposition sits on top of it, in the
empty cream area the illustration reserves on its left. It is not a
two-column grid: splitting the panel would crop that reserved area off and
shrink the scene into a thumbnail, which is the one thing the artwork is
composed not to be.
Below the one-column breakpoint the artwork leaves the background and
becomes a band under the search — see the 860px block. */
.heroPanel {
position: relative;
isolation: isolate;
background: var(--hero-ground);
border-radius: var(--radius-xl);
overflow: hidden;
margin-bottom: 1.5rem;
/*
* Deliberately NOT overflow: hidden.
*
* It used to be, to clip the artwork and the scrim to the rounded corners —
* and it also clipped the search box's suggestion dropdown, which is 320px
* tall against 145px of panel below the input. Roughly half the list was cut
* off with no indication anything was missing.
*
* The two things that actually needed clipping round themselves instead, so
* the panel can let a dropdown out. Anything absolutely positioned inside
* this panel and taller than the space below it depends on this.
*/
}
.heroContent {
align-self: center;
padding: 3rem 1.5rem 3rem 3rem;
position: relative;
z-index: 2;
padding: 3.5rem 1.5rem 3.5rem 3rem;
/* Held inside the artwork's cream area. Beyond roughly this width the text
runs onto the hillside, where the scrim below is doing all the work. */
max-width: min(56%, 40rem);
min-width: 0;
}
.heroArt {
position: relative;
min-width: 0;
/* The scene needs height to read; without a floor it collapses to the
content column's height on short viewports. */
min-height: 24rem;
}
.heroArt svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
z-index: 0;
/* Rounds itself, because the panel no longer clips it. inset: 0 makes this
exactly the panel's own corners. */
border-radius: inherit;
overflow: hidden;
}
.heroTrust {
.heroArt picture,
.heroArt img {
display: block;
width: 100%;
height: 100%;
}
.heroArt img {
object-fit: cover;
/* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops
height only, and the school sits well inside the surviving band. */
object-position: center;
}
/*
* Contrast guarantee.
*
* The artwork's cream area is generous but it is a fixed image on a fluid
* panel — as the panel widens the text block and the cream drift apart, and at
* some width the headline would sit on hillside green. Rather than tune the
* text width per breakpoint and hope, the panel fades its own ground across
* the left of the artwork, so the copy always has a known surface under it.
*
* The gradient starts from --hero-ground, which is sampled from the artwork
* itself, so in the light theme this is invisible — it reads as the
* illustration, not as an overlay on it.
*/
.heroPanel::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
/* Same reason as .heroArt: the panel stopped clipping, so the scrim keeps
its own corners rather than squaring off over the panel's. */
border-radius: inherit;
background: linear-gradient(
to right,
var(--hero-ground) 0%,
rgba(var(--hero-ground-rgb), 0.97) 26%,
rgba(var(--hero-ground-rgb), 0.72) 44%,
rgba(var(--hero-ground-rgb), 0) 62%
);
}
/*
* Dark theme.
*
* The artwork is a fixed raster, so unlike the drawn hero it cannot be
* re-graded token by token — but the failure it would otherwise reproduce is
* the same one: a bright illustration is the brightest object on a near-black
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
* whole treatment, and the scrim then fades it into the dark panel instead of
* into cream.
*
* Raising the brightness raises the background under light dark-theme text, so
* this value has a contrast floor, not just a taste range. Measured off
* rendered pixels, sampling background up to 120px past each line:
*
* brightness title body
* 0.52 11.01:1 6.44:1
* 0.75 9.48:1 5.21:1 ← current
*
* Body text is the binding one. Going much above 0.75 walks it toward the
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
* than the artwork being dimmed less.
*/
@media (prefers-color-scheme: dark) {
.heroArt img {
filter: brightness(0.75) saturate(0.72) contrast(1.02);
}
.heroPanel::before {
background: linear-gradient(
to right,
var(--hero-ground) 0%,
rgba(var(--hero-ground-rgb), 0.96) 30%,
rgba(var(--hero-ground-rgb), 0.66) 50%,
rgba(var(--hero-ground-rgb), 0.15) 78%
);
}
}
/*
* The one line in the first person, so it is set apart from the marketing copy
* around it without shouting: display face, a step down in size, and a short
* brand rule instead of a bullet or an emoji.
*
* No italic — Manrope ships no italic in the loaded weights, so font-style
* would be synthesised into a slant. Same reason .heroEmph sets font-style
* back to normal.
*/
.heroByline {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 1.4rem;
gap: 0.55rem;
margin: 1.25rem 0 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 500;
font-style: normal;
color: var(--text-secondary);
}
.heroTrustDots {
display: inline-flex;
.heroByline::before {
content: '';
flex: 0 0 auto;
width: 1.25rem;
height: 2px;
border-radius: 1px;
background: var(--brand);
}
.heroTrustDot {
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
margin-right: -0.55rem;
@media (max-width: 640px) {
.heroByline {
margin-top: 1rem;
font-size: var(--step--2);
}
}
.heroTrustDot:nth-child(1) { background: var(--sage); }
.heroTrustDot:nth-child(2) { background: var(--mustard); }
.heroTrustDot:nth-child(3) { background: var(--sky); }
.heroEyebrow {
display: inline-flex;
@@ -72,7 +252,10 @@
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand);
/* --brand on its own 10% tint over Sand measures 4.19:1 — the tint darkens
the ground under the label, and the token was specced against the ground,
not against the tint. --brand-strong on the same composite is 5.85:1. */
color: var(--brand-strong);
background: rgba(var(--brand-rgb), 0.1);
padding: 0.3rem 0.7rem;
border-radius: 999px;
@@ -120,24 +303,68 @@
@media (max-width: 1024px) {
.heroContent {
padding: 2.25rem 1.5rem 2.25rem 2rem;
}
.heroArt {
min-height: 20rem;
padding: 2.75rem 1.5rem 2.75rem 2rem;
max-width: min(64%, 34rem);
}
}
/* One column: content first, then a short band of landscape. */
/*
* One column: the artwork stops being a background and becomes a band under
* the search.
*
* Overlaying at this width does not work — the panel is too narrow for the
* copy to stay inside the artwork's cream area, so the scrim would have to
* cover nearly the whole image, which is just a tinted rectangle with a
* picture faintly behind it. Below the band, <picture> has already switched
* to the pre-cropped file (see Illustration.tsx); this is the layout half of
* the same decision.
*/
@media (max-width: 860px) {
/*
* Artwork above the copy, not below it.
*
* The DOM keeps .heroContent first so the desktop overlay does not depend on
* source order, which left the band stranded under the search on phones —
* reading as a strip stuck on the end rather than as a hero image. `order`
* moves it visually only; it is decorative and aria-hidden, so there is no
* reading order to disturb.
*
* It costs about 136px above the search. Measured on a 667px viewport (the
* shortest phone still in use) the search still lands near 400px, well
* inside the fold.
*/
.heroPanel {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
}
.heroContent {
padding: 2rem 1.5rem 1.75rem;
padding: 1.75rem 1.5rem 1.75rem;
max-width: none;
}
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
crop into that throws away enough height to cut the flag off the roof and
the base off the building. 13rem holds it at about 3.8:1, where the
building stays whole. */
.heroArt {
min-height: 11rem;
order: 2;
position: static;
order: -1;
height: 13rem;
/* Top corners only. Here the artwork is a band flush with the top of the
panel, not a layer covering it — inheriting all four would leave it
floating with rounded bottom corners against the copy below. */
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
/* The band crop puts the schoolhouse at 73% across — reported by
scripts/build-hero-images.js, which derives it from the crop box rather
than leaving the two to drift. Squeezing narrower then crops the emptier
left side rather than the subject. */
.heroArt img {
object-position: 73% center;
}
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
it here, and leaving it would wash the band out. */
.heroPanel::before {
display: none;
}
.heroTitle {
font-size: var(--step-3);
@@ -150,13 +377,11 @@
}
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
long coverage sentence, leaving the proposition and the search — a
first-time visitor still gets the trust line beneath the box, which carries
the data provenance the coverage sentence used to (audit P1.7, feeds the
46% home exit rate). */
long coverage sentence, leaving the proposition and the search — the data
provenance is carried by the "Official & trusted" prop immediately below
the hero (audit P1.7, feeds the 46% home exit rate). */
@media (max-width: 640px) {
.heroPanel {
margin-bottom: 1rem;
border-radius: var(--radius-lg);
}
.heroContent {
@@ -175,12 +400,12 @@
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.heroTrust {
margin-top: 1rem;
font-size: var(--step--2);
}
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
the height steps down here while the width does not. 10rem keeps it near
3.6:1, in line with the rest of the range. */
.heroArt {
min-height: 8.5rem;
height: 10rem;
}
}
@@ -193,7 +418,7 @@
gap: 1.25rem;
list-style: none;
padding: 0;
margin: 0 0 2rem;
margin: 0;
}
.valueProp {
@@ -256,7 +481,6 @@
.valueProps {
grid-template-columns: 1fr;
gap: 0.85rem;
margin-bottom: 1.5rem;
}
}
@@ -689,86 +913,15 @@
border-color: var(--brand-strong);
}
.exploringRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-top: 1rem;
}
.exploringLabel {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.exploringChips {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.exploringChip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.95rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.15s ease;
}
.exploringChip:hover {
border-color: var(--brand);
color: var(--brand-strong);
transform: translateY(-1px);
}
.chipDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.55;
flex-shrink: 0;
}
/* ── How it works section ─────────────────────────────── */
/* ── How it works section ─────────────────────────────────────────────────
A Sand band, borrowing the hero panel's ground and radius so the page has
two anchored bands rather than one designed element and a long tail of
unstyled strips. */
.howItWorks {
padding: 3rem 0 1rem;
}
.hiwHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.hiwHeading {
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.hiwSub {
font-size: 0.875rem;
color: var(--text-muted);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 2.5rem 2rem;
}
.hiwGrid {
@@ -788,8 +941,19 @@
gap: 0.9rem;
}
/*
* The preview panel sits on the card surface, not on Sand.
*
* Everything inside it is a translucent status or brand tint, and those tokens
* are specced to clear AA over --bg-card. Over Sand the same tints composite
* darker, which put the report-card chips at 4.22:1 and 4.29:1 against a 4.5
* floor — the ratio depended on a ground two levels up. On the card surface
* they are 5.69:1 and 5.81:1. The border keeps it reading as an inset frame
* now that panel and card share a colour.
*/
.hiwVisual {
background: var(--bg-secondary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem;
min-height: 180px;
@@ -1135,7 +1299,10 @@
.compareHeadLabel {
font-family: inherit;
font-size: 0.48rem;
color: var(--text-muted);
/* --text-muted on the header row's tint is 4.45:1 in the dark theme —
under the floor by a hair, because the tint darkens the ground beneath
the label. --text-secondary clears it on both grounds. */
color: var(--text-secondary);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -1183,12 +1350,9 @@
.hiwGrid {
grid-template-columns: 1fr;
}
.hiwHeader {
flex-direction: column;
align-items: flex-start;
}
.hiwHeading {
font-size: 1.4rem;
.howItWorks {
padding: 1.75rem 1.25rem;
border-radius: var(--radius-lg);
}
}
@@ -1212,18 +1376,29 @@
/* ── Editorial section ───────────────────────────────── */
.editorial {
padding: 2rem 0 3rem;
}
.editorialGrid {
/* Heading beside the prose rather than above it. The fact box used to occupy
the right-hand column; with it gone, a single column left the card half
empty, because the prose is capped at a 62ch reading measure and the card
is not. */
.editorialCard {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 2rem;
padding: 1.75rem;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
column-gap: 3rem;
padding: 2rem 2.25rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
border-radius: var(--radius-lg);
}
/* The shared header pattern spaces itself for a header sitting above its
content; here it sits beside it. */
.editorialCard .sectionHead {
margin-bottom: 0;
display: block;
}
.editorialCard .coverageLine {
grid-column: 1 / -1;
}
.editorialText {
@@ -1232,23 +1407,6 @@
gap: 0.65rem;
}
.editorialKicker {
font-size: 0.68rem;
color: var(--brand-strong);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.editorialHeading {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
line-height: 1.25;
}
/* Running prose is the one place the serif appears. Slightly larger than the
UI around it because Literata's x-height sits lower than the grotesque's. */
.editorialText p {
@@ -1260,53 +1418,25 @@
max-width: 62ch;
}
.factbox {
background: var(--bg-secondary);
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0;
}
.factboxHeading {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.85rem;
}
.factRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
gap: 0.5rem;
}
.factRow:last-child {
border-bottom: none;
}
.factKey {
/* One line of live coverage figures, replacing a six-row table whose headline
number was its own hardcoded fallback. Quiet by design: it is provenance,
not a claim. */
.coverageLine {
margin: 1.25rem 0 0;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: var(--step--1);
font-variant-numeric: tabular-nums;
color: var(--text-muted);
}
.factVal {
font-family: var(--font-display);
font-weight: 700;
color: var(--text-primary);
text-align: right;
}
@media (max-width: 768px) {
.editorialGrid {
.editorialCard {
grid-template-columns: 1fr;
padding: 1.25rem;
gap: 1.25rem;
}
.editorialCard .sectionHead {
margin-bottom: var(--section-head-gap);
}
}
@@ -1385,189 +1515,137 @@
.loadMoreButton {
min-width: 160px;
}
/* =========================================================
Admissions Countdown Strip
Next admissions deadline
One bar, replacing four equally-weighted countdown cards. Those gave the
page's largest numeral to things up to 245 days away, and two of the four
counted down to offer days — dates you receive something on, which cannot
be missed. The headline is the next real deadline; the rest stay on the
page as one supporting line, so no fact was lost, only its weight.
========================================================= */
.admissionsStrip {
padding: 1.5rem 0 2rem;
border-top: 1px solid var(--border);
}
.stripHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.stripLabel {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.stripCta {
font-size: 0.82rem;
color: var(--brand);
font-weight: 600;
text-decoration: none;
}
.stripCta:hover {
text-decoration: underline;
}
.countdownRail {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.countdownChip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.1rem 0.9rem;
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
.admissions {
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.85rem;
}
.nextDeadline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
position: relative;
overflow: hidden;
padding: 1.15rem 1.5rem 1.15rem 1.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
color: inherit;
text-decoration: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.countdownChip::before {
/* A brand rule down the leading edge — the same device the countdown cards
used along their top edge, kept so the band still reads as admissions. */
.nextDeadline::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 12px 12px 0 0;
}
.countdownChipDeadline::before {
inset: 0 auto 0 0;
width: 5px;
background: var(--brand);
}
.countdownChipOffer::before {
background: var(--status-above);
.nextDeadline:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-medium);
}
.countdownChipUrgent {
border-color: rgba(var(--status-below-rgb), 0.4);
background: rgba(var(--status-below-rgb), 0.04);
}
.chipTrack {
.nextDeadlineBody {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.15rem;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.chipTrackDeadline {
.nextDeadlineKicker {
font-size: var(--step--2);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-strong);
}
.chipTrackOffer {
color: var(--status-above);
.nextDeadlineTitle {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
}
.chipTrackDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
.nextDeadlineDate {
font-size: var(--step--1);
color: var(--text-muted);
}
.nextDeadlineCount {
display: flex;
align-items: baseline;
gap: 0.35rem;
flex-shrink: 0;
}
.chipDays {
font-variant-numeric: tabular-nums;
.nextDeadlineDays {
font-family: var(--font-display);
font-size: 2.6rem;
font-size: var(--step-3);
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--brand-strong);
}
.countdownChipDeadline .chipDays,
.countdownChipUrgent .chipDays {
.nextDeadlineUnit {
font-size: var(--step--1);
color: var(--text-muted);
}
/* Inside a fortnight the count changes hue rather than size, so urgency is
legible without the bar growing. Terracotta is the status hue, not the
action hue — this is a state, not a button. */
.nextDeadlineUrgent .nextDeadlineDays {
color: var(--status-below);
}
.countdownChipOffer .chipDays {
color: var(--status-above);
}
.chipDaysUnit {
font-family: var(--font-ui);
font-size: 0.78rem;
font-weight: 500;
.laterDates {
font-size: var(--step--1);
color: var(--text-muted);
margin-left: 0.2rem;
vertical-align: bottom;
line-height: 2;
line-height: 1.6;
margin: 0;
}
.chipMilestone {
font-size: 0.85rem;
.laterDatesLink {
color: var(--brand);
font-weight: 600;
color: var(--text-primary);
line-height: 1.25;
margin-top: 0.1rem;
text-decoration: none;
white-space: nowrap;
}
.chipDate {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.05rem;
.laterDatesLink:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.countdownRail {
grid-template-columns: repeat(2, 1fr);
}
}
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
Deadline" track label dropped to 9.6px. Switch to a horizontal
snap-scroller — each card is full-width-ish and stays readable,
and the rightmost card peeks past the edge to signal there's more. */
@media (max-width: 640px) {
.countdownRail {
display: flex;
grid-template-columns: none;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
.nextDeadline {
align-items: flex-start;
flex-direction: column;
gap: 0.75rem;
padding-right: 1.25rem;
margin-inline: -1rem;
padding-inline: 1rem;
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
padding: 1rem 1.1rem 1rem 1.35rem;
}
.countdownRail::-webkit-scrollbar {
display: none;
}
.countdownChip {
flex: 0 0 auto;
width: 78%;
min-width: 220px;
scroll-snap-align: start;
}
.chipTrack {
font-size: 0.7rem;
.nextDeadlineCount {
align-items: baseline;
}
}
+171 -110
View File
@@ -29,6 +29,8 @@ interface HomeViewProps {
// show (e.g. an active search).
howItWorks?: React.ReactNode;
editorial?: React.ReactNode;
/** Server-read feature flag, threaded to both FilterBar instances. */
autosuggest?: boolean;
}
function daysUntil(month: number, day: number): number {
@@ -40,13 +42,22 @@ function daysUntil(month: number, day: number): number {
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
}
function formatCountdownDate(month: number, day: number): string {
function nextOccurrence(month: number, day: number): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target < today) target = new Date(y + 1, month - 1, day);
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
const target = new Date(y, month - 1, day);
return target < today ? new Date(y + 1, month - 1, day) : target;
}
function formatCountdownDate(month: number, day: number): string {
return nextOccurrence(month, day)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
}
function formatShortDate(month: number, day: number): string {
return nextOccurrence(month, day)
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
interface CountdownChipData {
@@ -57,6 +68,16 @@ interface CountdownChipData {
day: number;
}
interface AdmissionsMilestone {
chip: CountdownChipData;
/** Whole days from today. Always present — computed during render. */
days: number;
/** "Sat, 31 October 2026" — for the headline bar. */
dateLabel: string;
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
shortDate: string;
}
const ADMISSIONS_CHIPS: CountdownChipData[] = [
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
@@ -125,26 +146,46 @@ interface ValueProp {
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
* favourites). That feature does not exist in this product, so it is
* deliberately replaced by the admissions-deadline prop below — which is real,
* and is backed by the countdown strip further down this page.
* and is backed by the next-deadline bar further down this page.
*
* Every claim here must name something the product actually does. Two of the
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
* in context/ComparisonProvider.tsx (and the card further down the page, which
* correctly said five), and "class sizes" described data the codebase has never
* held — grep for it and this line was the only hit. Both are corrected below
* against the real fields, which live in components/school/InclusionSection.tsx.
*/
const VALUE_PROPS: ValueProp[] = [
{
icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust,
title: 'Official & trusted',
/*
* "Built on official data", never "Official".
*
* The previous title was "Official & trusted", whose grammatical subject
* is this site — it reads as a claim that schoolcompare is itself an
* official service. It is not: it is an independent site that republishes
* official figures. The distinction is the difference between describing
* the data and describing ourselves, and only the first is true.
*
* Everywhere else the word appears ("official DfE figures", "the official
* figure isn't in") it already qualifies the data, which is correct and
* should stay.
*/
title: 'Built on official data',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
icon: <BarsIcon />,
tintClass: styles.propIconCompare,
title: 'Easy to compare',
body: 'Up to three schools side by side, on the measures that matter.',
body: 'Up to five schools side by side, on the measures that matter.',
},
{
icon: <HeartIcon />,
tintClass: styles.propIconContext,
title: 'Beyond the numbers',
body: 'Class sizes, SEN support and local context, not just results.',
body: 'SEN support, pupil premium and attendance — not just results.',
},
{
icon: <CalendarIcon />,
@@ -154,7 +195,7 @@ const VALUE_PROPS: ValueProp[] = [
},
];
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -174,9 +215,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
const mapParamsRef = useRef<string>('');
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
const [geoError, setGeoError] = useState<string | null>(null);
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
);
/*
* Computed during render, on the server as well as the client, so the
* deadline bar is in the first HTML rather than appearing on hydrate.
*
* The previous version deferred this to an effect to dodge a hydration
* mismatch, which meant the section had to reserve its own height — and the
* reservation was a single guessed number for a block whose supporting line
* wraps to a different height at every width. Measured, it was short at
* every breakpoint, shifting the page by up to 108px on a phone.
*
* The mismatch it was dodging is real but tiny: a server rendering at
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
* confined to two text nodes, which carry suppressHydrationWarning below.
*/
const [milestones] = useState<AdmissionsMilestone[]>(() => {
const measured = ADMISSIONS_CHIPS.map(chip => ({
chip,
days: daysUntil(chip.month, chip.day),
dateLabel: formatCountdownDate(chip.month, chip.day),
shortDate: formatShortDate(chip.month, chip.day),
}));
measured.sort((a, b) => a.days - b.days);
return measured;
});
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
@@ -239,13 +301,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
.catch(() => {});
}, []);
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
useEffect(() => {
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
setSortedChips(withDays);
}, []);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return;
track('results_load_more', { next_page: currentPage + 1 });
@@ -334,6 +389,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
});
}, [initialSchools.total, isSearchActive, searchParams]);
/*
* The coverage figure, or nothing at all.
*
* This used to be the string "24,000+", hardcoded in three places while the
* database held 27,230 — and the fact box that was meant to show the live
* number silently rendered its fallback, because DataInfoResponse declared a
* `total_schools` field the API has never sent. Now there is one source: if
* the real count is unavailable the sentence simply omits it rather than
* inventing a floor.
*/
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
/*
* The headline milestone is the next thing a parent can actually miss — an
* application deadline. Offer days are dates you receive something on, so
* they belong in the supporting line, not in the page's largest numeral.
* Four equally-sized cards counting down to things 245 days away was the
* loudest element on the page and asked nothing of anyone.
*/
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
const laterMilestones = milestones.filter(m => m !== nextDeadline);
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
// split which surface drives compare adds.
const addSchoolFromSearch = useCallback((school: School) => {
@@ -346,12 +423,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
}, [addSchool, selectedSchools.length]);
return (
<div className={styles.homeView}>
/* The landing arrangement owns its own vertical rhythm (one gap, set in
CSS); the search arrangement is a filter bar directly above its results
and wants none of it. */
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
{/* Hero: a Sand panel with the proposition and the search on the left and
the brand landscape bleeding to the panel edge on the right. The
search lives inside the panel here and above the results elsewhere,
which is why FilterBar is rendered in two places rather than moved. */}
which is why FilterBar is rendered in two places rather than moved.
The hero and the four value props are one group, so they sit closer
together than the page's section gap. */}
{!isSearchActive ? (
<div className={styles.heroGroup}>
<section className={styles.heroPanel}>
<div className={styles.heroContent}>
<span className={styles.heroEyebrow}>
@@ -369,7 +452,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
every line above the fold costs. */}
<span className={styles.heroDescriptionFull}>
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
and admissions data for <strong>24,000+ schools</strong> — side by
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
side, in one place.
</span>
</p>
@@ -381,22 +464,37 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
<p className={styles.heroTrust}>
<span className={styles.heroTrustDots} aria-hidden="true">
<span className={styles.heroTrustDot} />
<span className={styles.heroTrustDot} />
<span className={styles.heroTrustDot} />
</span>
Built on official DfE and Ofsted data — 24,000+ schools
</p>
</div>
{/*
Rendered after the content on purpose. On desktop it is absolutely
positioned behind the panel, so order does not matter; below the
one-column breakpoint it returns to normal flow, where DOM order is
what puts the band under the search rather than above the headline.
*/}
<div className={styles.heroArt}>
<HeroIllustration />
</div>
</section>
{/* Why this site, in four lines. These titles are labels on a list,
not section headings — as <h2> they outranked the page's real
headings in the document outline and gave a screen-reader user
four false landmarks before any content. */}
<ul className={styles.valueProps}>
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
<li key={title} className={styles.valueProp}>
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
<div>
<p className={styles.propTitle}>{title}</p>
<p className={styles.propBody}>{body}</p>
</div>
</li>
))}
</ul>
</div>
) : (
<FilterBar
filters={filters}
@@ -405,97 +503,59 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
)}
{/* Why this site, in four lines. Only on the landing page. */}
{/* Next admissions deadline — one bar, not four cards. */}
{!isSearchActive && (
<ul className={styles.valueProps}>
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
<li key={title} className={styles.valueProp}>
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
<div>
<h2 className={styles.propTitle}>{title}</h2>
<p className={styles.propBody}>{body}</p>
</div>
</li>
))}
</ul>
)}
{/* Admissions countdown strip — only on landing page */}
{!isSearchActive && (
<section className={styles.admissionsStrip}>
<div className={styles.stripHeader}>
<span className={styles.stripLabel}>Key admissions deadlines</span>
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
</div>
<div
className={styles.countdownRail}
style={{
opacity: sortedChips[0]?.days !== null ? 1 : 0,
transition: 'opacity 0.2s ease',
}}
>
{sortedChips.map(({ chip, days }) => {
const isUrgent = days !== null && days <= 14;
const chipClass = [
styles.countdownChip,
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
isUrgent ? styles.countdownChipUrgent : '',
].filter(Boolean).join(' ');
const trackClass = [
styles.chipTrack,
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
].join(' ');
return (
<div key={chip.milestone} className={chipClass}>
<span className={trackClass}>
<span className={styles.chipTrackDot} aria-hidden="true" />
{chip.track}
<section className={styles.admissions} aria-label="Admissions deadlines">
{nextDeadline && (
<>
<a
href="/admissions"
className={
nextDeadline.days <= 14
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
: styles.nextDeadline
}
>
<span className={styles.nextDeadlineBody}>
<span className={styles.nextDeadlineKicker}>
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
</span>
<div>
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
</div>
<div className={styles.chipMilestone}>{chip.milestone}</div>
<div className={styles.chipDate}>
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
</div>
</div>
);
})}
</div>
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
{nextDeadline.dateLabel}
</span>
</span>
<span className={styles.nextDeadlineCount}>
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
</span>
{nextDeadline.days !== 0 && (
<span className={styles.nextDeadlineUnit}>days left</span>
)}
</span>
</a>
<p className={styles.laterDates} suppressHydrationWarning>
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
{' '}
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
</p>
</>
)}
</section>
)}
{/* Secondary discovery — moved below deadlines so the admissions
countdown (time-sensitive) shows ahead of generic "explore" links. */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.exploringRow}>
<span className={styles.exploringLabel}>Start exploring</span>
<div className={styles.exploringChips}>
<a href="/rankings" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Top-rated primary schools
</a>
<a href="/rankings" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Top-rated secondary schools
</a>
<a href="/compare" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Start a comparison
</a>
</div>
</div>
)}
{/* How it works + Editorial — server-rendered slots, only on landing */}
{!isSearchActive && howItWorks}
{!isSearchActive && editorial}
{/* Results Section */}
{/* Results Section. Skipped entirely on the landing page when there is
nothing to list — an empty <section> is still a flex child, so it was
contributing a full section gap of blank space above the footer. */}
{(isSearchActive || initialSchools.schools.length > 0) && (
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
@@ -681,6 +741,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</>
)}
</section>
)}
</div>
);
}
+27 -8
View File
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
];
/*
* The columns are deliberately unnamed.
*
* They previously carried two real school names — "Our Lady Queen of Heaven"
* and "St Mary's Catholic Primary" — beside invented results and invented
* Ofsted grades. On a site whose entire proposition is official DfE and
* Ofsted data, publishing made-up figures against identifiable schools is
* the one thing it cannot do, and no illustrative intent survives being
* screenshotted. Generic labels make the layout point just as well.
*/
const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
return (
<section className={styles.howItWorks}>
<div className={styles.hiwHeader}>
<h2 className={styles.hiwHeading}>What you&apos;ll see on every school</h2>
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
{/* One header pattern across every band on this page: kicker, heading,
optional aside. The page previously ran four different treatments
(two uppercase micro-labels — one left, one centred — a large heading
with right-aligned grey text, and a kicker-plus-heading), which is
most of why a designed page read as a stack of unrelated strips. */}
<div className={styles.sectionHead}>
<p className={styles.sectionKicker}>Every school page</p>
<h2 className={styles.sectionHeading}>What you&apos;ll see on every school</h2>
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
</div>
<div className={styles.hiwGrid}>
{/* Card 1 — Performance */}
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
<div className={styles.comparePreview}>
<div className={styles.compareHead}>
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
<div className={styles.compareHeadCell}>St Mary&apos;s<br />Catholic Primary</div>
<div className={styles.compareHeadCell}>School A</div>
<div className={styles.compareHeadCell}>School B</div>
</div>
{compareRows.map(({ label, a, b, aHi }) => (
<div key={label} className={styles.compareRow}>
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
<span className={styles.compareRowVal}>{b}</span>
</div>
))}
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Compare</div>
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
<div className={styles.hiwTitle}>Side by side</div>
{/* One verb for one feature. The site previously called this
"compare" in the nav, "shortlist" in the footer and "pin" here,
which reads as three separate things it does not have. */}
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
</div>
</div>
</div>
+87 -130
View File
@@ -1,146 +1,103 @@
/**
* Brand illustration — the landing hero's landscape.
* The landing hero artwork.
*
* Guideline rules for illustration: no people or faces, soft shapes, rounded
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
* a school" — a winding path up through layered hills to a small schoolhouse,
* with a pin marking where you are.
* This is the supplied illustration, not a drawing of one — see
* assets/hero-source.png and scripts/build-hero-images.js, which produces
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
*
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
* it renders in the RSC pass and never reaches the client bundle.
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
* renders in the RSC pass and never reaches the client bundle.
*
* TWO CROPS, ONE <picture>
* ------------------------
* The slot is two different shapes. On desktop the artwork sits behind the
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
* area the illustration reserves on its left. Below the one-column breakpoint
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
* behind it.
*
* A single file under `object-fit: cover` centre-crops, and at the band's
* extreme that slices a strip through the middle of the scene and loses the
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
* <source media> switches crop, not just resolution: `band` is pre-cropped
* around the school and is already near 2.6:1, so the narrow band barely
* crops it further.
*
* `object-position` is set in CSS per breakpoint and is load-bearing — see
* .heroArt in HomeView.module.css.
*
* The artwork is decorative: the proposition beside it carries the meaning, so
* alt is empty and it is hidden from assistive tech rather than described.
*/
/*
* These hex values mirror the brand palette in app/globals.css — --sky,
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
* around them. They are written literally on purpose: an illustration is
* artwork, not themed UI. Recolouring it per theme would break the picture,
* so it keeps one fixed, light palette in both themes (it sits on a Sand
* panel in light mode and reads as a framed image in dark mode).
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
* padding (1rem under 768px), so the artwork's box is that minus the padding —
* describing it as 100vw over-requests by ~48px worth of candidate at every
* width, which on a slow connection is a larger file than needed for the LCP
* element.
*/
const SKY_HIGH = '#DAEDF8';
const SKY_LOW = '#EFF8FB';
const CLOUD = '#FFFFFF';
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
const HILL_FAR = '#D6EDE2'; // --sage, lightened
const HILL_MID = '#A7D7C5'; // --sage, exact
const HILL_NEAR = '#7FC3AC';
const HILL_FRONT = '#5BA88F'; // --sage darkened toward --brand
const PATH_FILL = '#FAF6EE'; // --sand, lightened
const PATH_EDGE = '#E6DAC2'; // --sand, darkened
const SCHOOL_WALL = '#FCE8C3'; // cream
const SCHOOL_ROOF = '#F0A868'; // warm orange
const SCHOOL_DOOR = '#0F766E'; // --brand, exact
const SCHOOL_WINDOW = '#C7EBF5'; // --sky, exact
const SCHOOL_CLOCK = '#FAFAF8'; // --bg-primary (Warm White)
const FLAGPOLE = '#8FA3AE';
const PENNANT = '#F97360'; // --coral, exact
const TREE_DARK = '#2E7D6B';
const TREE_MID = '#3E8C74';
const TREE_LIGHT = '#4A9E85';
const TREE_PALE = '#7FC3AC';
const PIN = '#F97360'; // --coral, exact
const PIN_EYE = '#FFFFFF';
/** Scoped so the gradient id can't collide with another inline SVG. */
const SKY_GRADIENT_ID = 'sc-hero-sky';
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 2000;
const WIDE_H = 1125;
export function HeroIllustration() {
return (
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={SKY_HIGH} />
<stop offset="1" stopColor={SKY_LOW} />
</linearGradient>
</defs>
{/* Sky */}
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
{/* Two soft clouds, each a pair of overlapping ellipses */}
<g fill={CLOUD} opacity="0.85">
<ellipse cx="96" cy="86" rx="32" ry="15" />
<ellipse cx="122" cy="79" rx="23" ry="18" />
<ellipse cx="418" cy="58" rx="27" ry="13" />
<ellipse cx="439" cy="52" rx="19" ry="14" />
</g>
{/* Four layered rolling hills, palest and furthest first */}
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill={HILL_FAR} />
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill={HILL_MID} />
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill={HILL_NEAR} />
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill={HILL_FRONT} />
{/* Winding cream path, with a dotted edge for a little texture */}
<path
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
stroke={PATH_FILL}
strokeWidth="18"
fill="none"
strokeLinecap="round"
<picture>
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
rules have to precede the unconstrained desktop ones. */}
<source
media="(max-width: 860px)"
type="image/avif"
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
sizes={SIZES}
/>
<path
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
stroke={PATH_EDGE}
strokeWidth="18"
fill="none"
strokeLinecap="butt"
strokeLinejoin="round"
strokeDasharray="0.5 26"
opacity="0.5"
<source
media="(max-width: 860px)"
type="image/webp"
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
sizes={SIZES}
/>
{/*
The band's JPEG, and the reason it exists.
{/* The school at the top of the path */}
<g transform="translate(276 232)">
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
<g fill={SCHOOL_WINDOW}>
<rect x="24" y="46" width="16" height="14" rx="2" />
<rect x="86" y="46" width="16" height="14" rx="2" />
<rect x="24" y="70" width="16" height="12" rx="2" />
<rect x="86" y="70" width="16" height="12" rx="2" />
</g>
{/* Flagpole and coral pennant */}
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
{/* Clock in the gable */}
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
</g>
{/* Scattered rounded trees */}
<g fill={TREE_DARK}>
<circle cx="104" cy="372" r="27" />
<circle cx="136" cy="386" r="19" />
<circle cx="470" cy="336" r="23" />
</g>
<g fill={TREE_LIGHT}>
<circle cx="78" cy="394" r="21" />
<circle cx="492" cy="358" r="17" />
<circle cx="176" cy="420" r="18" />
</g>
<g fill={TREE_MID}>
<ellipse cx="44" cy="336" rx="15" ry="22" />
<ellipse cx="508" cy="296" rx="13" ry="18" />
<ellipse cx="150" cy="342" rx="12" ry="17" />
</g>
<g fill={TREE_PALE} opacity="0.9">
<circle cx="410" cy="420" r="16" />
<circle cx="438" cy="430" r="12" />
<circle cx="120" cy="470" r="14" />
</g>
{/* Location pin — you are here, the path leads up to the school */}
<g transform="translate(196 372)">
<path d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z" fill={PIN} />
<circle cx="21" cy="15.5" r="8" fill={PIN_EYE} />
</g>
</svg>
<img src> cannot vary by viewport, so it is always the wide crop. A
browser that takes neither AVIF nor WebP would therefore fall through
to the desktop frame on a phone and lose the schoolhouse — the exact
failure this whole component is arranged to prevent, surviving in the
one path nobody looks at. No `type` here, so it matches anywhere the
media query does, and it sits after the modern formats so they still
win where supported.
*/}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
<source
type="image/avif"
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
sizes={SIZES}
/>
<source
type="image/webp"
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
sizes={SIZES}
/>
{/*
The hero is the largest thing above the fold, so it is almost certainly
the LCP element: fetchPriority high, and never lazy. eslint's
no-img-element wants next/image, which cannot art-direct between two
different crops — that is the whole point of the <picture> above.
*/}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/brand/hero-wide-1400.jpg"
alt=""
aria-hidden="true"
width={WIDE_W}
height={WIDE_H}
decoding="async"
fetchPriority="high"
/>
</picture>
);
}
@@ -0,0 +1,169 @@
/**
* LeafletCutoffMapInner
* The cut-off distances drawn around the school.
*
* L.circle takes a radius in metres and projects it properly, which is exactly
* what these rings are: a straight-line distance from the school. (L.circleMarker
* would take pixels and would silently stop meaning anything as the user zoomed.)
*
* The latest year is filled and labelled; earlier years are hairlines behind it,
* so a tightening cut-off reads as a set of shrinking circles without needing a
* legend to decode it. The map auto-fits the widest ring, so the whole history
* is in frame whatever the distances are.
*/
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
export interface CutoffRing {
year: number;
radiusM: number;
latest: boolean;
}
interface Props {
lat: number;
lng: number;
rings: CutoffRing[];
/** The parent's geocoded postcode, once they have entered one. */
home: { lat: number; lng: number } | null;
interactive: boolean;
}
const SCHOOL_PIN = `
<div style="position:relative;width:22px;height:22px">
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
</div>`;
// A square rotated 45° — a different SHAPE from the school's circle, not just a
// different colour, so the two are still distinguishable to anyone who cannot
// separate the hues.
const HOME_PIN = `
<div style="width:18px;height:18px;display:grid;place-items:center">
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
</div>`;
function cssVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
const elRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layersRef = useRef<L.Layer[]>([]);
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
useEffect(() => {
if (!elRef.current || mapRef.current) return;
const map = L.map(elRef.current, {
zoomControl: false,
attributionControl: true,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
touchZoom: false,
}).setView([lat, lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
setTimeout(() => map.invalidateSize(), 60);
return () => {
map.remove();
mapRef.current = null;
layersRef.current = [];
zoomCtrlRef.current = null;
};
}, [lat, lng]);
// Redraw rings, pins and the viewport whenever the data changes.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
layersRef.current.forEach((l) => l.remove());
layersRef.current = [];
const brand = cssVar('--brand', '#0F766E');
const above = cssVar('--status-above', '#2F855A');
// Widest first, so the small recent ring is not buried under the old ones.
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
for (const r of ordered) {
const circle = L.circle([lat, lng], {
radius: r.radiusM,
color: brand,
weight: r.latest ? 3 : 1.5,
opacity: r.latest ? 1 : 0.45,
fill: r.latest,
fillColor: brand,
fillOpacity: r.latest ? 0.12 : 0,
interactive: false,
}).addTo(map);
layersRef.current.push(circle);
}
const school = L.marker([lat, lng], {
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
keyboard: false,
interactive: false,
}).addTo(map);
layersRef.current.push(school);
if (home) {
const pin = L.marker([home.lat, home.lng], {
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
keyboard: false,
interactive: false,
}).addTo(map);
layersRef.current.push(pin);
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
color: above,
weight: 2,
dashArray: '5 5',
opacity: 0.85,
interactive: false,
}).addTo(map);
layersRef.current.push(line);
}
// Frame the widest ring — and the home pin when it falls outside it, so a
// family beyond every cut-off can still see where they sit.
const widest = ordered[0]?.radiusM ?? 500;
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
map.fitBounds(bounds, { padding: [16, 16] });
}, [lat, lng, rings, home]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handlers = [
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
];
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
if (interactive && !zoomCtrlRef.current) {
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
zoomCtrlRef.current.addTo(map);
} else if (!interactive && zoomCtrlRef.current) {
zoomCtrlRef.current.remove();
zoomCtrlRef.current = null;
}
setTimeout(() => map.invalidateSize(), 80);
}, [interactive]);
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
}
+80 -85
View File
@@ -1,107 +1,102 @@
/**
* The schoolcompare mark.
*
* A location pin with a leaf growing inside it: where a school is, and a child
* growing there. The pin's tail sweeps left as the guideline's "signature path
* shape" — the journey a parent takes to find the right school — so the mark
* and the illustration style share one gesture.
* This is the supplied brand artwork, not a reconstruction of it. Two
* colourways were extracted from the logo sheet and live in public/brand:
*
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
* because the header and the favicon had previously drifted into two different
* logos.
* mark.png the primary lockup's mark — teal pin, white window and
* path, green leaves. Reads correctly on Warm White,
* white cards and Sand, and on both dark-theme grounds.
* mark-on-dark.png the on-dark colourway — white pin with the counter
* knocked through to the ground. Required wherever the
* ground is teal, because there the teal pin's silhouette
* disappears and only the white window survives.
*
* Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked
* out of it, so the mark needs exactly two colours and works on any ground.
* Both are written onto one 176×208 canvas with the artwork at the same
* height, so they are drop-in swappable — see ASPECT below for why that
* matters rather than merely being neat.
*
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
* <picture> source, so this needs no JavaScript and no client boundary.
*
* The wordmark beside it is live text in Manrope rather than the sheet's
* raster: the written style guide specifies Manrope, and live text stays
* selectable, scales cleanly and recolours with the theme.
*
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
* which is ample for the header (36px), the favicon and the share card, but
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
* consumer goes through this component or public/brand, so it is one swap.
*/
/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */
export const MARK_PATHS = {
/** The path tail, stroked — 8.5 wide with a round cap. */
tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
/** The pin head. */
head: { cx: 26.5, cy: 18.5, r: 16.5 },
/** Leaf stem, stroked — 2.6 wide with a round cap. */
stem: 'M26.5 29.5 C26.5 25 26.6 21 27 18',
/** Upper leaf, filled. */
leafUpper: 'M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z',
/** Lower leaf, filled. */
leafLower: 'M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z',
} as const;
const MARK_LIGHT = '/brand/mark.png';
const MARK_ON_DARK = '/brand/mark-on-dark.png';
/**
* The mark as a standalone SVG string.
* Intrinsic size of the artwork files, used to derive width from height.
*
* Satori (next/og) renders `<img>` with a data URI far more reliably than it
* renders inline SVG children, so the generated icon and share card both draw
* the mark this way — from the same geometry as the React component above,
* which is the whole point of MARK_PATHS.
* Both colourways are deliberately written onto the SAME canvas at the same
* artwork height, so this one ratio is correct for either of them. That is not
* tidiness — it is required. `variant="auto"` renders a single <img> whose
* srcset swaps the file underneath it, so the width/height attributes are
* shared by both colourways and cannot be varied per file. When the two were
* tightly cropped they had different ratios (0.810 and 0.875) and different
* padding, which meant a wrong aspect hint before load — a reflow on load
* under `width: auto` — and a visible jump in logo size whenever the OS theme
* flipped. Re-crop or replace one file and you must re-normalise both.
*/
export function markSvg(pin: string, leaf: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">',
`<path d="${MARK_PATHS.tail}" stroke="${pin}" stroke-width="8.5" stroke-linecap="round" fill="none"/>`,
`<circle cx="${MARK_PATHS.head.cx}" cy="${MARK_PATHS.head.cy}" r="${MARK_PATHS.head.r}" fill="${pin}"/>`,
`<path d="${MARK_PATHS.stem}" stroke="${leaf}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`,
`<path d="${MARK_PATHS.leafUpper}" fill="${leaf}"/>`,
`<path d="${MARK_PATHS.leafLower}" fill="${leaf}"/>`,
'</svg>',
].join('');
}
/** The same string as a data URI, ready for `<img src>`. */
export function markDataUri(pin: string, leaf: string): string {
return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
}
const ART_WIDTH = 176;
const ART_HEIGHT = 208;
const ASPECT = ART_WIDTH / ART_HEIGHT;
interface LogoMarkProps {
className?: string;
/** Rendered size in px. Defaults to inheriting via CSS. */
/** Rendered height in px. Width follows the artwork's aspect ratio. */
size?: number;
/** The pin. Defaults to the brand token so it inverts with the theme. */
pin?: string;
/** The leaf knocked out of the pin. */
leaf?: string;
/**
* Which colourway to serve.
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
* - `onDark` always the white pin — for teal grounds, which are teal in
* both themes (the footer band)
*/
variant?: 'auto' | 'onDark';
title?: string;
}
export function LogoMark({
className,
size,
pin = 'var(--brand)',
leaf = 'var(--bg-card)',
title,
}: LogoMarkProps) {
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
const height = size;
const width = Math.round(size * ASPECT);
const alt = title ?? '';
if (variant === 'onDark') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
className={className}
src={MARK_ON_DARK}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/>
);
}
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : true}
aria-label={title}
>
{title ? <title>{title}</title> : null}
<path
d={MARK_PATHS.tail}
stroke={pin}
strokeWidth={8.5}
strokeLinecap="round"
fill="none"
<picture>
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={className}
src={MARK_LIGHT}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/>
<circle cx={MARK_PATHS.head.cx} cy={MARK_PATHS.head.cy} r={MARK_PATHS.head.r} fill={pin} />
<path
d={MARK_PATHS.stem}
stroke={leaf}
strokeWidth={2.6}
strokeLinecap="round"
fill="none"
/>
<path d={MARK_PATHS.leafUpper} fill={leaf} />
<path d={MARK_PATHS.leafLower} fill={leaf} />
</svg>
</picture>
);
}
+6 -4
View File
@@ -57,13 +57,15 @@
.logoIcon {
display: inline-flex;
flex: 0 0 auto;
width: 36px;
height: 36px;
/* The artwork is taller than it is wide (153:189), so height drives the box
and width follows. Constraining both would squash the pin. */
height: 38px;
}
.logoIcon svg {
width: 100%;
.logoIcon img {
display: block;
height: 100%;
width: auto;
}
/*
+5 -6
View File
@@ -102,14 +102,13 @@ export function Navigation() {
<div className={styles.container}>
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
{/*
LogoMark's defaults are already correct for this ground: the pin
takes var(--brand) and the leaf is knocked out in var(--bg-card),
which is exactly the header's own background in both themes. No
explicit pin/leaf needed here — the footer, which sits on the
sunken teal band, does have to pass them.
variant="auto": the teal pin on the white header, swapping to the
on-dark artwork for dark-theme viewers. The footer has to force
onDark instead, because its band is teal in both themes and the
teal pin has no silhouette against it.
*/}
<span className={styles.logoIcon}>
<LogoMark />
<LogoMark size={38} />
</span>
<span className={styles.logoText}>
school<span className={styles.logoTextAccent}>compare</span>
+39 -7
View File
@@ -48,13 +48,32 @@
Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */
/*
* Knockout, not a colour.
*
* This marker was var(--brand) — the same value as .barExpected and
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
* rendered distinguishably at all. It scored 5.47:1 only against the empty
* track, which means it was visible precisely when a school was BELOW the
* national average and vanished for every school at or above it.
*
* No single colour fixes this, because the marker's position is data-driven:
* it can land on the bar, on the empty track, or straddle the boundary. So it
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
* the core reads; on the pale track the edge reads. Both tokens flip with the
* theme, so the pairing holds in dark mode too.
*
* The edge is box-shadow rather than border so it costs no layout width and
* cannot shift the 50% translate.
*/
.natTick {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
width: 3px;
transform: translateX(-50%);
background: var(--brand);
background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
border-radius: 2px;
z-index: 4;
pointer-events: none;
@@ -63,13 +82,14 @@
.natTick::before {
content: '';
position: absolute;
top: -3px;
top: -4px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--brand);
background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
}
.barHeaderRight {
@@ -122,12 +142,24 @@
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/*
* Expected and Exceeding were both var(--brand) — one colour for two series,
* distinguished only by which row you were looking at, while the legend
* claimed two.
*
* They are a sequential pair, not two categories: "exceeding" is a subset of
* the same cohort at a harder bar. So they take two steps of the same hue
* rather than two different hues, with the harder measure the more intense
* step. --brand-stronger is darker than --brand in the light theme and lighter
* in the dark one, which is the right direction in both: further from the
* ground.
*/
.barExpected {
background: var(--brand);
}
.barExceeding {
background: var(--brand);
background: var(--brand-stronger);
}
.barLabel {
+26 -3
View File
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
<SubjectColumn key={subject.name} subject={subject} />
))}
</div>
{/*
The legend describes what is drawn, which it previously did not.
Both data swatches were var(--status-above) — green — while the bars
they labelled were var(--brand) teal, and they were identical to each
other, so two different series shared one swatch. Worse, the only
swatch that matched the bar colour was the one labelled "National
average": anyone reading the chart by matching colours would conclude
the teal bars WERE the national average.
Each swatch now carries the exact value its bar carries, and the
national-average swatch mirrors the knockout marker rather than being
a flat colour, so it is recognisable as the thing on the chart.
*/}
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
<div
className={styles.legendSwatch}
style={{
background: 'var(--bg-card)',
boxShadow: '0 0 0 1px var(--text-primary)',
width: '3px',
height: '12px',
borderRadius: '2px',
}}
/>
National average
</div>
</div>
+31 -7
View File
@@ -47,7 +47,13 @@
width: 100%;
height: 100%;
background:
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
/* Sweeps toward the card colour, which is a shade lighter than this
ground in both themes. Hardcoded white was a bright flash across a
dark page every 1.4s while the tiles loaded. */
linear-gradient(100deg,
rgba(var(--bg-card-rgb), 0) 40%,
rgba(var(--bg-card-rgb), .5) 50%,
rgba(var(--bg-card-rgb), 0) 60%) var(--bg-secondary);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@@ -76,6 +82,15 @@
justify-content: center;
}
/*
* Controls that float ON the map.
*
* The map tiles are light in both themes, so these deliberately do NOT follow
* the theme — they follow the map. The literal ink below is the point: paired
* with a hardcoded white background, `color: var(--text-primary)` resolved to
* #E9EEF0 in the dark theme and put near-white text on a near-white button.
* A themed token is the wrong tool for a surface that never changes.
*/
.openHint {
display: inline-flex;
align-items: center;
@@ -85,7 +100,8 @@
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
/* See "Controls that float ON the map" above. */
color: #1C2731;
background: rgba(255, 255, 255, .85);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
@@ -113,11 +129,18 @@
on top of the blend. */
z-index: 450;
pointer-events: none;
/* The card colour, not white.
This ramp was hardcoded white and ended at var(--bg-card). In the light
theme that is white into white and invisible, as intended. In the dark
theme it climbed to 95% WHITE and then met a near-black card — a bright
band across the full width, right where the map is supposed to dissolve
into the header. Fading to the same colour the gradient lands on is the
whole trick, and it only works if that colour is a token. */
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, .35) 35%,
rgba(255, 255, 255, .75) 62%,
rgba(255, 255, 255, .95) 82%,
rgba(var(--bg-card-rgb), 0) 0%,
rgba(var(--bg-card-rgb), .35) 35%,
rgba(var(--bg-card-rgb), .75) 62%,
rgba(var(--bg-card-rgb), .95) 82%,
var(--bg-card) 100%);
}
@@ -134,7 +157,8 @@
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, .92);
color: var(--text-primary);
/* See "Controls that float ON the map" above. */
color: #1C2731;
cursor: pointer;
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
}
@@ -0,0 +1,53 @@
/*
* Anchored to .omniBoxContainer, which is position: relative for this reason.
*
* Every colour is a token, so the dropdown follows the theme. The dark theme
* redefines --bg-card, --border, --text-muted and --shadow-soft, and this
* inherits all four without a second rule.
*/
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
/* Above the sticky filter bar (10) and the hero layers (0–2), below the
skip-link (10000) and the modal overlay (1000). */
z-index: 40;
margin: 0;
padding: 4px;
list-style: none;
max-height: 320px;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
}
.option {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-primary);
}
/* Hover and keyboard share one style: the active option is the active option
however it became active. Two rules would drift. */
.option:hover,
.active {
background: var(--bg-secondary);
}
.name {
font-weight: 500;
}
.meta {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
/**
* The autosuggest dropdown. Presentational only — it fetches nothing and owns
* no state, so the fetching rules and the ARIA rules can be read separately.
*/
import type { Suggestion } from '@/lib/suggest';
import styles from './SuggestList.module.css';
/** The id the input's aria-activedescendant points at. */
export function suggestOptionId(id: string, index: number): string {
return `${id}-option-${index}`;
}
interface Props {
/** Shared with the input's aria-controls. */
id: string;
suggestions: Suggestion[];
activeIndex: number;
onPick: (s: Suggestion) => void;
onHover: (index: number) => void;
}
export function SuggestList({ id, suggestions, activeIndex, onPick, onHover }: Props) {
if (suggestions.length === 0) return null;
return (
<ul className={styles.list} id={id} role="listbox">
{suggestions.map((s, i) => (
<li
key={s.urn}
id={suggestOptionId(id, i)}
role="option"
aria-selected={i === activeIndex}
className={`${styles.option} ${i === activeIndex ? styles.active : ''}`}
/*
* onMouseDown, not onClick: the input's blur handler closes the list,
* and blur fires before click — so a click handler never runs. This
* is the classic autosuggest bug where the dropdown is unclickable
* with a mouse while working perfectly with a keyboard.
*/
onMouseDown={(e) => { e.preventDefault(); onPick(s); }}
onMouseEnter={() => onHover(i)}
>
<span className={styles.name}>{s.school_name}</span>
{/* Not decoration: there are many "St Mary's". */}
<span className={styles.meta}>{s.local_authority}</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,210 @@
/* Tokens only — see globals.css. Follows RankingsView's conventions, and in
particular its link treatment: table links take --text-primary with no
underline and a brand-coloured hover, not the browser default. The first
cut used bare <Link> with no class at all, which rendered as default blue
underlined links and read as unstyled beside the rest of the site. */
.container {
width: 100%;
min-width: 0;
}
.header {
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-family: var(--font-display);
text-wrap: balance;
}
.summary {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
/* Links in running copy: brand colour, underline on hover only. */
.inlineLink {
color: var(--brand);
text-decoration: none;
transition: color 0.2s ease;
}
.inlineLink:hover {
color: var(--brand-strong);
text-decoration: underline;
}
/* Phase variants are separate indexable pages, so the bare place page has to
link them — a sitemap entry alone leaves them with no internal path in. */
.phaseLinks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin: 0 0 1.25rem;
}
.phaseLink {
display: inline-block;
padding: 0.4rem 0.875rem;
border: 1px solid var(--border-strong);
border-radius: 999px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
transition: border-color 0.2s ease, color 0.2s ease;
}
.phaseLink:hover {
border-color: var(--brand);
color: var(--brand-strong);
}
/* The one number a list cannot give you, so it gets its own band. */
.compare {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.875rem 1.125rem;
margin: 0 0 1.5rem;
color: var(--text-primary);
font-size: 1rem;
}
.ofsted {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--text-secondary);
}
.group {
margin-bottom: 2rem;
}
.groupHeading {
display: flex;
align-items: baseline;
gap: 0.625rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-display);
margin: 0 0 0.75rem;
}
.groupCount {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
/* Wide content scrolls in its own container so the page body never does. */
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 600;
font-size: 0.8125rem;
}
.table tbody tr:last-child td {
border-bottom: none;
}
/*
* Header and value share one class and one rule, so they cannot drift apart.
*
* The first cut aligned them with two different selectors: `.table th:last-child`
* at (0,2,1) beat the element rule and went right, while `.num` at (0,1,0) lost
* to `.table td` at (0,1,1) and stayed left. The heading and its numbers sat on
* opposite edges of the column.
*
* width:1% with nowrap makes the measure column hug its content so the school
* name takes the remaining width — without it the two columns split evenly and
* the gap between heading and value reads as misalignment on a wide screen.
*/
.table th.num,
.table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
width: 1%;
white-space: nowrap;
}
/* The measure is spelled out; the tooltip carries the definition. */
.metricHead {
text-decoration: none;
cursor: help;
border-bottom: 1px dotted var(--border-strong);
}
/* Table links: site convention is body colour, brand on hover. */
.schoolLink {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolLink:hover {
color: var(--brand-strong);
}
/* "Not published" is a fact about the school, not an error. */
.noData {
color: var(--text-muted);
font-size: 0.8125rem;
}
.neighbours {
margin-top: 2rem;
}
.neighbours h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem;
font-family: var(--font-display);
}
.neighbours ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
list-style: none;
padding: 0;
margin: 0;
}
+264
View File
@@ -0,0 +1,264 @@
/**
* One place page, shared by all four families.
*
* They differ in what fills the registry, not in what the page shows, so a
* second component would be a second place to forget the same change.
*
* The local-versus-England comparison is the reason this page is not a list:
* it is the one number a parent cannot get by reading the schools one by one,
* and it is what keeps the page from reading as a name dropped into a
* template.
*/
import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site';
import { TrackPlaceView } from './TrackPlaceView';
import styles from './PlaceView.module.css';
interface Props {
detail: PlaceDetail;
phase?: 'primary' | 'secondary';
englandAverage: number | null;
/** Nearby places, so the page links onward instead of dead-ending. */
neighbours: PlaceSummary[];
}
// Ofsted grades in the order they are reported.
const OFSTED_LABELS: Array<[number, string]> = [
[1, 'Outstanding'], [2, 'Good'],
[3, 'Requires improvement'], [4, 'Inadequate'],
];
/**
* Column headings, taken from the site's own metric dictionary rather than
* invented here — see METRIC_DEFINITIONS in backend/schemas.py, surfaced at
* /api/metrics. The first cut said "RWM expected", which is jargon that
* appears nowhere else on the site.
*/
const METRICS = {
primary: {
key: 'rwm_expected_pct' as const,
heading: 'Reading, writing & maths',
hint: '% meeting the expected standard in reading, writing and maths',
unit: '%',
},
secondary: {
key: 'attainment_8_score' as const,
heading: 'Attainment 8',
hint: "Average grade across a pupil's best 8 GCSEs, including English and maths",
unit: '',
},
};
type PhaseKey = keyof typeof METRICS;
/** All-through schools sit in both phases, matching the search filters. */
function isPhase(school: School, phase: PhaseKey): boolean {
const p = (school.phase ?? '').toLowerCase();
if (p === 'all-through') return true;
return phase === 'secondary'
? p.includes('secondary') || p === '16 plus'
: p.includes('primary') || p.includes('middle');
}
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
const metric = METRICS[phase];
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th scope="col">School</th>
{/* Same class as the value cell below: one rule aligns both, so
they cannot drift apart. */}
<th scope="col" className={styles.num}>
<abbr className={styles.metricHead} title={metric.hint}>
{metric.heading}
</abbr>
</th>
</tr>
</thead>
<tbody>
{schools.map((s) => {
const value = s[metric.key];
return (
<tr key={s.urn}>
<td>
<Link href={schoolUrl(s.urn, s.school_name)} className={styles.schoolLink}>
{s.school_name}
</Link>
</td>
<td className={styles.num}>
{value == null
? <span className={styles.noData}>Not published</span>
: `${Math.round(Number(value))}${metric.unit}`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
const { place, schools, averages } = detail;
// Fall back to the single parent when the API predates the authorities
// field, so a stale cache never blanks the line entirely.
const authorities = place.authorities?.length
? place.authorities
: place.parent_authority
? [{ name: place.parent_authority, slug: authoritySlug(place.parent_authority), count: 0 }]
: [];
const local = averages[METRICS[phase ?? 'primary'].key];
const phaseWord = phase === 'secondary' ? 'Secondary schools'
: phase === 'primary' ? 'Primary schools' : 'Schools';
const graded = OFSTED_LABELS
.map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
.filter(([, n]) => n > 0);
/*
* An unphased page holds both primaries and secondaries, and they are
* scored on different measures — a percentage and a 0-90 score. Showing one
* column for both left 30% of rows blank on /schools/brentwood and put two
* incomparable scales in one column when it did not.
*
* So the phases get a table each. A blank cell inside one now means the
* school genuinely has no published result, which is worth saying.
*/
const groups: Array<[PhaseKey, School[]]> = phase
? [[phase, schools]]
: (['primary', 'secondary'] as PhaseKey[])
.map((p) => [p, schools.filter((s) => isPhase(s, p))] as [PhaseKey, School[]])
.filter(([, list]) => list.length > 0);
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'ItemList',
name: `${phaseWord} in ${place.name}`,
numberOfItems: schools.length,
// Alphabetical, and said so. Without this an ItemList carrying
// `position` reads as a ranking, which would be a claim the page
// stopped making when the table became A-Z.
itemListOrder: 'https://schema.org/ItemListOrderAscending',
itemListElement: schools.slice(0, 20).map((s, i) => ({
'@type': 'ListItem',
position: i + 1,
url: absoluteUrl(schoolUrl(s.urn, s.school_name)),
name: s.school_name,
})),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Schools', item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: place.name },
],
},
],
};
return (
<div className={styles.container}>
{/* One line, and all four place families are measured, because they all
render through this component. */}
<TrackPlaceView kind={place.kind} slug={place.slug}
count={place.count} phase={phase} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className={styles.header}>
<h1>{phaseWord} in {place.name}</h1>
<p className={styles.summary}>
{place.count} schools
{authorities.length > 0 && (
<>
{' · '}
{/* Every authority, not just the largest. A quarter of outcodes
and a third of towns cross a boundary: SW19 is mostly Merton
but partly Wandsworth, and naming one asserts otherwise. */}
{authorities.map((a, i) => (
<span key={a.name}>
{i > 0 && (i === authorities.length - 1 ? ' and ' : ', ')}
{/* No slug means no page: City of London and the Isles of
Scilly hold too few schools for one. Saying where the
place is stays right; linking there would 404. */}
{a.slug
? (
<Link href={`/schools/authority/${a.slug}`} className={styles.inlineLink}>
{a.name}
</Link>
)
: a.name}
</span>
))}
</>
)}
</p>
</header>
{!phase && (place.phases ?? []).length > 0 && (
<nav className={styles.phaseLinks} aria-label="By phase">
{(place.phases ?? []).map((ph) => (
/* placeUrl, not a template: the bare `/schools/[slug]/[phase]`
shape belongs to towns alone, and using it everywhere sent
every authority page into the town namespace. */
<Link key={ph} href={placeUrl(place.kind, place.slug, ph)}
className={styles.phaseLink}>
{ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name}
</Link>
))}
</nav>
)}
{local != null && englandAverage != null && (
<p className={styles.compare} data-testid="local-vs-england">
{place.name} averages <strong>{Math.round(local)}</strong> against{' '}
<strong>{Math.round(englandAverage)}</strong> across England.
</p>
)}
{graded.length > 0 && (
<ul className={styles.ofsted} data-testid="ofsted-distribution">
{graded.map(([label, n]) => (
<li key={label}>{label}: <strong>{n}</strong></li>
))}
</ul>
)}
{groups.map(([p, list]) => (
<section key={p} className={styles.group}>
{groups.length > 1 && (
<h2 className={styles.groupHeading}>
{p === 'secondary' ? 'Secondary schools' : 'Primary schools'}
<span className={styles.groupCount}>{list.length}</span>
</h2>
)}
<SchoolTable schools={list} phase={p} />
</section>
))}
{neighbours.length > 0 && (
<nav className={styles.neighbours} aria-label="Nearby places">
<h2>Nearby</h2>
<ul>
{neighbours.map((n) => (
<li key={n.kind + n.slug}>
<Link href={placeUrl(n.kind, n.slug)} className={styles.inlineLink}>{n.name}</Link>
</li>
))}
</ul>
</nav>
)}
</div>
);
}
@@ -0,0 +1,47 @@
'use client';
/**
* Fires `place_viewed` once per location page.
*
* A separate client component because PlaceView is a server component and
* cannot call into the browser. It renders nothing — its whole job is the
* effect, which keeps the page itself server-rendered.
*
* Umami already counts a pageview for every one of these URLs, so this is not
* about traffic. It is about `kind`: whether to keep investing in the location
* layer turns on which *sort* of page earns engagement — towns, authorities,
* London localities or postcode districts — and a pageview cannot say, because
* all four families share the /schools/ prefix and only the registry knows
* which is which.
*/
import { useEffect } from 'react';
import { track, getNavigationSource } from '@/lib/analytics';
interface Props {
kind: string;
slug: string;
count: number;
phase?: 'primary' | 'secondary';
}
export function TrackPlaceView({ kind, slug, count, phase }: Props) {
useEffect(() => {
track('place_viewed', {
kind,
slug,
// "all" rather than omitting it, so the unphased page is a value in the
// same field rather than a gap that has to be interpreted.
phase: phase ?? 'all',
school_count: count,
// Internal navigation only. An arrival from Google reads as 'direct'
// here; Umami's own pageview referrer is where external attribution
// lives, and these pages exist to be arrived at externally.
from: getNavigationSource(),
});
// Once per place, not once per render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kind, slug, phase]);
return null;
}
@@ -9,62 +9,128 @@
* AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript.
*
* The two views are stacked in one grid cell so switching never shifts layout,
* which means the card is sized by the TALLER of them. Keep any view added
* here close to the tile grid in height: a cut-off-distance view carrying a
* chart, a table and a map was tried, measured 1402px against the tiles' 316px,
* and left the default view as four tiles adrift in blank card. It now lives in
* DistanceSection.
*/
import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types';
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function AdmissionsSection({
admissions,
admissionsHistory,
admissionDistance,
isAllThrough,
}: {
admissions: SchoolAdmissions;
/* Nullable: the two data sources are independent. A minority of schools have
a published cut-off distance but no EES admissions row (3% of the schools
that render, sampled against staging), and for those this section exists
to carry the distance alone. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
isAllThrough: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2;
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions);
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
const title = (
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
);
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
) : null;
{/* Spans both columns rather than taking a half-width cell. This is the
figure parents come to the page for, and at tile width the two-line
"0.31 miles / September 2025" pairing wraps badly. */}
{/* The {' '} between the figure and its metric support is not decoration.
The two are flex children, so the gap is drawn by CSS and the text layer
had nothing between them: textContent read "0.88 miles1.4 km", which is
what a screen reader announces and what any text matcher sees. Whitespace
text nodes are not rendered as flex items, so this changes the reading
without changing the layout. */}
const distanceTile = cutoff && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
<dd className={styles.admissionsTileNum}>
{cutoff.primary}{' '}
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
</dd>
<dt className={styles.admissionsTileLabel}>
Last distance offered · {cutoff.entryYear}
</dt>
</div>
);
const distanceNote = cutoff && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
);
/*
* Which row template the tile grid needs.
*
* The grid reserves two equal rows so the year view matches the height of the
* trend chart beside it. With a cut-off and no EES admissions there are no
* metric tiles to fill them, and the reserved rows render as a bare block of
* the grid's own gap colour under the distance tile.
*/
const hasMetricTiles = admissions != null && (
admissions.places_offered != null
|| admissions.first_preference_applications != null
|| admissions.first_preference_offer_pct != null
|| admissions.total_applications != null
);
const tilesClass = [
styles.admissionsTiles,
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
].filter(Boolean).join(' ');
const yearView = (
<>
<dl className={styles.admissionsTiles}>
{admissions.places_offered != null && (
<dl className={tilesClass}>
{admissions?.places_offered != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt>
</div>
)}
{admissions.first_preference_applications != null && (
{admissions?.first_preference_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div>
)}
{admissions.first_preference_offer_pct != null && (
{admissions?.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
@@ -81,20 +147,25 @@ export function AdmissionsSection({
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div>
)}
{admissions.total_applications != null && (
{admissions?.total_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div>
)}
{distanceTile}
</dl>
{admissionsSummary.chip && (
{admissionsSummary?.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
{distanceNote}
</>
);
const trendView = (
{/* Guarded rather than asserted: showAdmissionsTrend already requires
admissions, and tying the two together in one expression keeps that
invariant checked by the compiler instead of assumed. */}
const trendView = admissions && (
<>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} />
@@ -109,18 +180,24 @@ export function AdmissionsSection({
</>
);
const views: AdmissionsView[] = [
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
];
if (showAdmissionsTrend) {
views.push({
id: 'trend',
label: `${admissionsHistory.length}-year trend`,
content: trendView,
className: styles.admissionsViewTrend,
});
}
return (
<Section id="admissions">
{showAdmissionsTrend ? (
<AdmissionsViewToggle
title={title}
subtitle={subtitle}
trendLabel={`${admissionsHistory.length}-year trend`}
yearView={yearView}
trendView={trendView}
/>
{views.length > 1 ? (
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
) : (
/* No trend data — render statically, with no client component at all. */
/* One view — render statically, with no client component at all. */
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
@@ -3,14 +3,25 @@
import { useState, type ReactNode } from 'react';
import styles from './schoolSections.module.css';
export interface AdmissionsView {
id: string;
/** Button text. "This year" and the "N-year trend" label are asserted by the
* e2e journey, so they are not free to drift. */
label: string;
content: ReactNode;
/** The year view sizes its tile grid to match the chart beside it, so each
* view keeps its own class rather than sharing one wrapper style. */
className?: string;
}
/**
* The only interactive part of the primary admissions section, and the only
* client component in components/school/.
*
* Both views are always present in the DOM and visibility is toggled with the
* `hidden` attribute — matching the previous behaviour exactly — so the
* server-rendered markup passed in as yearView/trendView never ships as client
* JavaScript.
* Every view is always present in the DOM and visibility is toggled with the
* `hidden` attribute, so the server-rendered markup passed in as content never
* ships as client JavaScript — this component carries the state and nothing
* else.
*
* It spans the header and the viewport because the segmented control sits
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
@@ -19,39 +30,38 @@ import styles from './schoolSections.module.css';
export function AdmissionsViewToggle({
title,
subtitle,
trendLabel,
yearView,
trendView,
views,
}: {
title: ReactNode;
subtitle: ReactNode;
trendLabel: string;
yearView: ReactNode;
trendView: ReactNode;
views: AdmissionsView[];
}) {
const [view, setView] = useState<'year' | 'trend'>('year');
const [active, setActive] = useState(views[0]?.id);
return (
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
This year
</button>
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
{trendLabel}
</button>
{views.map((v) => (
<button
key={v.id}
type="button"
aria-pressed={active === v.id}
onClick={() => setActive(v.id)}
>
{v.label}
</button>
))}
</div>
</div>
{subtitle}
<div className={styles.admissionsViewport}>
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
{yearView}
</div>
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
{trendView}
</div>
{views.map((v) => (
<div key={v.id} className={v.className} hidden={active !== v.id}>
{v.content}
</div>
))}
</div>
</>
);
@@ -0,0 +1,181 @@
'use client';
/**
* CutoffMapPanel — "How far away are you?"
*
* Measures a postcode against the one cut-off we publish, and will draw that
* cut-off as a ring around the school on request.
*
* It used to compare against every published year and show a set of shrinking
* rings. Earlier years are now held back as a paid feature and no longer leave
* the API, so this answers one question about one year — which makes the
* verdict sharper to state, and puts more weight on qualifying it properly,
* since there is no run of years left to soften a single close call.
*
* The map is not rendered until asked for: before a postcode is entered it is a
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
* height to say that. A successful check opens it automatically, because that
* is the point at which it starts answering something.
*
* The postcode never leaves the browser except to postcodes.io for a lat/long,
* and nothing is stored — this is a client-side measurement, not a lookup
* against the family.
*/
import { useState, type FormEvent } from 'react';
import dynamic from 'next/dynamic';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils';
import {
compareToCutoff, CUTOFF_CHECK_CAVEAT,
type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered';
import styles from './schoolSections.module.css';
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
ssr: false,
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
});
// Written out rather than composed from the verdict string. A computed
// `styles[...]` key silently yields undefined when a class is renamed, and an
// unstyled "outside" result would look exactly like an "inside" one.
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
inside: styles.cutoffResultInside,
outside: styles.cutoffResultOutside,
'too-close': styles.cutoffResultTooClose,
};
export function CutoffMapPanel({
schoolInfo,
cutoff,
}: {
schoolInfo: School;
cutoff: SchoolAdmissionDistance;
}) {
const [postcode, setPostcode] = useState('');
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
const [result, setResult] = useState<CutoffCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [mapOpen, setMapOpen] = useState(false);
const lat = schoolInfo.latitude;
const lng = schoolInfo.longitude;
const cutoffM = cutoff.distance_m;
// Without coordinates there is nothing to measure against and nothing to draw.
if (lat == null || lng == null || cutoffM == null) return null;
const onCheck = async (e: FormEvent) => {
e.preventDefault();
const value = postcode.trim();
if (!value) return;
if (!isValidPostcode(value)) {
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
setResult(null);
setHome(null);
return;
}
setBusy(true);
setError(null);
try {
const point = await geocodePostcode(value);
if (!point) {
setError('We could not find that postcode. Check it and try again.');
setResult(null);
setHome(null);
return;
}
setHome({ lat: point.latitude, lng: point.longitude });
setMapOpen(true);
// calculateDistance returns kilometres; everything here is metres.
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
} catch {
setError('Something went wrong looking up that postcode. Try again in a moment.');
setResult(null);
setHome(null);
} finally {
setBusy(false);
}
};
return (
<div className={styles.cutoffCheck}>
<p className={styles.cutoffCheckSub}>
Straight-line distance from your postcode, compared with the September{' '}
{cutoff.year} cut-off. Not stored.
</p>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
<input
id="cutoff-postcode"
type="text"
inputMode="text"
autoComplete="postal-code"
spellCheck={false}
placeholder="e.g. SE23 3NA"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
className={styles.cutoffCheckInput}
/>
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
{busy ? 'Checking…' : 'Check'}
</button>
</form>
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
{result && (
<div
className={`${styles.cutoffCheckResult} ${VERDICT_CLASS[result.verdict]}`}
role="status"
>
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
</div>
)}
{mapOpen ? (
<div className={styles.cutoffMapReveal}>
<div className={styles.cutoffMapFigure}>
<CutoffMap
lat={lat}
lng={lng}
rings={[{ year: cutoff.year, radiusM: cutoffM, latest: true }]}
home={home}
interactive={false}
/>
</div>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
September {cutoff.year} cut-off
</li>
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
</div>
) : (
<button
type="button"
className={styles.cutoffMapToggle}
onClick={() => setMapOpen(true)}
>
Show this distance on a map
</button>
)}
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
</div>
);
}
@@ -0,0 +1,51 @@
/**
* DistanceSection — "How far away are you?"
*
* The section exists to answer one question a parent cannot answer from a
* number alone: whether their own address falls inside it. The figure itself is
* already on the Admissions tile above; this turns it into something they can
* act on.
*
* Scope note. This used to carry the full published record — chart, year table
* and a set of shrinking rings. Earlier years are now held back as a paid
* feature and no longer leave the API at all, so what remains is the latest
* year and the check against it. The pipeline is unchanged: every published
* year is still loaded into fact_admission_distance, so restoring history for
* entitled callers is a serving change rather than a re-collection.
*
* Server component; the map and postcode form carry their own client boundary.
*/
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffMapPanel } from './CutoffMapPanel';
export function DistanceSection({
admissionDistance,
schoolInfo,
}: {
admissionDistance: SchoolAdmissionDistance | null;
schoolInfo: School;
}) {
// Without a figure there is nothing to compare against, and without
// coordinates there is nothing to measure — CutoffMapPanel enforces the
// second, but the section must not render an empty card either way.
if (
admissionDistance?.distance_m == null
|| schoolInfo.latitude == null
|| schoolInfo.longitude == null
) {
return null;
}
return (
<Section id="distance">
<h2 className={styles.sectionTitle}>How far away are you?</h2>
<p className={styles.sectionSubtitle}>
Check your postcode against the furthest home offered a place in
September {admissionDistance.year}.
</p>
<CutoffMapPanel schoolInfo={schoolInfo} cutoff={admissionDistance} />
</Section>
);
}
@@ -13,13 +13,14 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { InclusionSection } from './InclusionSection';
import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection';
@@ -34,6 +35,7 @@ export interface PrimarySchoolSectionsProps {
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -42,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -91,14 +94,23 @@ export function PrimarySchoolSections({
/>
)}
{admissions && (
{/* Either source is enough to justify the section. The cut-off distance
and the EES admissions figures come from different places and a
minority of schools have one without the other — gating on admissions
alone would hide a published distance on those pages. */}
{(admissions || admissionDistance) && (
<AdmissionsSection
admissions={admissions}
admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
isAllThrough={flags.isAllThrough}
/>
)}
{/* Its own section, directly after Admissions: it answers the question
the tile above raises. */}
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
{flags.hasInclusionData && (
<InclusionSection
latestResults={flags.latestResults}
@@ -6,17 +6,26 @@
* JavaScript at all. Server component.
*/
import type { School, SchoolAdmissions } from '@/lib/types';
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import {
describeCutoff, describeCutoffAbsence,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
admissions, schoolInfo, hasSixthForm,
admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
}: {
admissions: SchoolAdmissions;
/* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
schoolInfo: School;
hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -40,32 +49,43 @@ export function SecondaryAdmissionsSection({
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
{admissions?.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
{admissions?.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
{admissions?.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
{admissions?.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
{cutoff && (
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
<div className={styles.metricLabel}>
Last distance offered · {cutoff.entryYear}
</div>
<div className={styles.metricValue}>
{cutoff.primary}{' '}
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
{admissions?.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
@@ -73,9 +93,24 @@ export function SecondaryAdmissionsSection({
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{/* Replaces a blanket "distance cut-off data is not available for this
school", which was hardcoded onto every secondary page and was untrue
wherever the local authority does publish. The absence is now stated
only when it is real, and names the authority that would hold it. */}
{cutoff ? (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
) : (
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
{describeCutoffAbsence({
localAuthority: schoolInfo.local_authority,
admissionsPolicy: schoolInfo.admissions_policy,
admissionsHistory,
})}
</p>
)}
{hasSixthForm && (
<div className={styles.sixthFormNote}>
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more