Compare commits

..
Author SHA1 Message Date
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
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
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
31 changed files with 2648 additions and 1220 deletions

No files matched your search

-1
View File
@@ -633,7 +633,6 @@ async def get_school_details(request: Request, urn: int):
"admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [],
"admission_distance": supplementary.get("admission_distance"),
"admission_distance_history": supplementary.get("admission_distance_history") or [],
"sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"),
+16 -16
View File
@@ -771,7 +771,6 @@ def _empty_supplementary() -> dict:
"admissions": None,
"admissions_history": [],
"admission_distance": None,
"admission_distance_history": [],
"sen_detail": None,
"phonics": None,
"deprivation": None,
@@ -850,29 +849,30 @@ 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 — all years per URN, oldest first, with the latest
# also exposed on its own.
# Last distance offered — the latest year per URN, and only that.
#
# The history was deliberately withheld at first: coverage is ragged (a
# school may have 2021 and 2026 and nothing between), and a plain series
# would draw a trend line straight through gaps that are absences of
# publication rather than absences of a cut-off. It is served now because
# the client distinguishes those gaps explicitly — see cutoffYearRows in
# lastDistanceOffered.ts, which classifies every year in the range and
# breaks the line rather than interpolating across it.
# 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.asc())
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
.all()
)
history: dict = {urn: [] for urn in urns}
seen = set()
for d in rows:
history[d.urn].append(_admission_distance_dict(d))
for urn, rows_for_urn in history.items():
result[urn]["admission_distance_history"] = rows_for_urn
result[urn]["admission_distance"] = rows_for_urn[-1] if rows_for_urn else None
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.
+7 -5
View File
@@ -151,11 +151,13 @@ 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: full history oldest-first, with the newest year also
# exposed on its own. The fixture rows are deliberately out of order, so
# this only passes if the query's ORDER BY is doing the work.
assert [r["year"] for r in out[1]["admission_distance_history"]] == [2024, 2025, 2026]
# 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.
@@ -171,4 +173,4 @@ def test_single_wrapper_matches_batch(monkeypatch):
assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == []
assert single["admission_distance"] is None
assert single["admission_distance_history"] == []
assert "admission_distance_history" not in single
+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
@@ -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?
+289 -75
View File
@@ -1226,21 +1226,13 @@ const CUTOFF_CANDIDATE_URNS = [
101099, 100553, 102574, 100769, // mixed
];
async function schoolWithCutoff(page: Page, minPublishedYears = 1) {
async function schoolWithCutoff(page: Page) {
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const body = await res.json();
if (body?.admission_distance?.distance_m == null) continue;
const published = (body.admission_distance_history ?? [])
.filter((h: { distance_m?: number | null }) => h.distance_m != null);
if (published.length < minPublishedYears) continue;
return {
urn,
distance: body.admission_distance,
history: published,
phase: body.school_info?.phase,
};
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
}
return null;
}
@@ -1260,7 +1252,11 @@ test('a published cut-off distance is shown with the year it belongs to', async
await expect(label).toContainText(`September ${found!.distance.year}`);
// And the figure itself, in the unit councils publish in.
await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible();
// No trailing \b: the tile's support figure is an adjacent text node, so
// the element reads "0.88 miles 1.4 km" and a word boundary after "miles"
// is not guaranteed. The leading shape is what matters — a decimal figure
// in miles, never a metric one.
await expect(page.getByText(/\d+\.\d+ miles/).first()).toBeVisible();
});
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
@@ -1317,103 +1313,321 @@ test('no school page shows an implausible cut-off distance', async ({ page }) =>
// ── The Distance view, map and postcode check ────────────────────────────
test('a school with several published years gets the Distance view', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
const isSecondary = /secondary/i.test(found!.phase ?? '');
if (!isSecondary) {
// Primary pages carry a segmented control; the detail sits behind it.
const distanceTab = page.getByRole('button', { name: 'Distance' });
await expect(distanceTab).toBeVisible();
await distanceTab.click();
await expect(distanceTab).toHaveAttribute('aria-pressed', 'true');
}
// Every published year must appear as a row, whichever template rendered it.
for (const h of found!.history as { year: number }[]) {
await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible();
test('the public API serves the latest cut-off only', async ({ page }) => {
/*
* Earlier years are held back as a paid feature. This endpoint is public and
* unauthenticated, so withholding them in the UI alone would be decoration:
* anyone could read the history out of the network tab. The mart still holds
* every year — this asserts what leaves the process, not what was collected.
*/
let checked = 0;
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const body = await res.json();
if (body?.admission_distance?.distance_m == null) continue;
checked += 1;
expect(body, `URN ${urn} still exposes a cut-off history`)
.not.toHaveProperty('admission_distance_history');
}
test.skip(checked === 0, 'no cut-off distances available to check yet');
});
test('the year table never leaves a gap unexplained', async ({ page }) => {
// A blank row would tell a parent nothing; each gap must name its reason.
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
test('a school page shows no year-by-year cut-off record', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const statuses = await page.locator('[class*="cutoffPill"]').allTextContents();
expect(statuses.length).toBeGreaterThan(0);
for (const s of statuses) {
expect(s.trim(), 'every row carries a status').not.toBe('');
}
// The stronger claim the data cannot support must never appear.
await expect(page.getByText(/every applicant was offered/i)).toHaveCount(0);
// The render side of the same rule, so a future component cannot put the
// history back without the API.
await expect(page.getByText(/Last distance offered, by year/)).toHaveCount(0);
await expect(page.getByText(/too few to read as a trend/)).toHaveCount(0);
/*
* No "Distance" tab in the admissions segmented control.
*
* Scoped to the control, and exact, because getByRole matches accessible
* names by case-insensitive SUBSTRING: an unscoped { name: 'Distance' }
* matched "Show this distance on a map" — the map toggle added by this same
* feature — and failed a page that was entirely correct. Naming the group
* this assertion is about also means unrelated copy elsewhere on the page
* can never break it again.
*/
const viewToggle = page.getByRole('group', { name: 'Admissions view' });
const tabs = await viewToggle.getByRole('button').allTextContents();
// Read the tabs positively rather than asserting an absence against a
// locator that might resolve to nothing: if the group selector ever stops
// matching, an absence check passes for the wrong reason, which is how the
// bug this test is guarding would slip back in unnoticed.
expect(tabs, 'admissions view toggle did not resolve').toContain('This year');
expect(tabs.map((s) => s.trim()), `admissions tabs: ${tabs.join(', ')}`)
.not.toContain('Distance');
});
test('the postcode check answers with a distance and per-year verdicts', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
test('the postcode check answers for the published year, and names it', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const section = page.locator('#distance');
// Needs coordinates as well as a figure; not every school has both.
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
const input = page.getByLabel('Your postcode');
await expect(input).toBeVisible();
await input.fill('SW1A 1AA');
await page.getByRole('button', { name: 'Check' }).click();
await page.getByLabel('Your postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Check', exact: true }).click();
// Either a verdict or a plain error — never a silent no-op.
const result = page.getByRole('status');
const error = page.getByRole('alert');
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
if (await result.isVisible()) {
await expect(result).toContainText(/mile|m\b/);
// A verdict without its year is a number a parent cannot place.
await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`));
await expect(result).toContainText(/inside|beyond|too close/);
// Both figures in the sentence must be in the same unit. The verdict used
// to render the home distance in metres and the cut-off in miles — "69 m
// away — inside the September 2026 cut-off of 0.17 miles" — which asks the
// reader to convert between units to check a comparison already made for
// them. Miles throughout: the unit councils publish and quote.
const headline = (await result.textContent()) ?? '';
const verdictLine = headline.split('.')[0];
expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/);
expect((verdictLine.match(/miles/g) ?? []).length,
`verdict should quote two figures in miles: ${verdictLine}`).toBe(2);
}
});
test('the postcode check states its limits before it is used', async ({ page }) => {
// The caveat is not revealed with the answer — it is there while the parent
// is deciding whether to trust the answer at all.
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/An indication only/)).toBeVisible();
await expect(page.getByText(/not a catchment boundary/)).toBeVisible();
const caveat = page.getByText(/Distance is the last criterion applied/);
await expect(caveat).toBeVisible();
await expect(caveat).toContainText(/not a catchment boundary/);
await expect(caveat).toContainText(/walking route/);
await expect(caveat).toHaveCount(1);
});
test('the distance section never scrolls the page sideways', async ({ page }) => {
const found = await schoolWithCutoff(page, 2);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
// The year table is deliberately wider than a phone; its own wrapper has to
// absorb that, or the whole page slides under the reader's thumb.
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
});
test('no single section dominates the height of a school page', async ({ page }) => {
/*
* The admissions views are stacked in one grid cell so switching them never
* shifts layout, which means the card is sized by its TALLEST view while the
* hidden ones keep their box. A distance view carrying a chart, a table and a
* map was added there and measured 1402px against the tile grid's 316px; the
* DEFAULT view rendered as four tiles adrift in ~1080px of blank card, and
* Admissions alone came to half the height of every section on the page
* (1503px against 526px for the next largest).
*
* Measuring the card's internals cannot catch it: the tile grid is
* `flex: 1`, so it absorbs the stretch and every box still looks full. What
* a reader actually sees is one section wildly out of proportion with its
* neighbours, so that is what this asserts.
*/
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(1200); // charts settle, and they carry real height
const sections = await page.evaluate(() =>
[...document.querySelectorAll('section[id]')]
.map((s) => ({ id: s.id, h: Math.round(s.getBoundingClientRect().height) }))
.filter((s) => s.h > 0));
expect(sections.length).toBeGreaterThanOrEqual(3);
const heights = sections.map((s) => s.h).sort((a, b) => a - b);
const median = heights[Math.floor(heights.length / 2)];
const worst = sections.reduce((a, b) => (a.h > b.h ? a : b));
// Generous: a rich section may fairly run to twice a plain one. Nearly three
// times over is the shape of a layout fault, not of denser content.
expect(
worst.h / median,
`#${worst.id} is ${worst.h}px against a ${median}px median: `
+ sections.map((s) => `${s.id}=${s.h}`).join(', '),
).toBeLessThan(2.5);
});
/*
* England-only corpus.
*
* GIAS ships the whole UK plus overseas and offshore establishments, none of
* which carry comparable DfE performance data. dim_school and dim_location
* exclude them (vars.non_england_school_type_codes in dbt_project.yml), which
* keeps them out of the site, the filter lists and the sitemap together.
*
* These assert the published surface, not the warehouse: the dbt test
* assert_england_only_schools guards the marts, and these guard what the
* environment actually serves once the marts have been rebuilt.
*/
const WELSH_AUTHORITIES = [
'Blaenau Gwent', 'Bridgend', 'Caerphilly', 'Cardiff', 'Carmarthenshire',
'Ceredigion', 'Conwy', 'Denbighshire', 'Flintshire', 'Gwynedd',
'Isle of Anglesey', 'Merthyr Tydfil', 'Monmouthshire', 'Neath Port Talbot',
'Newport', 'Pembrokeshire', 'Powys', 'Rhondda Cynon Taf', 'Swansea',
'Torfaen', 'Vale of Glamorgan', 'Wrexham',
];
const NON_ENGLAND_AUTHORITIES = [
'BFPO Overseas Establishments', 'Fieldwork Overseas Establishments',
'Gibraltar Overseas Establishments', 'Guernsey Offshore Establishments',
'Isle of Man Offshore Establishments', 'Jersey Offshore Establishments',
'Scotland Offshore Establishments',
];
const NON_ENGLAND_TYPES = [
'Welsh establishment', 'Offshore schools',
"Service children's education", 'British schools overseas',
];
test('the local authority filter offers no Welsh or overseas authority', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { local_authorities: las } = await res.json();
expect(Array.isArray(las)).toBeTruthy();
// Guards against the list being empty, which would pass the check below
// for the wrong reason.
expect(las.length).toBeGreaterThan(100);
const leaked = [...WELSH_AUTHORITIES, ...NON_ENGLAND_AUTHORITIES]
.filter((la) => las.includes(la));
expect(leaked, `non-England authorities still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('the school type filter offers no non-England establishment type', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { school_types: types } = await res.json();
expect(Array.isArray(types)).toBeTruthy();
expect(types.length).toBeGreaterThan(10);
const leaked = NON_ENGLAND_TYPES.filter((t) => types.includes(t));
expect(leaked, `non-England types still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('searching a Welsh authority by name returns no schools', async ({ page }) => {
// Cardiff held 144 Welsh establishments and nothing else, so the authority
// should now be absent from the corpus entirely rather than merely thinned.
const res = await page.request.get('/api/schools?local_authority=Cardiff&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
});
test('a Welsh school GIAS does not type as Welsh is gone too', async ({ page }) => {
// Beechwood College, Sully, CF64 5SE. GIAS types it "Special post 16
// institution" (32), not "Welsh establishment" (30), so the establishment
// type filter alone left it behind — it was the last Welsh school standing.
const res = await page.request.get('/api/schools?local_authority=Vale%20of%20Glamorgan&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
const page404 = await page.goto('/school/142458-beechwood-college');
expect(page404?.status()).toBe(404);
});
test('English schools with Welsh postcodes are kept', async ({ page }) => {
// Royal Mail postcode areas straddle the border: these Gloucestershire
// schools carry NP16/NP25 postcodes but are English schools with English
// data. A postcode-based filter would have wrongly deleted them, so this
// guards the fix from being "simplified" into one.
for (const urn of [115639, 115655, 137382]) {
const res = await page.request.get(`/api/schools/${urn}`);
expect(res.ok(), `URN ${urn} should still resolve`).toBeTruthy();
const { school_info } = await res.json();
expect(school_info.local_authority).toBe('Gloucestershire');
}
});
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
// Paired on purpose: the Welsh assertion alone would also pass if the whole
// site were down, which is the failure this test most needs to distinguish.
const english = await page.request.get('/api/schools?search=primary&per_page=1');
expect(english.ok()).toBeTruthy();
const [first] = (await english.json()).schools ?? [];
expect(first, 'no English school available to compare against').toBeTruthy();
const good = await page.goto(`/school/${first.urn}-x`);
expect(good?.status(), 'an English school should still resolve').toBeLessThan(400);
// Adamsdown Primary School, Cardiff — a Welsh establishment (URN 401559).
const welsh = await page.goto('/school/401559-adamsdown-primary-school');
expect(welsh?.status(), 'a Welsh school should no longer resolve').toBe(404);
});
test('the sitemap submits no Welsh or overseas school', async ({ page }) => {
const res = await page.request.get('/sitemap.xml');
expect(res.ok()).toBeTruthy();
const xml = await res.text();
const urlCount = (xml.match(/<url>/g) ?? []).length;
expect(urlCount, 'sitemap looks empty or truncated').toBeGreaterThan(1000);
// 401559 (Cardiff) and 402426 (ACT Schools, Cardiff) were both submitted
// before the England-only filter landed.
expect(xml).not.toContain('/school/401559');
expect(xml).not.toContain('/school/402426');
});
/*
* Staging must not be indexable (spec 2026-08-20, W1 hygiene).
*
* These journeys only ever run against staging — deploy.yml passes
* STAGING_BASE_URL, and promote.yml only smoke-polls production without
* Playwright — so asserting the noindex header here is safe.
*/
test('staging answers noindex, and stays crawlable so the noindex is seen', async ({ page }) => {
const res = await page.request.get('/');
expect(res.ok()).toBeTruthy();
const tag = res.headers()['x-robots-tag'];
expect(tag, 'staging must send X-Robots-Tag').toBeTruthy();
expect(tag).toContain('noindex');
// The other half, and the reason this is one test rather than two: a
// Disallow would stop Google fetching the page at all, so it would never
// see the noindex above. The two only work together.
const robots = await (await page.request.get('/robots.txt')).text();
expect(robots).not.toMatch(/^\s*Disallow:\s*\/\s*$/mi);
});
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
const list = await page.request.get('/api/schools?search=primary&per_page=1');
const [first] = (await list.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
const res = await page.request.get(`/school/${first.urn}-x`);
expect(res.headers()['x-robots-tag']).toContain('noindex');
});
@@ -3,17 +3,17 @@
*
* 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: no verdict without a published figure, and no verdict at all
* when the margin is inside the error of a postcode centroid.
* 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 { cutoffYearRows, CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
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.
// The panel's logic is independent of it, so the map is stubbed out.
jest.mock('@/components/LeafletCutoffMapInner', () => ({
__esModule: true,
default: () => <div data-testid="cutoff-map" />,
@@ -27,7 +27,7 @@ jest.mock('@/lib/api', () => ({
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
const d = (year: number, distance_m: number): SchoolAdmissionDistance => ({
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
});
@@ -36,11 +36,8 @@ function northOf(metres: number) {
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
}
function renderPanel(history: SchoolAdmissionDistance[], admissions: { year: number; oversubscribed?: boolean }[] = []) {
return render(
<CutoffMapPanel schoolInfo={SCHOOL} rows={cutoffYearRows(history, admissions)} />,
);
}
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 } });
@@ -50,8 +47,8 @@ async function check(postcode: string) {
beforeEach(() => mockGeocode.mockReset());
describe('CutoffMapPanel', () => {
it('renders nothing without a published figure to draw', () => {
const { container } = renderPanel([]);
it('renders nothing without a figure to compare against', () => {
const { container } = renderPanel(cutoff(null));
expect(container).toBeEmptyDOMElement();
});
@@ -59,61 +56,58 @@ describe('CutoffMapPanel', () => {
const { container } = render(
<CutoffMapPanel
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
rows={cutoffYearRows([d(2024, 800)])}
cutoff={cutoff(800)}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it('rejects a malformed postcode without calling the geocoder', async () => {
renderPanel([d(2024, 800)]);
renderPanel();
await check('not a postcode');
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
expect(mockGeocode).not.toHaveBeenCalled();
});
it('reports a home clearly inside every published year', async () => {
it('names the year in the verdict, so the figure is never free-floating', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2022, 1000), d(2024, 800)]);
renderPanel(cutoff(800, 2026));
await check('SE23 3NA');
expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/);
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/inside the/);
expect(result).toHaveTextContent(/September 2026/);
});
it('reports a home clearly outside every published year', async () => {
it('reports a home clearly beyond the cut-off', async () => {
mockGeocode.mockResolvedValue(northOf(5000));
renderPanel([d(2022, 1000), d(2024, 800)]);
renderPanel(cutoff(800));
await check('SE23 3NA');
expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/);
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
});
it('declines to call a result that sits inside the measurement error', async () => {
// The home is nominally inside 2024's 800 m cut-off, but only by half the
// uncertainty band — which a postcode centroid cannot resolve.
// 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([d(2024, 800)]);
renderPanel(cutoff(800));
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/too close to call/);
expect(result).not.toHaveTextContent(/inside the cut-off in all/);
});
it('counts an unpublished year as unknown rather than as a pass', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2022, 1000), d(2024, 800)], [{ year: 2023, oversubscribed: false }]);
await check('SE23 3NA');
const result = await screen.findByRole('status');
expect(result).toHaveTextContent(/inside the cut-off in all 2 years/);
expect(result).toHaveTextContent(/no published figure/);
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([d(2024, 800)]);
renderPanel();
await check('ZZ99 9ZZ');
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
@@ -121,7 +115,7 @@ describe('CutoffMapPanel', () => {
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
mockGeocode.mockResolvedValue(northOf(200));
renderPanel([d(2024, 800)]);
renderPanel();
await check('SE23 3NA');
await screen.findByRole('status');
@@ -132,13 +126,27 @@ describe('CutoffMapPanel', () => {
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
});
it('always states the limits of the check', async () => {
renderPanel([d(2024, 800)]);
// Present before any postcode is entered, not revealed with the answer.
expect(screen.getByText(/An indication only/)).toBeInTheDocument();
// The two caveats divide the work: the map says the rings are not a
// boundary, the check says the measurement may not be a straight line.
expect(screen.getByText(/not a catchment boundary/)).toBeInTheDocument();
expect(screen.getByText(/walking route rather than a straight line/)).toBeInTheDocument();
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/);
});
});
@@ -30,6 +30,17 @@ describe('primary detail page', () => {
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() });
@@ -89,95 +100,68 @@ describe('secondary detail page', () => {
});
});
// ── The Distance view ──────────────────────────────────────────────────
// ── The Distance section ───────────────────────────────────────────────
const history = (pts: [number, number][]): SchoolAdmissionDistance[] =>
pts.map(([year, distance_m]) => ({
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
}));
describe('primary Distance view', () => {
it('appears once there are two or more published years', () => {
renderSchoolDetail({
describe('Distance section', () => {
it('appears for a school with a figure and coordinates', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]),
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
expect(screen.getByRole('button', { name: 'Distance' })).toBeInTheDocument();
expect(container.querySelector('#distance')).toBeInTheDocument();
expect(screen.getByText('How far away are you?')).toBeInTheDocument();
});
it('stays hidden on a single published year', () => {
// One point is a fact, not a history; a chart of it invites a trend reading
// that is not there.
renderSchoolDetail({
it('stays away when the school has no coordinates to measure from', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2024, 700]]),
schoolInfo: { ...primaryFixture.schoolInfo, latitude: null, longitude: null },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument();
expect(container.querySelector('#distance')).not.toBeInTheDocument();
});
it('lists every year in the span, including the ones with no figure', () => {
renderSchoolDetail({
it('stays away when no figure has been published', () => {
const { container } = renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: null,
});
// The view is server-rendered but starts hidden behind its tab, and role
// queries skip hidden subtrees — so open it the way a reader would.
fireEvent.click(screen.getByRole('button', { name: 'Distance' }));
// 2022 and 2023 were never published but must still appear as rows, or the
// gap in the chart has nothing explaining it.
for (const year of ['2021', '2022', '2023', '2024']) {
expect(screen.getByRole('rowheader', { name: year })).toBeInTheDocument();
}
expect(screen.getAllByText('Not published').length).toBe(2);
expect(container.querySelector('#distance')).not.toBeInTheDocument();
});
it('never claims everyone was offered a place', () => {
// fact_admissions.oversubscribed is about first preferences only, so the
// stronger claim is not supported by the data behind it.
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,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
admissionsHistory: [
...primaryFixture.admissionsHistory,
{ year: 2022, oversubscribed: false, places_offered: 60 },
],
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
});
expect(screen.getByText(/Places available on first preferences/)).toBeInTheDocument();
expect(screen.queryByText(/every applicant/i)).not.toBeInTheDocument();
expect(screen.queryByText(/all offered/i)).not.toBeInTheDocument();
});
it('withholds a trend reading while the record is thin', () => {
renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2023, 800], [2024, 700]]),
});
expect(screen.getByText(/too few to read as a trend/)).toBeInTheDocument();
expect(screen.queryByText(/Across \d+ published years/)).not.toBeInTheDocument();
// 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 detail', () => {
it('renders inline, with no extra tab, once there are two published years', () => {
renderSecondarySchoolDetail({
describe('secondary Distance section', () => {
it('renders on the secondary template too', () => {
const { container } = renderSecondarySchoolDetail({
...secondaryFixture,
admissionDistance: cutoff({ distance_m: 3472.96 }),
admissionDistanceHistory: history([[2023, 3800], [2024, 3472.96]]),
schoolInfo: { ...secondaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
admissionDistance: cutoff({ distance_m: 3472.96, year: 2026 }),
});
expect(screen.getByText(/Last distance offered, by year/)).toBeInTheDocument();
expect(screen.getByRole('rowheader', { name: '2023' })).toBeInTheDocument();
expect(container.querySelector('#distance')).toBeInTheDocument();
});
it('explains a selective school by how it admits rather than as missing data', () => {
@@ -185,7 +169,6 @@ describe('secondary Distance detail', () => {
...secondaryFixture,
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
admissionDistance: null,
admissionDistanceHistory: [],
});
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
@@ -196,7 +179,6 @@ describe('secondary Distance detail', () => {
renderSecondarySchoolDetail({
...secondaryFixture,
admissionDistance: null,
admissionDistanceHistory: [],
admissionsHistory: [
{ year: 2022, oversubscribed: false },
{ year: 2023, oversubscribed: false },
@@ -7,10 +7,9 @@
* place — so both are pinned here rather than left to the component.
*/
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
import {
describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote,
describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M,
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
} from '@/components/school/lastDistanceOffered';
import type { SchoolAdmissionDistance } from '@/lib/types';
@@ -33,8 +32,19 @@ describe('formatCutoffDistance', () => {
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
});
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
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', () => {
@@ -82,97 +92,72 @@ describe('describeCutoff', () => {
});
});
// ── Year-by-year history ───────────────────────────────────────────────
// ── "Would we have got in?" ────────────────────────────────────────────
const d = (year: number, distance_m: number | null, route_count = 1): SchoolAdmissionDistance => ({
year, distance_m, route_count, la_name: 'Camden', distance_unit_raw: 'miles',
});
describe('entryYearOf', () => {
it('reduces EES academic codes and plain entry years to one key', () => {
// Without this the two histories never join and every year looks unpublished.
expect(entryYearOf(202425)).toBe(2024);
expect(entryYearOf(2024)).toBe(2024);
});
});
describe('cutoffYearRows', () => {
it('emits a row for every year in the span, including the empty ones', () => {
const rows = cutoffYearRows([d(2021, 900), d(2024, 700)]);
expect(rows.map((r) => r.year)).toEqual([2024, 2023, 2022, 2021]);
expect(rows.map((r) => r.status)).toEqual([
'published', 'not-published', 'not-published', 'published',
]);
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('separates "nothing published" from "was not oversubscribed"', () => {
// The two look identical in the distance data and mean opposite things.
const rows = cutoffYearRows(
[d(2021, 900), d(2023, 700)],
[{ year: 2022, oversubscribed: false }],
);
expect(rows.find((r) => r.year === 2022)!.status).toBe('not-oversubscribed');
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('matches EES six-digit years against plain cut-off years', () => {
const rows = cutoffYearRows(
[d(2023, 700)],
[{ year: 202324, places_offered: 60, oversubscribed: true }],
);
expect(rows.find((r) => r.year === 2023)!.placesOffered).toBe(60);
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('does not stretch the span back over admissions years with no cut-offs', () => {
// EES reaches back further than councils publish; padding the chart with a
// decade of blanks would bury the years that carry a figure.
const rows = cutoffYearRows(
[d(2024, 700)],
[{ year: 2015, oversubscribed: true }, { year: 2024, oversubscribed: true }],
);
expect(rows.map((r) => r.year)).toEqual([2024]);
it('calls a clearly further home beyond', () => {
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
});
it('is safe when the backend does not send the field at all', () => {
expect(cutoffYearRows(undefined as never)).toEqual([]);
expect(cutoffYearRows([], [])).toEqual([]);
});
});
describe('cutoffTrendSummary', () => {
const rowsFor = (pts: [number, number][]) => cutoffYearRows(pts.map(([y, m]) => d(y, m)));
it('says nothing below four published points', () => {
expect(cutoffTrendSummary(rowsFor([[2022, 900], [2023, 800], [2024, 700]]))).toBeNull();
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('names both endpoints and their years rather than passing a verdict', () => {
const s = cutoffTrendSummary(rowsFor([
[2021, 1000], [2022, 900], [2023, 800], [2024, 500],
]));
expect(s).toContain('2021');
expect(s).toContain('2024');
expect(s).toContain('tightened');
});
it('does not call a small wobble a direction', () => {
const s = cutoffTrendSummary(rowsFor([
[2021, 1000], [2022, 1010], [2023, 990], [2024, 1030],
]));
expect(s).toContain('stayed broadly the same');
});
});
describe('cutoffCoverageNote', () => {
it('warns while the record is too thin to read as a trend', () => {
expect(cutoffCoverageNote(cutoffYearRows([d(2024, 700)]))).toContain('Only 1 year');
expect(cutoffCoverageNote(cutoffYearRows([
d(2021, 1000), d(2022, 900), d(2023, 800), d(2024, 700),
]))).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 by missing data', () => {
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');
@@ -195,37 +180,3 @@ describe('describeCutoffAbsence', () => {
.toContain('Camden has not published');
});
});
describe('compareToCutoffs', () => {
const rows = cutoffYearRows(
[d(2022, 1000), d(2024, 800)],
[{ year: 2023, oversubscribed: false }],
);
it('marks a clearly nearer home as inside every comparable year', () => {
const r = compareToCutoffs(300, rows);
expect(r.insideCount).toBe(2);
expect(r.comparableCount).toBe(2);
expect(r.headline).toContain('inside the cut-off in all 2 years');
});
it('marks a clearly further home as outside', () => {
const r = compareToCutoffs(4000, rows);
expect(r.insideCount).toBe(0);
expect(r.headline).toContain('outside the cut-off in every year');
});
it('refuses to call a result inside the measurement error', () => {
// A postcode centroid covers several addresses; claiming a place on a 20 m
// margin would be inventing precision the inputs do not have.
const r = compareToCutoffs(800 - CUTOFF_UNCERTAINTY_M / 2, rows);
expect(r.years.find((y) => y.year === 2024)!.verdict).toBe('too-close');
expect(r.detail).toContain('too close to call');
});
it('never counts an unpublished year as a pass or a fail', () => {
const r = compareToCutoffs(300, rows);
expect(r.years.find((y) => y.year === 2023)!.verdict).toBe('unknown');
expect(r.detail).toContain('no published figure');
});
});
+2 -3
View File
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, admission_distance_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/', '');
@@ -177,6 +177,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
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);
@@ -231,7 +232,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
admissionDistanceHistory={admission_distance_history ?? []}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
@@ -254,7 +254,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
admissionDistanceHistory={admission_distance_history ?? []}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
-123
View File
@@ -1,123 +0,0 @@
'use client';
/**
* CutoffTrendChart
* The last distance offered, year by year.
*
* Two things this chart deliberately does not do.
*
* It does not join across gaps (`spanGaps: false`). A missing year is usually a
* year the authority published nothing, and a line drawn through it would
* assert a cut-off that was never measured.
*
* It does not plot the non-distance outcomes. A year in which the school was
* not oversubscribed has no distance — that is the point of it — and giving it
* a y-position would put a number on the axis that does not exist. The mockup
* floated such years at the top of the plot; here they are a gap in the line
* and a labelled row in the table underneath, which is where a reason can be
* stated in words rather than implied by a coordinate.
*/
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup';
import { useThemeTokens, alpha } from '@/lib/theme';
import type { CutoffYearRow } from './school/lastDistanceOffered';
import styles from './AdmissionsTrendChart.module.css';
const METRES_PER_MILE = 1609.344;
export default function CutoffTrendChart({ rows }: { rows: CutoffYearRow[] }) {
// rows arrive newest-first; a time axis reads oldest-first.
const axis = [...rows].reverse();
const published = axis.filter((r) => r.distanceM != null);
if (published.length < 2) return null;
const labels = axis.map((r) => String(r.year));
const values: (number | null)[] = axis.map((r) =>
r.distanceM != null ? r.distanceM / METRES_PER_MILE : null,
);
const present = values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0);
const lastIdx = present[present.length - 1];
const numeric = values.filter((v): v is number => v != null);
const lo = Math.min(...numeric);
const hi = Math.max(...numeric);
// Headroom proportional to the spread, with a floor so a flat series does not
// collapse onto a single gridline and read as more precise than it is.
const pad = Math.max(0.05, (hi - lo) * 0.25);
const yMin = Math.max(0, lo - pad);
const yMax = hi + pad;
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card',
);
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
layout: { padding: { top: 8 } },
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: 10,
titleFont: { size: 12 },
bodyFont: { size: 12 },
callbacks: {
label: (ctx) =>
ctx.parsed.y == null ? '' : `Last distance offered: ${ctx.parsed.y.toFixed(2)} miles`,
},
},
},
scales: {
y: {
min: yMin,
max: yMax,
grid: { color: cGrid },
ticks: {
font: { size: 11 },
maxTicksLimit: 5,
callback: (v) => `${Number(v).toFixed(2)} mi`,
},
},
x: {
grid: { display: false },
ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 },
},
},
};
const data = {
labels,
datasets: [
{
label: 'Last distance offered',
data: values,
clip: false as const,
spanGaps: false,
borderColor: cBrand,
backgroundColor: alpha('--brand', 0.10),
borderWidth: 2.5,
tension: 0.3,
fill: true,
pointRadius: values.map((v, i) => (v == null ? 0 : i === lastIdx ? 5 : 3)),
pointBackgroundColor: cBrand,
pointBorderColor: cCard,
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6,
},
],
};
return (
<div className={styles.wrapper}>
<Line data={data} options={options} />
</div>
);
}
@@ -9,28 +9,29 @@
* 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 { School, SchoolAdmissions, SchoolAdmissionDistance } 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, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import {
describeCutoff, cutoffYearRows,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function AdmissionsSection({
admissions,
admissionsHistory,
admissionDistance,
admissionDistanceHistory,
schoolInfo,
isAllThrough,
}: {
/* Nullable: the two data sources are independent. A minority of schools have
@@ -40,18 +41,9 @@ export function AdmissionsSection({
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School;
isAllThrough: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Every year in the span, including the ones with nothing in them — the
// classification of a missing year is what the Distance view is mostly for.
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
const publishedYears = cutoffRows.filter((r) => r.status === 'published').length;
// One published year is a fact, not a history: the tile already says it, and
// a chart of a single point invites a trend reading that is not there.
const showDistanceView = publishedYears >= 2;
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
@@ -78,10 +70,16 @@ export function AdmissionsSection({
{/* 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}
{cutoff.primary}{' '}
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
</dd>
<dt className={styles.admissionsTileLabel}>
@@ -182,10 +180,6 @@ export function AdmissionsSection({
</>
);
const distanceView = showDistanceView && (
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} />
);
const views: AdmissionsView[] = [
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
];
@@ -197,14 +191,6 @@ export function AdmissionsSection({
className: styles.admissionsViewTrend,
});
}
if (showDistanceView) {
views.push({
id: 'distance',
label: 'Distance',
content: distanceView,
className: styles.admissionsViewTrend,
});
}
return (
<Section id="admissions">
@@ -1,70 +0,0 @@
/**
* CutoffDistanceDetail — the full year-by-year cut-off story.
*
* Chart, table, map and postcode check, in that order: the shape of the trend,
* then what happened in each year, then the same numbers over real streets,
* then the reader's own address measured against them.
*
* Shared by both templates because the content is identical, but placed
* differently by each. The primary page has a segmented control and gives this
* its own "Distance" tab; the secondary page is one flat panel by design, so it
* renders inline beneath the metric cards. Extracting it keeps the caveats and
* the ordering from drifting apart between the two.
*
* Server component — the map and postcode form are the only client parts, and
* they carry their own boundary.
*/
import type { School } from '@/lib/types';
import { sectionStyles as styles } from './sectionShared';
import { CutoffTrendChart } from './charts';
import { CutoffYearTable } from './CutoffYearTable';
import { CutoffMapPanel } from './CutoffMapPanel';
import {
cutoffTrendSummary, cutoffCoverageNote,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
type CutoffYearRow,
} from './lastDistanceOffered';
export function CutoffDistanceDetail({
rows,
schoolInfo,
/** Repeat the "not a catchment" caveat here. On the primary page this view is
* behind a tab, so a reader can reach it without ever seeing the tile that
* carries the caveat; on the secondary page the caveat is already directly
* above, and repeating it verbatim reads as a stutter. */
showCaveat = true,
}: {
rows: CutoffYearRow[];
schoolInfo: School;
showCaveat?: boolean;
}) {
const publishedYears = rows.filter((r) => r.status === 'published').length;
if (publishedYears < 2) return null;
const trendSummary = cutoffTrendSummary(rows);
const coverageNote = cutoffCoverageNote(rows);
return (
<>
<div className={styles.admissionsChartCap}>Last distance offered, by year</div>
<CutoffTrendChart rows={rows} />
<p className={styles.cutoffChartKey}>
The line breaks where no figure was published — it is never drawn across
a gap. The table below says what happened in each of those years.
</p>
{trendSummary && <p className={styles.admissionsTrendSummary}>{trendSummary}</p>}
<CutoffYearTable rows={rows} />
{coverageNote && <p className={styles.cutoffChartKey}>{coverageNote}</p>}
<CutoffMapPanel schoolInfo={schoolInfo} rows={rows} />
{showCaveat && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
</p>
)}
</>
);
}
+95 -119
View File
@@ -1,28 +1,36 @@
'use client';
/**
* CutoffMapPanel — "Where the last place went".
* CutoffMapPanel — "How far away are you?"
*
* The rings and the postcode check live in one component because they are one
* question asked twice: the map answers "how far is that?" and the form answers
* "how far are we?". Entering a postcode drops a pin on the same rings rather
* than producing a separate verdict somewhere else on the page.
* 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, useMemo, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import dynamic from 'next/dynamic';
import type { School } from '@/lib/types';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils';
import {
compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT,
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
compareToCutoff, CUTOFF_CHECK_CAVEAT,
type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered';
import type { CutoffRing } from '../LeafletCutoffMapInner';
import styles from './schoolSections.module.css';
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
@@ -30,59 +38,35 @@ const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
});
const VERDICT_MARK: Record<CutoffVerdict, string> = {
inside: '✓',
outside: '✕',
'too-close': '≈',
unknown: '–',
};
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
inside: 'Inside that year’s cut-off',
outside: 'Outside that year’s cut-off',
'too-close': 'Too close to the cut-off to say',
unknown: 'No published figure for this year',
};
// 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" chip would look exactly like an "inside" one.
// unstyled "outside" result would look exactly like an "inside" one.
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
inside: styles.cutoffYearInside,
outside: styles.cutoffYearOutside,
'too-close': styles.cutoffYearTooClose,
unknown: styles.cutoffYearUnknown,
inside: styles.cutoffResultInside,
outside: styles.cutoffResultOutside,
'too-close': styles.cutoffResultTooClose,
};
export function CutoffMapPanel({
schoolInfo,
rows,
cutoff,
}: {
schoolInfo: School;
rows: CutoffYearRow[];
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;
const rings: CutoffRing[] = useMemo(() => {
const published = rows.filter((r) => r.distanceM != null);
return published.map((r, i) => ({
year: r.year,
radiusM: r.distanceM as number,
// rows are newest-first, so the first published row is the current year.
latest: i === 0,
}));
}, [rows]);
// Without coordinates there is nothing to draw rings around and nothing to
// measure a postcode against.
if (lat == null || lng == null || rings.length === 0) return null;
// 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();
@@ -107,9 +91,10 @@ export function CutoffMapPanel({
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(compareToCutoffs(metres, rows));
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
} catch {
setError('Something went wrong looking up that postcode. Try again in a moment.');
setResult(null);
@@ -119,87 +104,78 @@ export function CutoffMapPanel({
}
};
const latestRing = rings[0];
return (
<div className={styles.cutoffMapPanel}>
<h3 className={styles.cutoffMapTitle}>Where the last place went</h3>
<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>
<div className={styles.cutoffMapFigure}>
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
</div>
<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>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
{latestRing.year} cut-off
</li>
{rings.length > 1 && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
</li>
)}
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
<p className={styles.cutoffMapCaveat}>{CUTOFF_MAP_CAVEAT}</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>
)}
<div className={styles.cutoffCheck}>
<h4 className={styles.cutoffCheckTitle}>How far away are you?</h4>
<p className={styles.cutoffCheckSub}>
We measure a straight line from your postcode to the school and compare
it with each year’s cut-off. Your postcode is 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} role="status">
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
<ul className={styles.cutoffYearStrip}>
{[...result.years].reverse().map((y) => (
<li
key={y.year}
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
title={VERDICT_TITLE[y.verdict]}
>
<span className={styles.cutoffYearChipYear}>{y.year}</span>
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
</li>
))}
</ul>
{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>
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
</div>
);
}
@@ -1,82 +0,0 @@
/**
* CutoffYearTable — the last distance offered, year by year.
*
* The table is where the chart's gaps get explained. Each year says which of
* the three things happened, in words, because "no figure" alone is the least
* useful thing we could tell a parent about a year.
*
* Server component: it is static markup over data the page already holds.
*/
import type { CutoffYearRow, CutoffYearStatus } from './lastDistanceOffered';
import { sectionStyles as styles } from './sectionShared';
const STATUS_LABEL: Record<CutoffYearStatus, string> = {
published: 'Oversubscribed',
// Precisely what the EES field supports, and no more: first preferences did
// not exceed places. It does not establish that every applicant was offered
// a place, so it must not be worded as though it did.
'not-oversubscribed': 'Places available on first preferences',
'not-published': 'Not published',
};
const STATUS_CLASS: Record<CutoffYearStatus, string> = {
published: styles.cutoffPillOver,
'not-oversubscribed': styles.cutoffPillOk,
'not-published': styles.cutoffPillNa,
};
const DISTANCE_TEXT: Record<CutoffYearStatus, string> = {
published: '',
'not-oversubscribed': 'No cut-off needed',
'not-published': '—',
};
export function CutoffYearTable({ rows }: { rows: CutoffYearRow[] }) {
if (rows.length === 0) return null;
const showPlaces = rows.some((r) => r.placesOffered != null);
return (
<div className={styles.cutoffTableWrap}>
<table className={styles.cutoffTable}>
<caption className={styles.srOnly}>
Last distance offered by year, with the reason where no figure was published
</caption>
<thead>
<tr>
<th scope="col">Year</th>
<th scope="col">Last distance</th>
{showPlaces && <th scope="col">Places</th>}
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.year} className={i === 0 ? styles.cutoffRowLatest : undefined}>
<th scope="row">{r.year}</th>
<td className={r.status === 'published' ? undefined : styles.cutoffCellMissing}>
{r.status === 'published' ? r.figure : DISTANCE_TEXT[r.status]}
{r.status === 'published' && (r.routeCount ?? 1) > 1 && (
<span className={styles.cutoffRouteFlag}>
{' '}furthest of {r.routeCount}
</span>
)}
</td>
{showPlaces && (
<td className={r.placesOffered == null ? styles.cutoffCellMissing : undefined}>
{r.placesOffered ?? '—'}
</td>
)}
<td>
<span className={`${styles.cutoffPill} ${STATUS_CLASS[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</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>
);
}
@@ -20,6 +20,7 @@ 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';
@@ -35,7 +36,6 @@ export interface PrimarySchoolSectionsProps {
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -44,7 +44,7 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
@@ -103,12 +103,14 @@ export function PrimarySchoolSections({
admissions={admissions}
admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
schoolInfo={schoolInfo}
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}
@@ -9,27 +9,23 @@
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import {
describeCutoff, cutoffYearRows, describeCutoffAbsence,
describeCutoff, describeCutoffAbsence,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
schoolInfo, hasSixthForm,
admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
}: {
/* 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;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School;
hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -83,7 +79,7 @@ export function SecondaryAdmissionsSection({
Last distance offered · {cutoff.entryYear}
</div>
<div className={styles.metricValue}>
{cutoff.primary}
{cutoff.primary}{' '}
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
</div>
</div>
@@ -116,11 +112,6 @@ export function SecondaryAdmissionsSection({
</p>
)}
{/* Inline rather than behind a tab: this template is one flat panel by
design and has no segmented control to hang a third view on. The
caveat is suppressed because it is already directly above. */}
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} showCaveat={false} />
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
@@ -20,6 +20,7 @@ import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { GcseSection } from './GcseSection';
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection';
@@ -36,7 +37,6 @@ export interface SecondarySchoolSectionsProps {
* school simply was not oversubscribed. */
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -45,7 +45,7 @@ export interface SecondarySchoolSectionsProps {
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -91,13 +91,15 @@ export function SecondarySchoolSections({
<SecondaryAdmissionsSection
admissions={admissions}
admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm}
/>
)}
{/* See PrimarySchoolSections — same placement, same reason. */}
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
{yearlyData.length > 1 && (
<SecondaryHistorySection
yearlyData={yearlyData}
-5
View File
@@ -24,8 +24,3 @@ export const AdmissionsTrendChart = dynamic(
() => import('../AdmissionsTrendChart'),
{ ssr: false },
);
export const CutoffTrendChart = dynamic(
() => import('../CutoffTrendChart'),
{ ssr: false },
);
@@ -16,7 +16,7 @@
*/
import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */
@@ -60,270 +60,119 @@ export function describeCutoff(
};
}
// ---------------------------------------------------------------------------
// Year-by-year history
// ---------------------------------------------------------------------------
/**
* Why a year has no cut-off figure.
*
* The distinction matters more than the figure does. A missing year is not one
* fact but three, and collapsing them into "no data" throws away the most
* reassuring case (the school had places for everyone who put it first) and
* the most important caveat (nobody published anything, so we cannot say).
*/
export type CutoffYearStatus =
/** The authority published a cut-off for this year. */
| 'published'
/**
* No cut-off published, and EES shows fewer first-preference applications
* than places offered. Note the precise claim: this says the school was not
* oversubscribed ON FIRST PREFERENCES, which is what the data supports. It
* does NOT establish that every applicant was offered a place — total
* applications can still exceed places — so no copy here may say so.
*/
| 'not-oversubscribed'
/** Nothing published, and nothing in the admissions data to explain why. */
| 'not-published';
export interface CutoffYearRow {
year: number;
status: CutoffYearStatus;
/** Straight-line metres, present only when status is 'published'. */
distanceM: number | null;
/** Formatted figure, present only when status is 'published'. */
figure: string | null;
placesOffered: number | null;
routeCount: number | null;
}
interface AdmissionsYearLike {
year: number;
places_offered?: number | null;
oversubscribed?: boolean | null;
}
/**
* EES years are six-digit academic codes (202425); cut-off years are plain
* entry years (2024). Both denote the same September intake, so they have to be
* reduced to a common key before they can be matched.
*/
export function entryYearOf(year: number): number {
const s = String(year);
return s.length === 6 ? Number(s.slice(0, 4)) : year;
}
/**
* One row per year across the full span the school has any record for, newest
* first. Years with no record at all still appear — an unbroken axis is what
* makes a gap legible as a gap.
*/
export function cutoffYearRows(
/* Defaulted, not merely optional: admission_distance_history is an additive
API field, so a frontend deployed ahead of its backend receives undefined
here. Defaulting keeps that a section that renders nothing rather than a
page that throws. */
history: SchoolAdmissionDistance[] = [],
admissionsHistory: AdmissionsYearLike[] = [],
): CutoffYearRow[] {
const published = new Map<number, SchoolAdmissionDistance>();
for (const d of history ?? []) {
if (d.distance_m != null) published.set(entryYearOf(d.year), d);
}
const admissions = new Map<number, AdmissionsYearLike>();
for (const a of admissionsHistory ?? []) admissions.set(entryYearOf(a.year), a);
const years = [...published.keys(), ...admissions.keys()];
if (years.length === 0) return [];
// The span is bounded by the cut-off data, not by admissions: EES reaches
// back further than councils publish, and padding the chart with a decade of
// "not published" would bury the years that carry a figure.
const publishedYears = [...published.keys()];
const from = publishedYears.length ? Math.min(...publishedYears) : Math.min(...years);
const to = Math.max(...years);
const rows: CutoffYearRow[] = [];
for (let y = to; y >= from; y -= 1) {
const d = published.get(y);
const a = admissions.get(y);
let status: CutoffYearStatus = 'not-published';
if (d) status = 'published';
else if (a?.oversubscribed === false) status = 'not-oversubscribed';
rows.push({
year: y,
status,
distanceM: d?.distance_m ?? null,
figure: d ? formatCutoffDistance(d.distance_m)?.primary ?? null : null,
placesOffered: a?.places_offered ?? null,
routeCount: d?.route_count ?? null,
});
}
return rows;
}
/**
* A factual summary of how the cut-off has moved.
*
* Deliberately not a verdict. It names both endpoints and their years and lets
* the reader draw the conclusion, because the series is short, gappy, and
* driven by things outside the school's control — one large sibling cohort
* moves it. Withheld below four published points, where a swing between two
* years is noise wearing the clothes of a trend.
*/
export function cutoffTrendSummary(rows: CutoffYearRow[]): string | null {
const pts = rows.filter((r) => r.status === 'published' && r.distanceM != null);
if (pts.length < 4) return null;
// rows are newest-first
const latest = pts[0];
const earliest = pts[pts.length - 1];
const a = formatCutoffDistance(earliest.distanceM)?.primary;
const b = formatCutoffDistance(latest.distanceM)?.primary;
if (!a || !b) return null;
const change = latest.distanceM! - earliest.distanceM!;
// A tenth of the earlier figure — below that the endpoints are effectively
// the same and calling it a direction would be reading noise.
const meaningful = Math.abs(change) > earliest.distanceM! * 0.1;
const direction = !meaningful
? 'has stayed broadly the same'
: change < 0
? 'has tightened'
: 'has widened';
return `Across ${pts.length} published years the cut-off ${direction}: `
+ `${a} in ${earliest.year}, ${b} in ${latest.year}.`;
}
// ---------------------------------------------------------------------------
// "Would we have got in?"
// ---------------------------------------------------------------------------
/**
* How close a home has to be to a year's cut-off before the comparison stops
* How close a home has to be to the cut-off before the comparison stops
* meaning anything, in metres.
*
* A UK postcode unit covers roughly fifteen addresses and postcodes.io returns
* its centroid, so the home point carries error of this order before anything
* else is considered. Against a cut-off that is often only 500 m, that is a
* fifth of the whole distance. Inside this band the honest answer is that we
* cannot tell, and saying "you would have been offered a place" would be
* inventing precision the inputs do not have.
* postcodes.io returns the centroid of a postcode unit covering roughly fifteen
* addresses, so the home point carries error of this order before anything else
* is considered. Against a cut-off that is often only 500 m that is a fifth of
* the whole distance. Inside this band the honest answer is that we cannot
* tell, and saying "you would have been offered a place" would be inventing
* precision the inputs do not have.
*/
export const CUTOFF_UNCERTAINTY_M = 100;
export type CutoffVerdict = 'inside' | 'outside' | 'too-close' | 'unknown';
export interface CutoffYearComparison {
year: number;
status: CutoffYearStatus;
verdict: CutoffVerdict;
}
export type CutoffVerdict = 'inside' | 'outside' | 'too-close';
export interface CutoffCheckResult {
/** Straight-line metres from the given postcode to the school. */
distanceM: number;
distanceLabel: string;
years: CutoffYearComparison[];
/** Years the home is clearly inside, out of those with a published figure. */
insideCount: number;
comparableCount: number;
verdict: CutoffVerdict;
headline: string;
detail: string;
}
export function compareToCutoffs(
distanceM: number,
rows: CutoffYearRow[],
): CutoffCheckResult {
const years: CutoffYearComparison[] = rows.map((r) => {
if (r.status !== 'published' || r.distanceM == null) {
return { year: r.year, status: r.status, verdict: 'unknown' as const };
}
const margin = r.distanceM - distanceM;
const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
return { year: r.year, status: r.status, verdict };
});
const comparable = years.filter((y) => y.verdict !== 'unknown');
const inside = comparable.filter((y) => y.verdict === 'inside');
const tooClose = comparable.filter((y) => y.verdict === 'too-close');
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
let headline: string;
if (comparable.length === 0) {
headline = `${label} from the school`;
} else if (inside.length === comparable.length) {
headline = `${label} away — inside the cut-off in all ${comparable.length} `
+ `${comparable.length === 1 ? 'year' : 'years'} with a published figure`;
} else if (inside.length === 0 && tooClose.length === 0) {
headline = `${label} away — outside the cut-off in every year with a published figure`;
} else {
headline = `${label} away — inside the cut-off in ${inside.length} of `
+ `${comparable.length} years with a published figure`;
}
const parts: string[] = [];
if (tooClose.length > 0) {
parts.push(
`${tooClose.length} ${tooClose.length === 1 ? 'year is' : 'years are'} too close to call: `
+ 'your postcode is a centroid covering several addresses, so a margin under '
+ `${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`,
);
}
const unknown = years.length - comparable.length;
if (unknown > 0) {
parts.push(`${unknown} ${unknown === 1 ? 'year has' : 'years have'} no published figure to compare against.`);
}
return {
distanceM,
distanceLabel: label,
years,
insideCount: inside.length,
comparableCount: comparable.length,
headline,
detail: parts.join(' '),
};
/** Why we cannot call it, on the one verdict that needs explaining. Kept
* apart from the headline so it does not run at headline weight. */
detail: string | null;
}
/**
* The limits of the check, stated where a parent will act on it.
* Compare a home against the one cut-off we publish.
*
* Every clause is load-bearing. Distance is the last criterion applied, not the
* first; the authority's own measurement may be a walking route rather than a
* straight line, which is always longer for the same home; and a past cut-off
* constrains next year's not at all.
* Only the latest year is compared because only the latest year is served:
* earlier years are held back as a paid feature and no longer leave the API.
* A single year makes the verdict sharper to state and easier to qualify — one
* distance, one year, one margin — but it also removes the reassurance a run of
* years gave, so the year is named in the headline rather than left implied.
*/
export function compareToCutoff(
distanceM: number,
cutoffM: number,
year: number,
): CutoffCheckResult {
const margin = cutoffM - distanceM;
const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
// Both sides through the same formatter, with no fallback that could reach
// for a different unit: the sentence compares these two numbers directly, so
// they have to be in the same one. formatCutoffDistance returns null for a
// zero or negative figure, and its old "N m" fallback here was one of the
// ways a metres reading used to appear next to a miles one.
const label = formatMiles(distanceM);
const cutoffLabel = formatMiles(cutoffM);
// "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles
// cut-off for September 2026": the figure carries its own unit word, which
// reads wrong used attributively.
const cutoffPhrase = `the September ${year} cut-off of ${cutoffLabel}`;
const headline =
verdict === 'inside'
? `${label} away — inside ${cutoffPhrase}.`
: verdict === 'outside'
? `${label} away — beyond ${cutoffPhrase}.`
: `${label} away — too close to ${cutoffPhrase} to call.`;
const detail =
verdict === 'too-close'
? 'Your postcode is a centroid covering several addresses, so a margin '
+ `under ${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`
: null;
return { distanceM, distanceLabel: label, verdict, headline, detail };
}
/**
* The limits of the figure, and of the check made against it.
*
* One caveat, rendered once at the end of the section. It was previously three
* paragraphs — under the map, under the check, and a trailing "not a catchment"
* line — which took ~180px between them, said walking-route twice, and made
* the same point about priorities in two voices.
*
* Phrased to stand up whether or not the postcode check is on the page: it
* opens on the figure rather than on "your result", because a school with no
* coordinates renders the table with no check beneath it.
*
* Every claim is still here:
* * distance is the last criterion applied, not the first;
* * the figures and rings are straight-line, and not a boundary;
* * some authorities measure a walking route, always longer for the same home;
* * a past cut-off constrains next year's not at all.
*/
export const CUTOFF_CHECK_CAVEAT =
'An indication only. Places go first to children in care, EHCP places, '
+ 'siblings and — at faith schools — on faith criteria, so a home inside the '
+ 'distance can still miss out. Some authorities measure a walking route '
+ 'rather than a straight line, which is always longer for the same home, and '
+ "next year's cut-off depends on next year's applicants. Always check the "
+ "school's own admissions policy.";
/**
* The rings are a drawing of a number, not a boundary anyone administers.
*
* The walking-route caveat belongs to CUTOFF_CHECK_CAVEAT immediately below
* this on the page, which states it more usefully ("always longer for the same
* home"). Saying it in both places read as a stutter.
*/
export const CUTOFF_MAP_CAVEAT =
'Each ring is the straight-line cut-off for that year, drawn around the '
+ 'school. It illustrates the distance — it is not a catchment boundary.';
'Distance is the last criterion applied. Places go first to children in care, '
+ 'EHCP places, siblings and — at faith schools — on faith criteria, so a home '
+ 'inside the distance can still miss out. Figures are straight-line distances '
+ 'and not a catchment boundary; some authorities measure a walking route '
+ "instead, which is always longer for the same home. Next year's cut-off "
+ "depends on next year's applicants — always check the school's own "
+ 'admissions policy.';
// ---------------------------------------------------------------------------
// When there is no figure
// ---------------------------------------------------------------------------
/** The slice of an admissions year this file needs. */
interface AdmissionsYearLike {
year: number;
oversubscribed?: boolean | null;
}
interface AbsenceInput {
localAuthority?: string | null;
admissionsPolicy?: string | null;
@@ -361,17 +210,3 @@ export function describeCutoffAbsence({
: 'No cut-off distance has been published for this school.')
+ ' Contact the admissions authority for its oversubscription criteria.';
}
/**
* A short note on how thin the record is.
*
* Four points is the same threshold the trend summary uses: below it the series
* is too short to carry a direction, and saying so is more useful than leaving
* the reader to count the rows.
*/
export function cutoffCoverageNote(rows: CutoffYearRow[]): string | null {
const published = rows.filter((r) => r.status === 'published').length;
if (published === 0 || published >= 4) return null;
return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, `
+ 'which is too few to read as a trend.';
}
@@ -1627,8 +1627,23 @@
color: var(--text-secondary);
}
/* Stack both views in one grid cell so the card sizes to the taller view —
toggling modes never shifts layout. */
/*
* Stack both views in one grid cell so the card sizes to the taller view —
* toggling modes never shifts layout.
*
* This only works while the views are of COMPARABLE height, and it fails
* silently rather than loudly when they are not. The hidden view keeps its
* box (display stays block below; only visibility is dropped), so the grid
* row is sized by the tallest view and every other view is padded out to
* match it. A cut-off-distance view carrying a chart, a table and a map was
* added here and came to 1402px against the tile grid's 316px, which left
* the DEFAULT view — the one nobody had clicked — as four tiles floating in
* ~1080px of blank card.
*
* Anything substantially taller than the tile grid belongs in its own
* section, not in this viewport. That is where the distance detail now
* lives; see DistanceSection.
*/
.admissionsViewport {
display: grid;
}
@@ -2084,105 +2099,6 @@
border: 0;
}
.cutoffChartKey {
margin: 0.6rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
/* ── Year-by-year table ── */
.cutoffTableWrap {
margin-top: 1.25rem;
overflow-x: auto;
}
.cutoffTable {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
/* Below this the four columns crush; the wrapper scrolls instead of the
page, so the section itself never scrolls sideways. */
min-width: 26rem;
font-variant-numeric: tabular-nums;
}
.cutoffTable thead th {
text-align: right;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
padding: 0 0.625rem 0.55rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cutoffTable thead th:first-child,
.cutoffTable tbody th {
text-align: left;
}
.cutoffTable tbody td,
.cutoffTable tbody th {
text-align: right;
padding: 0.65rem 0.625rem;
border-bottom: 1px solid var(--border);
font-weight: 400;
color: var(--text-primary);
}
.cutoffTable tbody th {
font-weight: 600;
}
.cutoffTable tbody tr:last-child td,
.cutoffTable tbody tr:last-child th {
border-bottom: none;
}
.cutoffRowLatest td,
.cutoffRowLatest th {
background: var(--bg-secondary);
}
.cutoffCellMissing {
color: var(--text-secondary);
}
.cutoffRouteFlag {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
}
.cutoffPill {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.cutoffPillOver {
background: var(--brand-bg);
color: var(--brand-strong);
}
.cutoffPillOk {
background: var(--status-above-bg);
color: var(--status-above);
}
.cutoffPillNa {
background: var(--bg-secondary);
color: var(--text-secondary);
}
/* ── Map panel ── */
.cutoffMapPanel {
@@ -2191,17 +2107,11 @@
border-top: 1px solid var(--border);
}
.cutoffMapTitle {
margin: 0 0 0.85rem;
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.cutoffMapFigure {
height: 320px;
/* Enough to read a set of concentric rings and no more — this is a
diagram of a number, not a map anyone navigates by. */
height: 240px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border);
@@ -2243,12 +2153,6 @@
box-shadow: inset 0 0 0 2px var(--brand);
}
.cutoffSwatchPast {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--brand);
opacity: 0.5;
}
.cutoffSwatchHome {
width: 11px;
height: 11px;
@@ -2267,16 +2171,18 @@
/* ── Postcode check ── */
/* The check now leads the panel, so it carries no divider of its own — the
rule above it belonged to a map that used to sit there. */
.cutoffCheck {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
margin-top: 0;
}
.cutoffCheckTitle {
margin: 0 0 0.25rem;
font-family: var(--font-display);
font-size: 1rem;
/* Now the panel's only heading (an h3), so it carries the weight the
retired .cutoffMapTitle used to. */
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
}
@@ -2383,54 +2289,6 @@
color: var(--text-secondary);
}
.cutoffYearStrip {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.cutoffYearChip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cutoffYearChipYear {
font-weight: 700;
}
.cutoffYearInside {
background: var(--status-above-bg);
color: var(--status-above);
}
.cutoffYearOutside {
background: var(--status-below-bg);
color: var(--status-below);
}
/* Deliberately neutral rather than a warning colour: "we cannot tell" is not a
bad outcome, and tinting it amber would read as one. */
.cutoffYearTooClose {
background: var(--bg-secondary);
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--border);
}
.cutoffYearUnknown {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.cutoffCheckCaveat {
margin: 0.9rem 0 0;
font-size: 0.8125rem;
@@ -2441,6 +2299,61 @@
@media (max-width: 640px) {
.cutoffMapFigure {
height: 260px;
height: 200px;
}
}
/* ── Map, revealed on request ── */
.cutoffMapToggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 1rem;
padding: 0.5rem 0.9rem;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
color: var(--brand-strong);
background: none;
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.cutoffMapToggle:hover {
border-color: var(--brand);
background: var(--brand-bg);
}
.cutoffMapToggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cutoffMapReveal {
margin-top: 1rem;
}
/* ── Postcode check result, coloured by verdict ──
*
* These override the neutral brand tint on .cutoffCheckResult. Without them the
* three outcomes render identically, which is the specific failure the class
* map in CutoffMapPanel is written out longhand to avoid — and which the
* stylesheet test caught when they were referenced before being defined.
*/
.cutoffResultInside {
background-image: linear-gradient(var(--status-above-bg), var(--status-above-bg));
}
.cutoffResultOutside {
background-image: linear-gradient(var(--status-below-bg), var(--status-below-bg));
}
/* Deliberately neutral: "we cannot tell" is not a bad outcome, and tinting it
like one would push a reader toward a conclusion the data does not support. */
.cutoffResultTooClose {
background-image: none;
background-color: var(--bg-secondary);
}
+12 -2
View File
@@ -122,6 +122,10 @@ export interface NavItemsInput {
/** A published cut-off distance renders the Admissions section on its own,
* so it has to open the nav entry too. */
admissionDistance: SchoolAdmissionDistance | null;
/** Whether the school has coordinates — the Distance section needs them to
* measure a postcode, so the nav must gate on them too or it will link to an
* anchor that was never rendered. */
hasLocation?: boolean;
yearlyDataLength: number;
}
@@ -136,7 +140,7 @@ export interface NavItemsInput {
*/
export function buildNavItems(
flags: SchoolFlags,
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -147,6 +151,9 @@ export function buildNavItems(
});
}
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (admissionDistance?.distance_m != null && hasLocation) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
@@ -216,12 +223,15 @@ export function computeSecondaryFlags({
*/
export function buildSecondaryNavItems(
flags: SecondaryFlags,
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (admissionDistance?.distance_m != null && hasLocation) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
+6 -6
View File
@@ -354,14 +354,14 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
/** Latest published cut-off distance, or null where the LA publishes none. */
admission_distance: SchoolAdmissionDistance | null;
/**
* Every published cut-off year, oldest first. Sparse by nature — a school may
* have 2021 and 2026 and nothing between, so consumers must classify the
* missing years rather than joining the points they do have.
* Latest published cut-off distance, or null where the LA publishes none.
*
* Only the latest year is served. Earlier years exist in the mart but are
* held back as a paid feature and are not part of this public payload — see
* data_loader._admission_distance.
*/
admission_distance_history: SchoolAdmissionDistance[];
admission_distance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
+31 -17
View File
@@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
const METRES_PER_MILE = 1609.344;
/**
* Format an admission cut-off distance.
* A distance in miles, the unit UK school admissions is conducted in.
*
* Miles lead because that is the unit councils publish in — 90% of the source
* rows are miles — and because it is the unit a parent has already been quoted
* if they have read their council's booklet. The metric figure follows as
* support rather than replacing it.
* Councils publish cut-offs in miles — 90% of the collected source rows — and
* it is the unit a parent has already been quoted in their council's booklet
* and offer letter. Everything a reader is asked to compare therefore renders
* through this one function, so two figures in the same sentence can never
* arrive in different units.
*
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
* the two swap round and metres lead instead.
* That was not previously true. This formatter used to swap to metres below
* 100m, on the reasoning that "0.04 miles" carries less for a reader than
* "69 m". Taken one figure at a time that holds; taken in a sentence it
* produced "69 m away — inside the September 2026 cut-off of 0.17 miles",
* which asks the reader to convert between units to understand a comparison we
* had already made for them. Legibility of a single number lost to coherence
* of the pair.
*
* Below 0.01 miles the two decimal places run out rather than the unit being
* wrong, so the figure is described instead of rounded to a flat "0.00 miles".
*/
export function formatMiles(metres: number): string {
const miles = metres / METRES_PER_MILE;
if (miles < 0.01) return 'under 0.01 miles';
return `${miles.toFixed(2)} miles`;
}
/**
* Format an admission cut-off distance: the headline figure in miles, and a
* metric equivalent to support it.
*
* The metric figure is support, not an alternative — it appears beside the
* miles figure, never instead of it, so nothing a reader compares is ever in
* two units at once.
*/
export function formatCutoffDistance(
metres: number | null | undefined
): { primary: string; secondary: string } | null {
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
const miles = metres / METRES_PER_MILE;
if (metres < 100) {
return {
primary: `${Math.round(metres)} m`,
secondary: `${miles.toFixed(2)} miles`,
};
}
return {
primary: `${miles.toFixed(2)} miles`,
primary: formatMiles(metres),
secondary: metres < 1000
? `${Math.round(metres / 10) * 10} m`
: `${(metres / 1000).toFixed(1)} km`,
+31
View File
@@ -55,6 +55,37 @@ const nextConfig = {
// Headers for caching and security
async headers() {
return [
{
/*
* Keep non-production hosts out of the index.
*
* Staging serves the same image as production off stx., so without
* this it is a full crawlable duplicate of the site.
*
* 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 worse, blocking the crawl
* means Google never fetches the page and never sees a noindex at all.
* Staging therefore stays crawlable and answers "noindex" when crawled.
*
* Matched on the staging host explicitly rather than "any host that is
* not production". The inverted form is tempting because it would cover
* future environments automatically, but its failure mode is
* deindexing production if the Host header ever arrives rewritten by a
* proxy. This form's failure mode is a new environment being indexable
* until someone adds it here — recoverable, where the other is not.
*
* Any new non-production hostname must be added to this list.
*/
source: '/:path*',
has: [{ type: 'host', value: 'stx.schoolcompare.co.uk' }],
headers: [
{
key: 'X-Robots-Tag',
value: 'noindex, nofollow',
},
],
},
{
source: '/:path*',
headers: [
+26
View File
@@ -11,6 +11,32 @@ seed-paths: ["seeds"]
target-path: "target"
clean-targets: ["target", "dbt_packages"]
# schoolcompare publishes England only. GIAS ships the whole UK plus overseas
# and offshore establishments, none of which have comparable DfE performance
# data — Wales does not publish on the English measures at all. Excluding them
# at the mart boundary keeps them out of the site, the filter lists and the
# sitemap together. Codes are TypeOfEstablishment, see seeds/gias_code_names.csv.
# 25 = Offshore schools (Jersey, Guernsey, Isle of Man, Gibraltar)
# 26 = Service children's education (BFPO, Fieldwork Overseas)
# 30 = Welsh establishment
# 37 = British schools overseas
# dim_school, dim_location and assert_england_only_schools all read this list.
vars:
non_england_school_type_codes: [25, 26, 30, 37]
# Type alone is not enough. Beechwood College (URN 142458, Sully, CF64 5SE)
# is a Welsh school that GIAS types as a Special post 16 institution (32)
# rather than a Welsh establishment (30), so the type filter let it through.
# LA code is the reliable discriminator: GIAS gives the 22 Welsh unitary
# authorities the contiguous block 660-681, which English authorities never
# use (they occupy 201-384 and 800-938).
#
# Filtering on postcode would be 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.
welsh_la_code_min: 660
welsh_la_code_max: 681
models:
school_compare:
staging:
@@ -31,5 +31,11 @@ select
else null
end as longitude
from {{ ref('stg_gias_establishments') }} s
-- Must match dim_school's status filter exactly (the API inner-joins the two).
-- Must match dim_school's status and England filters exactly (the API
-- inner-joins the two).
where s.status_code in (1, 3)
-- coalesce, not a bare NOT IN: a null type code would make the predicate
-- null and drop the row silently. Unknown type is not grounds for exclusion.
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
@@ -91,3 +91,10 @@ left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
-- GIAS flips to Closed — marts fully rebuild each run).
where s.status_code in (1, 3)
-- England only. dim_location must apply this filter identically (the API
-- inner-joins the two). See vars in dbt_project.yml for what the codes are.
-- coalesce, not a bare NOT IN: a null type code would make the predicate
-- null and drop the row silently. Unknown type is not grounds for exclusion.
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
@@ -0,0 +1,54 @@
-- Custom test: the published corpus is England only.
--
-- GIAS ships the whole UK plus overseas and offshore establishments. None of
-- them carry comparable DfE performance data, so dim_school and dim_location
-- filter them out (see vars.non_england_school_type_codes in dbt_project.yml).
-- This test is the guard: a GIAS refresh that reintroduces them, or an edit
-- that drops the filter from one of the two models, fails the pipeline rather
-- than quietly republishing ~2,000 dataless pages to the site and the sitemap.
select
urn,
school_name,
school_type_code
from {{ ref('dim_school') }}
where school_type_code in ({{ var('non_england_school_type_codes') | join(', ') }})
union all
-- dim_location is inner-joined to dim_school by the API, so it must filter
-- identically. Anything here that dim_school does not have means the two
-- models have drifted apart.
select
l.urn,
null::text as school_name,
null::integer as school_type_code
from {{ ref('dim_location') }} l
left join {{ ref('dim_school') }} s on l.urn = s.urn
where s.urn is null
union all
-- Belt and braces on the Welsh LA code range.
--
-- The range 660-681 is documented GIAS structure, not something this project
-- can verify from its own data — local_authority_code is not exposed anywhere
-- the app can assert on. So the range does the filtering and the authority
-- NAME does the checking: if 660-681 is ever wrong or incomplete, a Welsh
-- authority reappears here and the pipeline fails loudly rather than the
-- corpus quietly regrowing a Welsh tail.
--
-- Beechwood College (142458) is why this exists: a Welsh school GIAS types as
-- a Special post 16 institution, which the type filter alone let through.
select
l.urn,
l.local_authority_name as school_name,
null::integer as school_type_code
from {{ ref('dim_location') }} l
where l.local_authority_name in (
'Isle of Anglesey', 'Gwynedd', 'Conwy', 'Denbighshire', 'Flintshire',
'Wrexham', 'Powys', 'Ceredigion', 'Pembrokeshire', 'Carmarthenshire',
'Swansea', 'Neath Port Talbot', 'Bridgend', 'Vale of Glamorgan',
'Rhondda Cynon Taf', 'Merthyr Tydfil', 'Caerphilly', 'Blaenau Gwent',
'Torfaen', 'Monmouthshire', 'Newport', 'Cardiff'
)