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
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.
There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.
Data
* tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
(urn, year, school_name), because school_name carries the admission
route: (urn, year) alone collides on 118 keys and a reload would have
silently dropped every band but one.
* stg_school_distance applies a 25 m – 25 km plausibility band. The source
contains 0.0-mile rows (published where a school filled on a higher
criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
all of which would put a visibly wrong number on a live page.
* fact_admission_distance collapses routes to one row per school per year
using the furthest, and keeps route_count so the page can say the figure
is the widest of several bands rather than the one for a given child.
Serving
* Kept out of fact_admissions: that mart is EES-derived and near-complete
for England, this one covers 57 LAs, and the two refresh independently.
* Latest year only. Coverage is ragged — a school may have 2021 and 2026
and nothing between — so a history array would invite a trend line drawn
through gaps that are absences of publication, not of a cut-off.
* The Admissions section now renders on either source. 3% of the schools
that render have a cut-off and no EES admissions row, and gating on
admissions alone would have hidden the figure on those pages.
Interface
* The year travels with the figure everywhere it appears; a cut-off
detached from its admissions round is not a fact about anything.
* "Not a fixed catchment — it moves every year" sits under every instance,
because that is the inference a parent will otherwise draw.
* Replaces a hardcoded "Historical distance cut-off data is not available
for this school" that appeared on every secondary page, including the
ones whose council does publish it. The absence is now stated only when
it is real, and names the authority that would hold it.
The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.
The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
This commit is contained in:
1 parent
5156a85bd1
commit
88c653215d
31 files changed
+1753
-42
No files matched your search
@@ -1210,3 +1210,100 @@ for (const scheme of ['light', 'dark'] as const) {
|
||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Last distance offered ────────────────────────────────────────────────
|
||||
//
|
||||
// Coverage is partial by nature: only 57 local authorities publish cut-off
|
||||
// distances at all, and staging may not yet hold the extract. These tests
|
||||
// therefore locate a school that HAS a figure and assert the invariants that
|
||||
// make it safe to show, rather than asserting any particular school has one.
|
||||
|
||||
/** URNs known to be in the collected cut-off dataset, spread across LAs so a
|
||||
* single council's data gap cannot skip the whole check. */
|
||||
const CUTOFF_CANDIDATE_URNS = [
|
||||
139837, 100010, 100011, 100028, 100029, 130342, // Camden
|
||||
100458, 135587, // Islington (banded)
|
||||
101099, 100553, 102574, 100769, // mixed
|
||||
];
|
||||
|
||||
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) {
|
||||
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page);
|
||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const label = page.getByText(/Last distance offered/).first();
|
||||
await expect(label).toBeVisible();
|
||||
|
||||
// The year is the difference between a fact and a floating number: a cut-off
|
||||
// is the outcome of one admissions round and means nothing without it.
|
||||
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();
|
||||
});
|
||||
|
||||
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page);
|
||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(page.getByText(/not a fixed catchment/)).toBeVisible();
|
||||
|
||||
// The old secondary template asserted "distance cut-off data is not
|
||||
// available for this school" on every page, including ones whose council
|
||||
// does publish it. Showing both at once would be a flat contradiction.
|
||||
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
|
||||
|
||||
// A banded school's figure is the widest of several routes, and must say so.
|
||||
if ((found!.distance.route_count ?? 1) > 1) {
|
||||
await expect(page.getByText(/admission routes at this school/)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => {
|
||||
// buildNavItems and the section composers gate on the same condition; when
|
||||
// they drift the nav links to an anchor that was never rendered.
|
||||
const found = await schoolWithCutoff(page);
|
||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(page.locator('#admissions')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('no school page shows an implausible cut-off distance', async ({ page }) => {
|
||||
// The source carries transcription errors up to 533 miles, filtered in
|
||||
// stg_school_distance. This asserts the filter is actually reaching the API,
|
||||
// because a visibly absurd figure on a live page is the failure that would
|
||||
// cost the site its credibility.
|
||||
let checked = 0;
|
||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||
const res = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!res.ok()) continue;
|
||||
const d = (await res.json())?.admission_distance;
|
||||
if (d?.distance_m == null) continue;
|
||||
checked += 1;
|
||||
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
||||
.toBeGreaterThanOrEqual(25);
|
||||
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
||||
.toBeLessThanOrEqual(25000);
|
||||
}
|
||||
test.skip(checked === 0, 'no cut-off distances available to check yet');
|
||||
});
|
||||
Reference in new issue
Block a user