feat(admissions): show the last distance offered where councils publish it #101

Merged
tudor merged 1 commits from feat/last-distance-offered into main 2026-08-16 12:21:24 +00:00
Owner

Adds the cut-off distance parents actually ask about — "how close do we need to live?" — end to end: Singer tap → dbt staging + mart → Airflow DAG → API → a tile on both detail templates.

3,597 schools across 57 local authorities get a figure. Every other school page is unchanged.

Why most of this is judgement, not plumbing

There is no national dataset for this. Each LA publishes its own cut-offs in its own format, and the collected CSV is transcribed from council PDFs, spreadsheets and web pages. The interesting decisions are all about what is safe to put in front of a parent.

Multi-route schools (111 school-years). A banded secondary publishes one cut-off per ability band — Central Foundation Boys' School has four, ranging 2.0–3.9 km in 2023. Every one of the 111 genuinely-differing cases is an identifiable route (the band is in school_name); none are ambiguous. The mart takes the furthest, which is what "last distance offered" literally means, and carries route_count so the page can say the figure is the widest of several rather than the one that applies to a given child.

Implausible values (~4% of rows). The source contains 34 rows reading 0.0 miles (published where a school filled on a higher criterion, so distance was never reached), 72 rows under 20 m, and outliers up to 533 miles. stg_school_distance applies a 25 m – 25 km plausibility band, keeping genuine rural secondary cut-offs (~12–15 miles) and cutting figures that are visibly wrong. Raw stays verbatim so the rule lives in one reviewable place.

Primary key. (urn, year) collides on 118 keys. The tap keys on (urn, year, school_name) — unique across all 9,128 rows — so a reload doesn't silently drop every band but one.

Design decisions

  • Separate mart, not a join into fact_admissions. That mart is EES-derived and near-complete for England; this covers 57 LAs and refreshes on an unrelated timetable. Joining would add mostly-null columns to every school in the country.
  • Latest year only. Coverage is ragged — a school may have 2021 and 2026 and nothing between — so a history array would invite a trend line drawn through gaps that are absences of publication, not of a cut-off.
  • The section now renders on either source. Sampling 400 schools against staging: of the 255 that render, 8 (3%) have no EES admissions row. Gating on admissions alone would have hidden the figure on ~110 school pages. buildNavItems was updated to match, or the sticky nav would link to an anchor that never rendered.

Interface

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

Accessibility

The tile's tint is a background-image gradient over --bg-card, so getComputedStyle().backgroundColor reports the untinted card and flatters any contrast check. Measured against the real composited backdrop by sampling rendered pixels, --text-muted falls to 4.09:1 in dark theme — under AA, on the element carrying the year. Switched to --text-secondary: 6.50:1 dark / 6.60:1 light. All measured values now pass in both themes.

Verification

  • tsc --noEmit clean; next build green
  • 178 frontend tests pass (11 new, covering formatting, the caveats, the banded-school note, both templates, and the distance-only case)
  • 54 backend tests pass — test_supplementary_batch extended for the new table and its latest-year-per-URN behaviour
  • 44/48 e2e pass against staging; the 4 new ones skip, because staging has no distance data until the DAG runs. They assert invariants (figure carries its year, caveat always present, never shows a figure and claims none published, values inside the plausibility band) and activate automatically once it does.
  • Tap run live against the real URL: 9,127 records, unique PK, correct types
  • Rendered offline against the real compiled CSS in light, dark and at 390px — no horizontal overflow

Two layout bugs were found and fixed that way: the distance-only case left two empty grid rows painting the grid's gap colour, and the secondary card's label wrapped to three lines at the 140px track width.

Note for the reviewer

The DAG (school_data_annual_distance) is manual, like the other annual ones — councils publish on allocation day, each on its own timetable, so there is no date worth scheduling against. It has not been run, so nothing appears on staging until you trigger it.

mockups/last-distance-offered.html is committed as the design source. This PR implements section 1 (the tile) and section 3 (coverage states). The map with the cut-off ring and the postcode check are not included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE

Adds the cut-off distance parents actually ask about — *"how close do we need to live?"* — end to end: Singer tap → dbt staging + mart → Airflow DAG → API → a tile on both detail templates. **3,597 schools across 57 local authorities** get a figure. Every other school page is unchanged. ## Why most of this is judgement, not plumbing There is no national dataset for this. Each LA publishes its own cut-offs in its own format, and the collected CSV is transcribed from council PDFs, spreadsheets and web pages. The interesting decisions are all about what is safe to put in front of a parent. **Multi-route schools (111 school-years).** A banded secondary publishes one cut-off per ability band — Central Foundation Boys' School has four, ranging 2.0–3.9 km in 2023. Every one of the 111 genuinely-differing cases is an identifiable route (the band is in `school_name`); none are ambiguous. The mart takes the **furthest**, which is what "last distance offered" literally means, and carries `route_count` so the page can say the figure is the widest of several rather than the one that applies to a given child. **Implausible values (~4% of rows).** The source contains 34 rows reading `0.0 miles` (published where a school filled on a higher criterion, so distance was never reached), 72 rows under 20 m, and outliers up to **533 miles**. `stg_school_distance` applies a 25 m – 25 km plausibility band, keeping genuine rural secondary cut-offs (~12–15 miles) and cutting figures that are visibly wrong. Raw stays verbatim so the rule lives in one reviewable place. **Primary key.** `(urn, year)` collides on 118 keys. The tap keys on `(urn, year, school_name)` — unique across all 9,128 rows — so a reload doesn't silently drop every band but one. ## Design decisions - **Separate mart, not a join into `fact_admissions`.** That mart is EES-derived and near-complete for England; this covers 57 LAs and refreshes on an unrelated timetable. Joining would add mostly-null columns to every school in the country. - **Latest year only.** Coverage is ragged — a school may have 2021 and 2026 and nothing between — so a history array would invite a trend line drawn through gaps that are absences of *publication*, not of a cut-off. - **The section now renders on either source.** Sampling 400 schools against staging: of the 255 that render, **8 (3%) have no EES admissions row**. Gating on `admissions` alone would have hidden the figure on ~110 school pages. `buildNavItems` was updated to match, or the sticky nav would link to an anchor that never rendered. ## Interface - **The year travels with the figure everywhere.** A cut-off detached from its admissions round is not a fact about anything. - **"Not a fixed catchment — it moves every year"** sits under every instance, because that is the inference a parent will otherwise draw. - Replaces a hardcoded *"Historical distance cut-off data is not available for this school"* that appeared on **every** secondary page — including the ones whose council does publish it. The absence is now stated only when real, and names the authority that would hold it. ## Accessibility The tile's tint is a `background-image` gradient over `--bg-card`, so `getComputedStyle().backgroundColor` reports the *untinted* card and flatters any contrast check. Measured against the real composited backdrop by sampling rendered pixels, `--text-muted` falls to **4.09:1** in dark theme — under AA, on the element carrying the year. Switched to `--text-secondary`: **6.50:1 dark / 6.60:1 light**. All measured values now pass in both themes. ## Verification - `tsc --noEmit` clean; `next build` green - **178 frontend tests pass** (11 new, covering formatting, the caveats, the banded-school note, both templates, and the distance-only case) - **54 backend tests pass** — `test_supplementary_batch` extended for the new table and its latest-year-per-URN behaviour - **44/48 e2e pass against staging; the 4 new ones skip**, because staging has no distance data until the DAG runs. They assert invariants (figure carries its year, caveat always present, never shows a figure *and* claims none published, values inside the plausibility band) and activate automatically once it does. - Tap run live against the real URL: 9,127 records, unique PK, correct types - Rendered offline against the real compiled CSS in light, dark and at 390px — no horizontal overflow Two layout bugs were found and fixed that way: the distance-only case left two empty grid rows painting the grid's gap colour, and the secondary card's label wrapped to three lines at the 140px track width. ## Note for the reviewer The DAG (`school_data_annual_distance`) is **manual**, like the other annual ones — councils publish on allocation day, each on its own timetable, so there is no date worth scheduling against. **It has not been run**, so nothing appears on staging until you trigger it. `mockups/last-distance-offered.html` is committed as the design source. This PR implements section 1 (the tile) and section 3 (coverage states). The map with the cut-off ring and the postcode check are not included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
tudor added 1 commit 2026-08-15 21:49:03 +00:00
feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
88c653215d
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
tudor merged commit 5a71f54d94 into main 2026-08-16 12:21:24 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tudor/school_compare#101