feat(admissions): add the cut-off history, map and postcode check #102

Merged
tudor merged 1 commits from feat/last-distance-offered-full into main 2026-08-17 07:51:14 +00:00
Owner

Completes the last-distance-offered feature against mockups/last-distance-offered.html: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them.

Serving the history — answering the earlier objection rather than dropping it

PR #101 deliberately served only the latest year, on the grounds that a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered here rather than abandoned: cutoffYearRows classifies every year in the span, and the chart uses spanGaps: false so the line breaks rather than interpolating across it.

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

The claim is held to what the data supports. fact_admissions.oversubscribed compares first preferences against places — it does not establish that every applicant was offered one. The mockup's "All offered / every applicant was offered a place" overstates it, so the copy reads "Places available on first preferences" and a test asserts the stronger claim never appears anywhere on the page.

I also dropped the mockup's floating "all offered" marker at the top of the plot. A year with no distance has no y-position, and giving it one puts a number on the axis that does not exist. It is a gap in the line and a labelled row in the table, where a reason can be stated in words instead of implied by a coordinate.

The trend summary is not a verdict

The mockup proposed "The catchment has halved in nine years". This ships something quieter: it names both endpoints and their years and lets the reader conclude. Withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction — the series is short, gappy, and one large sibling cohort moves it.

The postcode check

This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say.

  • "Too close to call". postcodes.io returns a centroid covering roughly fifteen addresses. Against a cut-off that is often only 500 m that is a fifth of the whole distance, so a margin inside 100 m returns too close to call rather than a place a family does not have.
  • Unpublished years count as unknown, never as a pass.
  • The limits are stated before the check is used, not revealed with the answer.
  • The postcode is geocoded in the browser and never stored.

School coordinates come from GIAS easting/northing (the site itself), so the dominant error is the parent's postcode — which is what the uncertainty band is sized against.

Both templates

Banded and selective secondaries are exactly where this matters most, so the detail is shared rather than primary-only. The primary page gives it a third Distance tab (the view toggle is now N-view); the secondary page is one flat panel by design and renders it inline.

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

A test that was not testing anything

test_supplementary_batch's fake session ignored order_by. The loader picks a row per URN by position — first for latest Ofsted, last for latest cut-off — which is only correct because the database returned them sorted. With the sort dropped, "latest row per URN" was really "first row in the fixture", and the test would have passed with the ORDER BY reversed or deleted. The double now honours it, and the distance fixture is deliberately out of year order so the assertion depends on it.

Verification

  • tsc clean; next build green
  • 214 frontend tests (36 new), 54 backend, 45/53 e2e green against staging — the 8 cut-off journeys skip until the DAG runs
  • Rendered offline against the real compiled CSS in both themes and at 390px. Every new surface clears WCAG AA measured on composited pixels: pills 4.59–8.86:1, year chips 4.59–6.50:1, result block 6.49–13.18:1
  • The year table is wider than a phone on purpose; verified its own wrapper absorbs that and the page does not scroll sideways

One probe reading needed checking rather than acting on: the route flag measured 4.48:1 in dark, which turned out to be a 4×4 pixel patch straddling the preceding glyph run. On its real backdrop it is 8.86:1 — no change made.

Not verifiable offline, and not claimed

The Leaflet map and the Chart.js canvas need network tiles and a canvas backend, so the harness stubs both. Their surrounding markup, CSS and logic are verified; the rendered map and chart themselves need a look on staging once the DAG has run.

Note for the reviewer

school_data_annual_distance still has not been run — nothing appears until you trigger it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE

Completes the last-distance-offered feature against `mockups/last-distance-offered.html`: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. ## Serving the history — answering the earlier objection rather than dropping it PR #101 deliberately served **only the latest year**, on the grounds that a plain series would draw a trend line straight through gaps that are absences of *publication*, not of a cut-off. That reasoning is answered here rather than abandoned: `cutoffYearRows` classifies every year in the span, and the chart uses `spanGaps: false` so the line **breaks** rather than interpolating across it. **A missing year is not one fact but three.** It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. **The claim is held to what the data supports.** `fact_admissions.oversubscribed` compares *first preferences* against places — it does **not** establish that every applicant was offered one. The mockup's "All offered / every applicant was offered a place" overstates it, so the copy reads *"Places available on first preferences"* and a test asserts the stronger claim never appears anywhere on the page. I also dropped the mockup's floating "all offered" marker at the top of the plot. A year with no distance has no y-position, and giving it one puts a number on the axis that does not exist. It is a gap in the line and a labelled row in the table, where a reason can be stated in words instead of implied by a coordinate. ## The trend summary is not a verdict The mockup proposed *"The catchment has halved in nine years"*. This ships something quieter: it names both endpoints and their years and lets the reader conclude. Withheld below four published points, and a swing under a tenth of the earlier figure is reported as *"broadly the same"* rather than dressed up as a direction — the series is short, gappy, and one large sibling cohort moves it. ## The postcode check This is the only place on the site that answers a question about a **family** rather than a school, so most of the care went into what it refuses to say. - **"Too close to call".** postcodes.io returns a centroid covering roughly fifteen addresses. Against a cut-off that is often only 500 m that is a fifth of the whole distance, so a margin inside **100 m** returns *too close to call* rather than a place a family does not have. - **Unpublished years count as unknown**, never as a pass. - **The limits are stated before the check is used**, not revealed with the answer. - The postcode is geocoded in the browser and **never stored**. School coordinates come from GIAS easting/northing (the site itself), so the dominant error is the parent's postcode — which is what the uncertainty band is sized against. ## Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared rather than primary-only. The primary page gives it a third `Distance` tab (the view toggle is now N-view); the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported: a **selective** school's missing figure is explained by how it admits, and a **consistently undersubscribed** school reads as good news. ## A test that was not testing anything `test_supplementary_batch`'s fake session ignored `order_by`. The loader picks a row per URN *by position* — first for latest Ofsted, last for latest cut-off — which is only correct because the database returned them sorted. With the sort dropped, "latest row per URN" was really "first row in the fixture", and the test would have passed with the ORDER BY reversed or deleted. The double now honours it, and the distance fixture is deliberately out of year order so the assertion depends on it. ## Verification - `tsc` clean; `next build` green - **214 frontend tests** (36 new), **54 backend**, **45/53 e2e** green against staging — the 8 cut-off journeys skip until the DAG runs - Rendered offline against the real compiled CSS in both themes and at 390px. Every new surface clears WCAG AA measured on **composited pixels**: pills 4.59–8.86:1, year chips 4.59–6.50:1, result block 6.49–13.18:1 - The year table is wider than a phone on purpose; verified its own wrapper absorbs that and the page does not scroll sideways One probe reading needed checking rather than acting on: the route flag measured 4.48:1 in dark, which turned out to be a 4×4 pixel patch straddling the preceding glyph run. On its real backdrop it is 8.86:1 — no change made. ## Not verifiable offline, and not claimed The **Leaflet map** and the **Chart.js canvas** need network tiles and a canvas backend, so the harness stubs both. Their surrounding markup, CSS and logic are verified; the rendered map and chart themselves need a look on staging once the DAG has run. ## Note for the reviewer `school_data_annual_distance` still **has not been run** — nothing appears until you trigger it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
tudor added 1 commit 2026-08-16 12:42:36 +00:00
feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
a72323874f
Completes the last-distance-offered feature against the mockup: the
year-by-year record, the same numbers drawn over real streets, and the
reader's own address measured against them.

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

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

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

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

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

  The postcode is geocoded in the browser and never stored.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE

🤖 AI Code Review (Claude Code)

Adds year-by-year admission cut-off distance history end-to-end: backend serializes an ordered history array alongside the existing latest-year field, and the frontend adds a Distance tab with a trend chart, year table, ring map, and a postcode proximity check. The change is thoroughly covered by backend and frontend tests, and the backend history-building logic mirrors the existing, already-tested admissions_history pattern, so risk of production breakage is low.

🟡 Minor

  • nextjs-app/components/school/CutoffMapPanel.tsx: When there are exactly two rings, the legend renders 'Earlier years, {year}–{year}' with the same year twice (rings[rings.length-1] and rings[1] are the same element), which reads oddly instead of showing a single year.
  • nextjs-app/components/school/lastDistanceOffered.ts: cutoffYearRows bounds the start of the year span to the earliest published year (per its own comment, to avoid padding with years EES reaches back further than councils publish) but bounds the end of the span with Math.max over both published and admissions years, so an admissions year newer than the latest published cut-off will pad the table/chart forward with extra 'not-published' rows — inconsistent with the stated design rationale, though not clearly harmful.
## 🤖 AI Code Review (Claude Code) Adds year-by-year admission cut-off distance history end-to-end: backend serializes an ordered history array alongside the existing latest-year field, and the frontend adds a Distance tab with a trend chart, year table, ring map, and a postcode proximity check. The change is thoroughly covered by backend and frontend tests, and the backend history-building logic mirrors the existing, already-tested admissions_history pattern, so risk of production breakage is low. ### 🟡 Minor - **nextjs-app/components/school/CutoffMapPanel.tsx**: When there are exactly two rings, the legend renders 'Earlier years, {year}–{year}' with the same year twice (rings[rings.length-1] and rings[1] are the same element), which reads oddly instead of showing a single year. - **nextjs-app/components/school/lastDistanceOffered.ts**: cutoffYearRows bounds the start of the year span to the earliest *published* year (per its own comment, to avoid padding with years EES reaches back further than councils publish) but bounds the end of the span with Math.max over both published and admissions years, so an admissions year newer than the latest published cut-off will pad the table/chart forward with extra 'not-published' rows — inconsistent with the stated design rationale, though not clearly harmful.
tudor merged commit 8bf6145a73 into main 2026-08-17 07:51:14 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tudor/school_compare#102