diff --git a/.gitignore b/.gitignore index 19c8157..4f969df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ pipeline/transform/target/ pipeline/transform/logs/ pipeline/transform/.user.yml + +# Playwright MCP scratch output (screenshots, console logs, page snapshots) +.playwright-mcp/ diff --git a/backend/app.py b/backend/app.py index 120fffc..20d9570 100644 --- a/backend/app.py +++ b/backend/app.py @@ -632,6 +632,7 @@ async def get_school_details(request: Request, urn: int): "census": supplementary.get("census"), "admissions": supplementary.get("admissions"), "admissions_history": supplementary.get("admissions_history") or [], + "admission_distance": supplementary.get("admission_distance"), "sen_detail": supplementary.get("sen_detail"), "phonics": supplementary.get("phonics"), "deprivation": supplementary.get("deprivation"), diff --git a/backend/data_loader.py b/backend/data_loader.py index e3aec2f..0640b76 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -18,7 +18,7 @@ from .config import settings from .database import SessionLocal, engine from .models import ( DimSchool, DimLocation, KS2Performance, - FactOfstedInspection, FactAdmissions, + FactOfstedInspection, FactAdmissions, FactAdmissionDistance, FactDeprivation, FactFinance, FactPupilCharacteristics, ) from .ofsted_codes import ofsted_page_url, report_card_labels @@ -723,6 +723,17 @@ def _admissions_row_dict(a) -> dict: } +def _admission_distance_dict(d) -> dict: + """Serialize one fact_admission_distance row for API responses.""" + return { + "year": d.year, + "distance_m": d.distance_m, + "route_count": d.route_count, + "la_name": d.la_name, + "distance_unit_raw": d.distance_unit_raw, + } + + def _census_dict(pc) -> dict: return { "year": pc.year, @@ -759,6 +770,7 @@ def _empty_supplementary() -> dict: "census": None, "admissions": None, "admissions_history": [], + "admission_distance": None, "sen_detail": None, "phonics": None, "deprivation": None, @@ -837,6 +849,27 @@ 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 — latest year per URN. + # + # Only the most recent year is served. Coverage is ragged (a school may + # have 2021 and 2026 but nothing between), so a history array would invite + # a trend line drawn through gaps that are absences of publication rather + # than absences of a cut-off. + def _admission_distance(): + rows = ( + db.query(FactAdmissionDistance) + .filter(FactAdmissionDistance.urn.in_(urns)) + .order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc()) + .all() + ) + seen = set() + for d in rows: + if d.urn in seen: + continue + seen.add(d.urn) + result[d.urn]["admission_distance"] = _admission_distance_dict(d) + _safe(_admission_distance) + # Deprivation — one row per URN. def _deprivation(): rows = ( diff --git a/backend/models.py b/backend/models.py index 22e9b06..b68b342 100644 --- a/backend/models.py +++ b/backend/models.py @@ -188,6 +188,37 @@ class FactAdmissions(Base): admissions_policy = Column(String(100)) +class FactAdmissionDistance(Base): + """Last distance offered — one row per URN per year. + + Separate from FactAdmissions because the source is separate: EES publishes + admissions for the whole country, whereas cut-off distances exist only for + the local authorities that choose to publish them (57 at the time of + writing), and the two refresh on unrelated timetables. + """ + __tablename__ = "fact_admission_distance" + __table_args__ = ( + Index("ix_admission_distance_urn_year", "urn", "year"), + MARTS, + ) + + urn = Column(Integer, primary_key=True) + year = Column(Integer, primary_key=True) + # Straight-line distance in metres from the school to the last home offered + # a place that year. + distance_m = Column(Float) + # How many admission routes (ability bands, separate reception/junior + # intakes) were collapsed into distance_m. >1 means the figure is the + # furthest of several and the page must say so. + route_count = Column(Integer) + la_code = Column(Integer) + la_name = Column(String(100)) + # The unit the council published in, so the page can lead with the unit a + # parent was given rather than always converting. + distance_unit_raw = Column(String(20)) + source_file = Column(Text) + + class FactPupilCharacteristics(Base): """School pupil composition from EES census — one row per URN per year.""" __tablename__ = "fact_pupil_characteristics" diff --git a/backend/tests/test_supplementary_batch.py b/backend/tests/test_supplementary_batch.py index 0593034..2bdbdcd 100644 --- a/backend/tests/test_supplementary_batch.py +++ b/backend/tests/test_supplementary_batch.py @@ -69,6 +69,13 @@ def _adm_row(urn, year): ) +def _dist_row(urn, year, distance_m, route_count=1): + return types.SimpleNamespace( + urn=urn, year=year, distance_m=distance_m, route_count=route_count, + la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf", + ) + + def test_one_query_per_table_and_latest_row_per_urn(): rows = { # URN 1 has two Ofsted rows; the batch must keep the most recent (2023). @@ -78,6 +85,14 @@ def test_one_query_per_table_and_latest_row_per_urn(): _ofsted_row(2, "2021-06-01", 1), ], "FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)], + # URN 1 has three years of cut-offs; only the most recent is served. + # Deliberately not in year order — the ordering is the query's job. + "FactAdmissionDistance": [ + _dist_row(1, 2026, 529.47), + _dist_row(1, 2024, 772.49), + _dist_row(1, 2025, 1421.05), + _dist_row(2, 2023, 2029.38, route_count=4), + ], "FactPupilCharacteristics": [], "FactDeprivation": [], "FactFinance": [], @@ -85,10 +100,10 @@ def test_one_query_per_table_and_latest_row_per_urn(): session = _FakeSession(rows) out = get_supplementary_data_batch(session, [1, 2]) - # Exactly one query per table — five total, regardless of two URNs. + # Exactly one query per table — six total, regardless of two URNs. assert sorted(session.queries) == [ - "FactAdmissions", "FactDeprivation", "FactFinance", - "FactOfstedInspection", "FactPupilCharacteristics", + "FactAdmissionDistance", "FactAdmissions", "FactDeprivation", + "FactFinance", "FactOfstedInspection", "FactPupilCharacteristics", ] # Latest Ofsted kept per URN @@ -100,6 +115,14 @@ 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: latest year only, per URN. Coverage is ragged, so + # serving anything but the newest would show a parent a stale figure. + assert out[1]["admission_distance"]["year"] == 2026 + assert out[1]["admission_distance"]["distance_m"] == 529.47 + # route_count travels with the figure — the page needs it to say the + # distance is the furthest of several bands rather than the only one. + assert out[2]["admission_distance"]["route_count"] == 4 + # Empty tables degrade to the null block, not a crash assert out[1]["census"] is None and out[1]["deprivation"] is None @@ -109,3 +132,4 @@ def test_single_wrapper_matches_batch(monkeypatch): single = data_loader.get_supplementary_data(session, 5) assert single["ofsted"]["overall_effectiveness"] == 2 assert single["admissions_history"] == [] + assert single["admission_distance"] is None diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index a1d3392..07c59a8 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -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'); +}); diff --git a/mockups/last-distance-offered.html b/mockups/last-distance-offered.html new file mode 100644 index 0000000..a182865 --- /dev/null +++ b/mockups/last-distance-offered.html @@ -0,0 +1,591 @@ +Last distance offered — detail page mockup + + +
+ +
+

Last distance offered — school detail page

+

Adds the final-offer cut-off distance to the existing Admissions card, plus a catchment + view on the map. Data covers one to ten years depending on the school and local authority, so every + screen here is built around partial coverage rather than assuming a full run.

+

Sample school: Fairlawn Primary School, Lewisham — 8 years of distance data out of 10 years of admissions data.

+
+ + +
+

1. A distance tile joins the admissions tiles

+ SchoolDetailView · #admissions +
+

No new section and no new nav entry — the number a parent actually asks for + ("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a + third view, Distance.

+ +
+
+

Admissions

+
+ + + +
+
+

Reception entry, September 2025.

+ +
+ +
+
+
+
60
Places offered
+
+
+
142
Wanted it first
+
+
+
54of 142 · 38%
+
Got their first choice
+
+
+ New +
0.31mi≈ 500 m · 6 min walk
+
Last distance offered
+
+
+
+ + The furthest home offered a place once siblings, faith and EHCP priority were applied. + It is not a fixed catchment — it moves every year with the number of applications. +
+
+ + + +
+
+ + +
+

2. The cut-off drawn on the map

+ SchoolHeroMap · catchment layer +
+

"0.31 miles" is abstract until you see it over your own streets. The latest year is a + filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of + shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.

+ +
+
+

Where the last place went

+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2016 · 0.62 mi + + + 2025 · 0.31 mi + + + + Fairlawn Primary + + + + + + Your home · 0.24 mi + +
+ +
+ 2025 cut-off — 0.31 mi + Earlier years, 2016–2024 + Your home +
+ +
+
Would you have got in?
+

We measure straight-line distance from your postcode, the same way Lewisham does.

+
+ + + +
+ +
+
0.24 miles away — inside the cut-off in all 8 years on record
+
That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published + figure, and in 2022 every applicant was offered a place.
+
+ 2016 ✓ + 2017 ✓ + 2018 – + 2019 ✓ + 2020 ✓ + 2021 ✓ + 2022 ✓ + 2023 ✓ + 2024 ✓ + 2025 ✓ +
+
+ +
+ + An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's + cut-off depends on next year's applicants. Always check the school's own admissions policy. +
+
+
+ + +
+

3. Coverage states

+ Partial data is the normal case +
+

Coverage runs from ten years to none. Each state says something true rather than + falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.

+ +
+
+

4+ years

+
Full treatment: verdict banner, chart, table, map rings. The verdict line only + appears with 4+ points — below that a two-year swing isn't a trend.
+
+
+

2–3 years

+
Tile and table, no verdict banner, single map ring for the latest year. + "Only 3 years available" sits under the table.
+
+
+

1 year

+
Tile plus one map ring. No Distance tab — the tile and its footnote are + the whole story.
+
+
+

Never oversubscribed

+
Not missing data — good news, and it should read that way.
+
Everyone who applied was offered a place + in each of the last 6 years, so no distance cut-off was needed.
+
+
+

No data at all

+
Replaces the current placeholder text in + SecondarySchoolDetailView.tsx:780.
+
Lewisham hasn't published cut-off distances for this school. + The council's admissions page may list them.
+
+
+

Not distance-ranked

+
Grammar and some faith schools rank on test score or faith + practice, so a distance figure would mislead.
+
Places here are ranked by the entrance test, not by distance.
+
+
+ + +
+

4. Mobile

+ ≤ 640 px +
+

Tiles fall to two columns and the distance tile takes the full width beneath them, so the + headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.

+ +
+
+
+
+
+

Admissions

+
+

Reception, Sept 2025

+
+ + + +
+
+
60
Places offered
+
142
Wanted it first
+
+
+ New +
0.31mi + ≈ 500 m · 6 min walk
+
Last distance offered, 2025
+
+
+ +
+

Distance

+
+ +
Halved in nine years
+
0.62 mi → 0.31 mi
+
+
+ + + + + + 0.80.50.2 + + + + + + + + + + + + + + '16'20'25 + + 0.31 mi + +
+
+ See all 10 years +
+
+
+
+ +
+

Mobile decisions

+
    +
  • Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.
  • +
  • Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.
  • +
  • Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.
  • +
  • Map rings reuse the existing full-screen hero map sheet, opened from the tile.
  • +
+
+
+ +
+ + diff --git a/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx new file mode 100644 index 0000000..7472c5f --- /dev/null +++ b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx @@ -0,0 +1,90 @@ +/** + * Last distance offered, rendered on both detail templates. + * + * The figure is the one number on these pages that a parent may act on — it is + * easy to read as "we live inside the catchment, we will get a place". These + * tests pin the things that stop it being read that way: the year is always + * present, the caveat is always present, and the blanket "not available" + * sentence appears only when it is actually true. + */ + +import { screen } from '@testing-library/react'; +import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail'; +import { primaryFixture, secondaryFixture } from '../support/schoolFixtures'; +import type { SchoolAdmissionDistance } from '@/lib/types'; + +const cutoff = (over: Partial = {}): SchoolAdmissionDistance => ({ + year: 2025, + distance_m: 500, + route_count: 1, + la_name: 'Camden', + distance_unit_raw: 'miles', + ...over, +}); + +describe('primary detail page', () => { + it('shows the figure with the year it belongs to', () => { + renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) }); + + expect(screen.getByText('0.48 miles')).toBeInTheDocument(); + expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024'); + }); + + it('never shows the figure without saying it is not a catchment', () => { + renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() }); + + expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument(); + expect(screen.getByText(/moves every year/)).toBeInTheDocument(); + }); + + it('flags that a banded school\'s figure is the widest of several routes', () => { + renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) }); + + expect(screen.getByText(/4 admission routes/)).toBeInTheDocument(); + }); + + it('renders nothing distance-related when the LA publishes none', () => { + renderSchoolDetail({ ...primaryFixture, admissionDistance: null }); + + expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument(); + expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument(); + }); + + it('carries the figure even with no EES admissions row', () => { + // The two sources are independent; this school has a cut-off and no + // admissions figures. Before this feature the section did not render at all. + renderSchoolDetail({ + ...primaryFixture, + admissions: null, + admissionsHistory: [], + admissionDistance: cutoff({ distance_m: 1421.05 }), + }); + + expect(screen.getByText('0.88 miles')).toBeInTheDocument(); + }); +}); + +describe('secondary detail page', () => { + it('shows the figure with the year it belongs to', () => { + renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) }); + + expect(screen.getByText('2.16 miles')).toBeInTheDocument(); + expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025'); + }); + + it('drops the blanket "not available" line once a distance exists', () => { + renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() }); + + expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument(); + expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument(); + }); + + it('names the authority that would hold the data when there is none', () => { + // The old copy asserted "Historical distance cut-off data is not available + // for this school" on every secondary page, including the ones whose + // council does publish it. + renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null }); + + expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts new file mode 100644 index 0000000..0de75d0 --- /dev/null +++ b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts @@ -0,0 +1,80 @@ +/** + * Last distance offered — formatting and the caveats attached to the figure. + * + * The assertions about the route note and the year are not cosmetic. A cut-off + * shown without its year, or a banded school's widest cut-off shown as if it + * were the only one, tells a parent something false about their chances of a + * place — so both are pinned here rather than left to the component. + */ + +import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; +import { describeCutoff } from '@/components/school/lastDistanceOffered'; +import type { SchoolAdmissionDistance } from '@/lib/types'; + +const distance = (over: Partial = {}): SchoolAdmissionDistance => ({ + year: 2025, + distance_m: 500, + route_count: 1, + la_name: 'Camden', + distance_unit_raw: 'miles', + ...over, +}); + +describe('formatCutoffDistance', () => { + it('leads with miles, the unit councils publish in', () => { + expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' }); + expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' }); + }); + + it('switches to kilometres for the support figure above a kilometre', () => { + expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km'); + }); + + it('leads with metres below 100 m, where a miles figure carries nothing', () => { + expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' }); + }); + + it('returns null rather than a zero cut-off', () => { + // 0.0 miles appears in the source where a school filled on a higher + // criterion. Rendered as "0.00 miles" it would read as the opposite. + expect(formatCutoffDistance(0)).toBeNull(); + expect(formatCutoffDistance(null)).toBeNull(); + expect(formatCutoffDistance(undefined)).toBeNull(); + expect(formatCutoffDistance(Number.NaN)).toBeNull(); + }); +}); + +describe('formatEntryYear', () => { + it('names the intake, not the academic year', () => { + // formatAcademicYear would render 2025 as "2025/26", which reads as a + // school year rather than the September a child started. + expect(formatEntryYear(2025)).toBe('September 2025'); + expect(formatEntryYear(null)).toBe(''); + }); +}); + +describe('describeCutoff', () => { + it('always carries the entry year alongside the figure', () => { + const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 })); + expect(d).not.toBeNull(); + expect(d!.primary).toBe('0.48 miles'); + expect(d!.entryYear).toBe('September 2024'); + }); + + it('says nothing about routes for a school with one', () => { + expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull(); + expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull(); + }); + + it('warns that a banded school\'s figure is the widest of several', () => { + const note = describeCutoff(distance({ route_count: 4 }))!.routeNote; + expect(note).toContain('4 admission routes'); + expect(note).toContain('shorter cut-off'); + }); + + it('is null when there is nothing publishable', () => { + expect(describeCutoff(null)).toBeNull(); + expect(describeCutoff(undefined)).toBeNull(); + expect(describeCutoff(distance({ distance_m: null }))).toBeNull(); + }); +}); diff --git a/nextjs-app/__tests__/lib/schoolSections.test.ts b/nextjs-app/__tests__/lib/schoolSections.test.ts index 0c893d7..acaec4e 100644 --- a/nextjs-app/__tests__/lib/schoolSections.test.ts +++ b/nextjs-app/__tests__/lib/schoolSections.test.ts @@ -60,7 +60,7 @@ describe('buildNavItems', () => { it('omits sections with no data', () => { const flags = computeSchoolFlags(specialFixture); const ids = buildNavItems(flags, { - ofsted: null, admissions: null, yearlyDataLength: 1, + ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1, }).map((n) => n.id); expect(ids).not.toContain('ofsted'); @@ -74,6 +74,7 @@ describe('buildNavItems', () => { return buildNavItems(flags, { ofsted: fixture.ofsted, admissions: fixture.admissions, + admissionDistance: null, yearlyDataLength: fixture.yearlyData.length, }).find((n) => n.id === 'results')?.label; }; @@ -83,11 +84,27 @@ describe('buildNavItems', () => { expect(label(allThroughFixture)).toBe('Results'); }); + it('opens the admissions entry for a cut-off distance with no EES admissions', () => { + // 3% of the schools that render have one source and not the other. The nav + // condition and the section's render condition have to agree, or the sticky + // nav links to an anchor that was never rendered. + const flags = computeSchoolFlags(specialFixture); + const ids = buildNavItems(flags, { + ofsted: null, + admissions: null, + admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' }, + yearlyDataLength: 1, + }).map((n) => n.id); + + expect(ids).toContain('admissions'); + }); + it('keeps the engagement-led ordering', () => { const flags = computeSchoolFlags(primaryFixture); const ids = buildNavItems(flags, { ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions, + admissionDistance: null, yearlyDataLength: primaryFixture.yearlyData.length, }).map((n) => n.id); @@ -127,16 +144,29 @@ describe('buildSecondaryNavItems', () => { const ids = buildSecondaryNavItems(flags, { ofsted: secondaryFixture.ofsted, admissions: secondaryFixture.admissions, + admissionDistance: null, yearlyDataLength: secondaryFixture.yearlyData.length, }).map((n) => n.id); expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']); }); + it('opens the admissions entry for a cut-off distance alone', () => { + const flags = computeSecondaryFlags(secondaryFixture); + const ids = buildSecondaryNavItems(flags, { + ofsted: null, + admissions: null, + admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' }, + yearlyDataLength: 1, + }).map((n) => n.id); + + expect(ids).toContain('admissions'); + }); + it('gates History on more than one year, unlike the primary page', () => { const flags = computeSecondaryFlags(secondaryFixture); const ids = buildSecondaryNavItems(flags, { - ofsted: null, admissions: null, yearlyDataLength: 1, + ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1, }).map((n) => n.id); expect(ids).not.toContain('history'); diff --git a/nextjs-app/__tests__/support/renderSchoolDetail.tsx b/nextjs-app/__tests__/support/renderSchoolDetail.tsx index 72deca2..9dc43bd 100644 --- a/nextjs-app/__tests__/support/renderSchoolDetail.tsx +++ b/nextjs-app/__tests__/support/renderSchoolDetail.tsx @@ -31,6 +31,7 @@ export function renderSchoolDetail(fixture: any) { const navItems = buildNavItems(flags, { ofsted: fixture.ofsted, admissions: fixture.admissions, + admissionDistance: fixture.admissionDistance ?? null, yearlyDataLength: fixture.yearlyData.length, }); @@ -57,6 +58,7 @@ export function renderSecondarySchoolDetail(fixture: any) { const navItems = buildSecondaryNavItems(flags, { ofsted: fixture.ofsted, admissions: fixture.admissions, + admissionDistance: fixture.admissionDistance ?? null, yearlyDataLength: fixture.yearlyData.length, }); diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 538ee3d..55a156d 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { notFound(); } - const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data; + const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data; // Redirect bare URN to canonical slug URL const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -176,6 +176,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { const navInput = { ofsted: ofsted ?? null, admissions: admissions ?? null, + admissionDistance: admission_distance ?? null, yearlyDataLength: yearly_data.length, }; const primaryNavItems = buildNavItems(primaryFlags, navInput); @@ -228,6 +229,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { ofsted={ofsted ?? null} census={census ?? null} admissions={admissions ?? null} + admissionDistance={admission_distance ?? null} deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} @@ -249,6 +251,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { census={census ?? null} admissions={admissions ?? null} admissionsHistory={admissions_history ?? []} + admissionDistance={admission_distance ?? null} deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} diff --git a/nextjs-app/components/school/AdmissionsSection.tsx b/nextjs-app/components/school/AdmissionsSection.tsx index def1d3b..4b9f6c8 100644 --- a/nextjs-app/components/school/AdmissionsSection.tsx +++ b/nextjs-app/components/school/AdmissionsSection.tsx @@ -12,59 +12,112 @@ */ import type { ReactNode } from 'react'; -import type { SchoolAdmissions } from '@/lib/types'; +import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types'; import { formatAcademicYear, formatPercentage } from '@/lib/utils'; import { summariseAdmissions } from '@/lib/compareLogic'; import { Section, sectionStyles as styles } from './sectionShared'; import { AdmissionsViewToggle } from './AdmissionsViewToggle'; import { AdmissionsTrendChart } from './charts'; +import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered'; export function AdmissionsSection({ admissions, admissionsHistory, + admissionDistance, isAllThrough, }: { - admissions: SchoolAdmissions; + /* Nullable: the two data sources are independent. A minority of schools have + a published cut-off distance but no EES admissions row (3% of the schools + that render, sampled against staging), and for those this section exists + to carry the distance alone. */ + admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; + admissionDistance: SchoolAdmissionDistance | null; isAllThrough: boolean; }) { + const cutoff = describeCutoff(admissionDistance); + // Trend toggle only appears with ≥2 years carrying an offer rate. const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; - const showAdmissionsTrend = admissionsOfferYears >= 2; + const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2; // Banded interpretation of the first-choice offer rate ("More than half of // first choices missed out" etc.) — the same banding the compare screen // uses, so a low offer rate reads as how severe it actually is. - const admissionsSummary = summariseAdmissions(admissions); + const admissionsSummary = admissions ? summariseAdmissions(admissions) : null; - const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}; + const title = ( + <>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`} + ); {/* All-through admissions data covers a single entry point (usually the Year 7 secondary intake), not reception — say so, or a parent could read these as the whole-school figures. */} - const subtitle: ReactNode = isAllThrough && admissions.school_phase ? ( + const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (

These figures are for {admissions.school_phase.toLowerCase()} entry {/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.

) : null; + {/* Spans both columns rather than taking a half-width cell. This is the + figure parents come to the page for, and at tile width the two-line + "0.31 miles / September 2025" pairing wraps badly. */} + const distanceTile = cutoff && ( +
+
+ {cutoff.primary} + {cutoff.secondary} +
+
+ Last distance offered · {cutoff.entryYear} +
+
+ ); + + const distanceNote = cutoff && ( +

+ {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE} + {cutoff.routeNote && <> {cutoff.routeNote}} +

+ ); + + /* + * Which row template the tile grid needs. + * + * The grid reserves two equal rows so the year view matches the height of the + * trend chart beside it. With a cut-off and no EES admissions there are no + * metric tiles to fill them, and the reserved rows render as a bare block of + * the grid's own gap colour under the distance tile. + */ + const hasMetricTiles = admissions != null && ( + admissions.places_offered != null + || admissions.first_preference_applications != null + || admissions.first_preference_offer_pct != null + || admissions.total_applications != null + ); + const tilesClass = [ + styles.admissionsTiles, + cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '', + cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '', + ].filter(Boolean).join(' '); + const yearView = ( <> -
- {admissions.places_offered != null && ( +
+ {admissions?.places_offered != null && (
{admissions.places_offered}
Places offered
)} - {admissions.first_preference_applications != null && ( + {admissions?.first_preference_applications != null && (
{admissions.first_preference_applications}
Wanted it first
)} - {admissions.first_preference_offer_pct != null && ( + {admissions?.first_preference_offer_pct != null && (
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? ( @@ -81,20 +134,25 @@ export function AdmissionsSection({
Got their first choice
)} - {admissions.total_applications != null && ( + {admissions?.total_applications != null && (
{admissions.total_applications.toLocaleString()}
Applied in total
)} + {distanceTile}
- {admissionsSummary.chip && ( + {admissionsSummary?.chip && (

{admissionsSummary.chip.text}

)} + {distanceNote} ); - const trendView = ( + {/* Guarded rather than asserted: showAdmissionsTrend already requires + admissions, and tying the two together in one expression keeps that + invariant checked by the compiler instead of assumed. */} + const trendView = admissions && ( <>
First-choice offer rate
diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx index 2cafb22..b2bff36 100644 --- a/nextjs-app/components/school/PrimarySchoolSections.tsx +++ b/nextjs-app/components/school/PrimarySchoolSections.tsx @@ -13,7 +13,7 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, - SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, + SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SchoolFlags } from '@/lib/schoolSections'; @@ -34,6 +34,7 @@ export interface PrimarySchoolSectionsProps { census: SchoolCensus | null; admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; + admissionDistance: SchoolAdmissionDistance | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; nationalAvg: NationalAverages | null; @@ -42,7 +43,7 @@ export interface PrimarySchoolSectionsProps { export function PrimarySchoolSections({ schoolInfo, yearlyData, absenceData, ofsted, census, - admissions, admissionsHistory, deprivation, finance, nationalAvg, flags, + admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags, }: PrimarySchoolSectionsProps) { const primaryAvg = nationalAvg?.primary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -91,10 +92,15 @@ export function PrimarySchoolSections({ /> )} - {admissions && ( + {/* Either source is enough to justify the section. The cut-off distance + and the EES admissions figures come from different places and a + minority of schools have one without the other — gating on admissions + alone would hide a published distance on those pages. */} + {(admissions || admissionDistance) && ( )} diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx index bd0fbf8..7e8fdb3 100644 --- a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx +++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx @@ -6,17 +6,22 @@ * JavaScript at all. Server component. */ -import type { School, SchoolAdmissions } from '@/lib/types'; +import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types'; import { formatPercentage } from '@/lib/utils'; import { Section, sectionStyles as styles } from './sectionShared'; +import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered'; export function SecondaryAdmissionsSection({ - admissions, schoolInfo, hasSixthForm, + admissions, admissionDistance, schoolInfo, hasSixthForm, }: { - admissions: SchoolAdmissions; + /* Nullable for the same reason as the primary section: a school can have a + published cut-off and no EES admissions row. */ + admissions: SchoolAdmissions | null; + admissionDistance: SchoolAdmissionDistance | null; schoolInfo: School; hasSixthForm: boolean; }) { + const cutoff = describeCutoff(admissionDistance); // Moved with this section from SecondarySchoolDetailView, its only consumer. const admissionsTag = (() => { const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; @@ -40,32 +45,43 @@ export function SecondaryAdmissionsSection({ )}
- {admissions.places_offered != null && ( + {admissions?.places_offered != null && (
Year 7 places offered
{admissions.places_offered}
)} - {admissions.total_applications != null && ( + {admissions?.total_applications != null && (
Total applications
{admissions.total_applications.toLocaleString()}
)} - {admissions.first_preference_applications != null && ( + {admissions?.first_preference_applications != null && (
1st preference applications
{admissions.first_preference_applications.toLocaleString()}
)} - {admissions.first_preference_offer_pct != null && ( + {admissions?.first_preference_offer_pct != null && (
Families who got their first choice
{formatPercentage(admissions.first_preference_offer_pct)}
)} + {cutoff && ( +
+
+ Last distance offered · {cutoff.entryYear} +
+
+ {cutoff.primary} + {cutoff.secondary} +
+
+ )}
- {admissions.oversubscribed != null && ( + {admissions?.oversubscribed != null && (
{admissions.oversubscribed ? '⚠ Applications exceeded places last year' @@ -73,9 +89,23 @@ export function SecondaryAdmissionsSection({
)} -

- Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details. -

+ {/* Replaces a blanket "distance cut-off data is not available for this + school", which was hardcoded onto every secondary page and was untrue + wherever the local authority does publish. The absence is now stated + only when it is real, and names the authority that would hold it. */} + {cutoff ? ( +

+ {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE} + {cutoff.routeNote && <> {cutoff.routeNote}} +

+ ) : ( +

+ {schoolInfo.local_authority + ? `${schoolInfo.local_authority} has not published a cut-off distance for this school.` + : 'No cut-off distance has been published for this school.'}{' '} + Contact the admissions authority for its oversubscription criteria. +

+ )} {hasSixthForm && (
diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index 4dd0925..9c95439 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -13,7 +13,7 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, - SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, + SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SecondaryFlags } from '@/lib/schoolSections'; @@ -32,6 +32,7 @@ export interface SecondarySchoolSectionsProps { ofsted: OfstedInspection | null; census: SchoolCensus | null; admissions: SchoolAdmissions | null; + admissionDistance: SchoolAdmissionDistance | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; nationalAvg: NationalAverages | null; @@ -40,7 +41,7 @@ export interface SecondarySchoolSectionsProps { export function SecondarySchoolSections({ schoolInfo, yearlyData, ofsted, census, - admissions, deprivation, finance, nationalAvg, flags, + admissions, admissionDistance, deprivation, finance, nationalAvg, flags, }: SecondarySchoolSectionsProps) { const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -79,9 +80,12 @@ export function SecondarySchoolSections({ /> )} - {admissions && ( + {/* See PrimarySchoolSections: distance and EES admissions are independent + sources, so either one warrants the section. */} + {(admissions || admissionDistance) && ( diff --git a/nextjs-app/components/school/lastDistanceOffered.ts b/nextjs-app/components/school/lastDistanceOffered.ts new file mode 100644 index 0000000..5523384 --- /dev/null +++ b/nextjs-app/components/school/lastDistanceOffered.ts @@ -0,0 +1,61 @@ +/** + * Shared logic and copy for the "last distance offered" figure. + * + * The primary and secondary admissions sections lay their metrics out + * differently (a seamless tile grid vs. metric cards), so the markup is not + * shared — but the words are. Every caveat below is doing a specific job, and + * a figure that appeared on one template without them would be misleading in a + * way the other template was not: + * + * * The year, because a cut-off is the outcome of one particular admissions + * round and means nothing detached from it. + * * "not a fixed catchment", because that is the inference a parent will + * otherwise draw, and it is wrong — the distance moves every year. + * * The route count, because on a banded school the headline is the widest + * of several cut-offs and does not apply to every child. + */ + +import type { SchoolAdmissionDistance } from '@/lib/types'; +import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; + +export interface CutoffDisplay { + /** Headline figure, e.g. "0.31 miles". */ + primary: string; + /** Supporting conversion, e.g. "500 m". */ + secondary: string; + /** Entry point the figure belongs to, e.g. "September 2025". */ + entryYear: string; + /** Present only where several admission routes were collapsed into one figure. */ + routeNote: string | null; +} + +/** What the cut-off measures, and what it does not. Identical on both templates. */ +export const CUTOFF_NOTE = + 'The furthest home offered a place, after higher priorities such as siblings, ' + + 'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' + + 'with the number of applications.'; + +/** Straight-line, because that is how councils measure it. */ +export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.'; + +export function describeCutoff( + distance: SchoolAdmissionDistance | null | undefined +): CutoffDisplay | null { + if (!distance) return null; + + const figure = formatCutoffDistance(distance.distance_m); + if (!figure) return null; + + const routes = distance.route_count ?? 1; + + return { + primary: figure.primary, + secondary: figure.secondary, + entryYear: formatEntryYear(distance.year), + routeNote: + routes > 1 + ? `Furthest of ${routes} admission routes at this school — the one that ` + + 'applies to your child may have had a shorter cut-off.' + : null, + }; +} diff --git a/nextjs-app/components/school/schoolSections.module.css b/nextjs-app/components/school/schoolSections.module.css index 6ea4edc..a9f4515 100644 --- a/nextjs-app/components/school/schoolSections.module.css +++ b/nextjs-app/components/school/schoolSections.module.css @@ -778,6 +778,38 @@ word-break: break-word; } +/* Last distance offered, secondary template. These cards are opaque + (--bg-secondary), so the tint can be set directly here — unlike the primary + tile grid, nothing shows through between them. */ +.metricCardDistance { + background-color: var(--bg-card); + background-image: linear-gradient(var(--brand-bg), var(--brand-bg)); + border-color: var(--brand); + /* Two columns' worth of width. Its label carries the entry year as well as + the measure, which at the 140px track width of the other cards wraps to + three lines and pushes the figure out of alignment with them. */ + grid-column: span 2; +} + +.metricCardDistance .metricValue { + flex-direction: column; + gap: 0.1rem; + color: var(--brand-strong); +} + +.metricValueSub { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); +} + +/* Same measured reason as .admissionsTileDistance above: --text-muted falls to + 4.09:1 on the tinted card in dark theme. */ +.metricCardDistance .metricLabel, +.metricCardDistance .metricValueSub { + color: var(--text-secondary); +} + .metricHint { font-size: 0.7rem; color: var(--text-muted); @@ -1453,6 +1485,62 @@ color: var(--brand-strong); } +/* ── Admissions: last distance offered ── + * + * Spans the full grid width instead of taking a fifth half-cell. Two reasons: + * the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap; + * and this is the figure the section is most often opened for, which a + * half-width cell would rank below "Applied in total". + */ +.admissionsTileDistance { + grid-column: 1 / -1; + /* + * --brand-bg is translucent, and the tile grid paints --border between + * cells, so a plain `background` would let the hairline colour show through + * and read as muddy rather than tinted. The opaque card colour is laid down + * first and the tint composited over it as an image. + */ + background-color: var(--bg-card); + background-image: linear-gradient(var(--brand-bg), var(--brand-bg)); +} + +.admissionsTileDistance .admissionsTileNum { + color: var(--brand-strong); +} + +/* + * The tint costs the muted tokens their AA margin, so this tile does not use + * them. + * + * Measured on the composited backdrop, not the computed one: the tint is a + * background-image over --bg-card, so getComputedStyle reports the untinted + * card and flatters the result. Sampling the rendered pixel in dark theme gives + * a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5 + * floor — while --text-secondary is 6.50:1. + * + * The label is where the year lives, so it is the last thing on the tile that + * can afford to be hard to read: a distance without its year is not a fact + * about anything. + */ +.admissionsTileDistance .admissionsTileLabel, +.admissionsTileDistance .admissionsTileSub { + color: var(--text-secondary); +} + +/* + * The caveats, not a footnote in the visual-noise sense: without them the + * figure reads as a catchment boundary. Kept at body contrast rather than + * --text-muted for that reason — it qualifies the number above it, so it has + * to be as readable as the number. + */ +.admissionsDistanceNote { + margin: 0.85rem 0 0; + font-size: 0.8125rem; + line-height: 1.55; + color: var(--text-secondary); + max-width: 68ch; +} + .admissionsVerdict { margin-top: 0.75rem; margin-bottom: 0.25rem; @@ -1568,6 +1656,20 @@ grid-template-rows: 1fr 1fr; } +/* The distance tile occupies a third row. Left implicit it would inherit the + 1fr sizing of the row template and stretch to match the metric tiles above, + which are twice its content height. */ +.admissionsViewYear .admissionsTilesWithDistance { + grid-template-rows: 1fr 1fr auto; +} + +/* Cut-off distance but no EES admissions figures: there is nothing to put in + the two metric rows, and leaving them in the template paints an empty block + of the grid's gap colour beneath the tile. */ +.admissionsViewYear .admissionsTilesDistanceOnly { + grid-template-rows: auto; +} + .admissionsChartCap { font-size: 0.8125rem; font-weight: 600; diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts index e11b614..4a4dbc5 100644 --- a/nextjs-app/lib/schoolSections.ts +++ b/nextjs-app/lib/schoolSections.ts @@ -9,7 +9,7 @@ import type { School, SchoolResult, AbsenceData, SchoolCensus, - OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance, + OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, } from './types'; import { isSpecialSchool } from './utils'; @@ -119,6 +119,9 @@ export function computeSchoolFlags({ export interface NavItemsInput { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; + /** A published cut-off distance renders the Admissions section on its own, + * so it has to open the nav entry too. */ + admissionDistance: SchoolAdmissionDistance | null; yearlyDataLength: number; } @@ -133,7 +136,7 @@ export interface NavItemsInput { */ export function buildNavItems( flags: SchoolFlags, - { ofsted, admissions, yearlyDataLength }: NavItemsInput, + { ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput, ): NavItem[] { const navItems: NavItem[] = []; if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); @@ -143,7 +146,7 @@ export function buildNavItems( label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs', }); } - if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); + if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' }); 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' }); @@ -213,12 +216,12 @@ export function computeSecondaryFlags({ */ export function buildSecondaryNavItems( flags: SecondaryFlags, - { ofsted, admissions, yearlyDataLength }: NavItemsInput, + { ofsted, admissions, admissionDistance, 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) navItems.push({ id: 'admissions', label: 'Admissions' }); + if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' }); 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' }); diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 5778f06..32ff142 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -163,6 +163,30 @@ export interface SchoolAdmissions { } +/** + * Last distance offered — how far from the school the last child to be given a + * place that year lived. + * + * Not a catchment: it is the outcome of one year's applications and moves every + * year. Only published by the local authorities that choose to, so most schools + * have none of this at all. + */ +export interface SchoolAdmissionDistance { + /** Entry year the cut-off belongs to — always shown alongside the figure. */ + year: number; + /** Straight-line distance in metres. */ + distance_m: number | null; + /** + * Admission routes collapsed into this figure (ability bands, or separate + * reception and junior intakes). Above 1 the distance is the furthest of + * several, which the page has to say out loud. + */ + route_count: number | null; + la_name: string | null; + /** The unit the council published in ("miles", "meters", "km"). */ + distance_unit_raw: string | null; +} + export interface SchoolDeprivation { lsoa_code: string | null; idaci_score: number | null; @@ -330,6 +354,8 @@ 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; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; } diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index fcbaf41..45f3e78 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -502,6 +502,54 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri } } +const METRES_PER_MILE = 1609.344; + +/** + * Format an admission cut-off distance. + * + * 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. + * + * Below 100 m the miles figure stops carrying information ("0.03 miles"), so + * the two swap round and metres lead instead. + */ +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`, + secondary: metres < 1000 + ? `${Math.round(metres / 10) * 10} m` + : `${(metres / 1000).toFixed(1)} km`, + }; +} + +/** + * The entry point a cut-off belongs to, phrased the way councils phrase it. + * + * The distance year is a plain entry year (2025 = the September 2025 intake), + * not the six-digit academic year EES uses, so formatAcademicYear would render + * it as "2025/26" and invite the reader to think of it as a school year rather + * than an application round. + */ +export function formatEntryYear(year: number | null | undefined): string { + if (year == null) return ''; + return `September ${year}`; +} + export function formatAcademicYear(year: number | null | undefined): string { if (year == null) return ''; const s = year.toString(); diff --git a/pipeline/dags/school_data_pipeline.py b/pipeline/dags/school_data_pipeline.py index 0278b5d..82ad099 100644 --- a/pipeline/dags/school_data_pipeline.py +++ b/pipeline/dags/school_data_pipeline.py @@ -224,3 +224,68 @@ with DAG( ) extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci + + +# ── Annual DAG (Last distance offered) ──────────────────────────────── + +with DAG( + dag_id="school_data_annual_distance", + default_args=default_args, + description="Last distance offered (LA admission cut-offs) extraction and transform", + # Councils publish on allocation day (March for secondary, April for + # primary) and each one on its own timetable, so there is no date worth + # scheduling against. Triggered manually after a collection run refreshes + # the CSV. + schedule=None, + start_date=datetime(2025, 1, 1), + catchup=False, + tags=["school-compare", "annual"], +) as annual_distance_dag: + + extract_distance = BashOperator( + task_id="extract_distance", + bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres", + ) + + # Coverage is the thing that silently rots here: the CSV is assembled by + # hand from council publications, so a collection run that half-failed + # produces a valid file with a fraction of the schools in it. A row count + # alone would not catch that — losing an entire local authority leaves the + # total looking healthy — so the floor is checked on distinct LAs too. + validate_distance = BashOperator( + task_id="validate_distance", + bash_command=f""" + cd {PIPELINE_DIR} && python -c " +import psycopg2, os, sys +conn = psycopg2.connect( + host=os.environ.get('PG_HOST', 'localhost'), + port=os.environ.get('PG_PORT', '5432'), + user=os.environ.get('PG_USER', 'postgres'), + password=os.environ.get('PG_PASSWORD', 'postgres'), + dbname=os.environ.get('PG_DATABASE', 'school_compare'), +) +cur = conn.cursor() +cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered') +rows, urns, las = cur.fetchone() +conn.close() +print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities') +if rows < 7000 or urns < 3000 or las < 45: + print('ERROR: distance extract is short of expected coverage ' + '(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr) + sys.exit(1) +print('Validation passed') +" + """, + ) + + dbt_build_distance = BashOperator( + task_id="dbt_build", + bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+", + ) + + invalidate_cache_distance = BashOperator( + task_id="invalidate_cache", + bash_command=INVALIDATE_CACHE_CMD, + ) + + extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance diff --git a/pipeline/meltano.yml b/pipeline/meltano.yml index 856ac86..65c8199 100644 --- a/pipeline/meltano.yml +++ b/pipeline/meltano.yml @@ -67,6 +67,21 @@ plugins: pip_url: ./plugins/extractors/tap-uk-idaci executable: tap-uk-idaci + # Last distance offered — admission cut-offs collected from LA + # publications. There is no national source for this, so the URL points at + # the collected CSV rather than at a government endpoint (same pattern as + # the legacy KS2/KS4 URLs above). + - name: tap-uk-school-distance + namespace: uk_school_distance + pip_url: ./plugins/extractors/tap-uk-school-distance + executable: tap-uk-school-distance + settings: + - name: download_url + kind: string + description: URL of the collected last-distance-offered CSV + config: + download_url: http://10.0.1.224:8081/filebrowser/api/public/dl/UU_oEqVr?inline=true + loaders: - name: target-postgres variant: meltanolabs diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml b/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml new file mode 100644 index 0000000..b363be1 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tap-uk-school-distance" +version = "0.1.0" +description = "Singer tap for last-distance-offered admission cut-offs collected from LA admissions publications" +requires-python = ">=3.10" +dependencies = [ + "singer-sdk~=0.53", + "requests>=2.31", +] + +[project.scripts] +tap-uk-school-distance = "tap_uk_school_distance.tap:TapUKSchoolDistance.cli" diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py new file mode 100644 index 0000000..c4c3617 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py @@ -0,0 +1 @@ +"""tap-uk-school-distance: Singer tap for LA "last distance offered" admission cut-offs.""" diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py new file mode 100644 index 0000000..2445141 --- /dev/null +++ b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py @@ -0,0 +1,135 @@ +"""Singer tap for "last distance offered" school admission cut-offs. + +Unlike every other tap in this project, the upstream here is not a single +government endpoint. There is no national dataset of admission cut-off +distances: each local authority publishes its own, in its own format +(PDF booklets, XLSX allocation tables, HTML pages), and only some publish +at all. The CSV this tap reads is the output of that collection work — +one row per school per year per admission route, already normalised to +metres in `distance_m`. + +The tap deliberately does no cleaning. Implausible values (0.0 miles, +1-metre cut-offs, a 533-mile outlier) are loaded verbatim into `raw` and +filtered in `stg_school_distance`, so the raw table stays a faithful +record of what the councils published and the plausibility rules live in +one reviewable place. +""" + +from __future__ import annotations + +import csv +import io + +import requests +from singer_sdk import Stream, Tap +from singer_sdk import typing as th + +REQUEST_TIMEOUT = 300 + + +class SchoolDistanceStream(Stream): + """Stream: last distance offered, one row per school × year × admission route.""" + + name = "school_distance_offered" + # school_name is part of the key, not decoration: where a school admits + # through several routes the route is encoded in the name ("… Band 1", + # "… Band 2"), and (urn, year) alone is not unique. Verified against the + # source: (urn, year) collides on 118 keys, (urn, year, school_name) on + # none. Without school_name in the key a reload would silently drop the + # other bands. + primary_keys = ["urn", "year", "school_name"] + replication_key = None + + schema = th.PropertiesList( + th.Property("urn", th.IntegerType, required=True), + th.Property("year", th.IntegerType, required=True), + th.Property("school_name", th.StringType, required=True), + th.Property("la_code", th.IntegerType), + th.Property("la_name", th.StringType), + # The figure as the council published it, before unit conversion — + # kept so a surprising metre value can be traced back to "0.31 miles" + # without reopening the source PDF. + th.Property("distance_value_raw", th.NumberType), + th.Property("distance_unit_raw", th.StringType), + th.Property("distance_m", th.NumberType), + # Path of the council publication the row was read from. This is the + # provenance trail for a figure we cannot re-derive from an API. + th.Property("source_file", th.StringType), + ).to_dict() + + @staticmethod + def _num(value: str) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _int(value: str) -> int | None: + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + def get_records(self, context): + url = self.config["download_url"] + self.logger.info("Downloading school distance data from %s", url) + + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + + reader = csv.DictReader(io.StringIO(resp.text)) + + yielded = 0 + skipped = 0 + for row in reader: + urn = self._int(row.get("urn")) + year = self._int(row.get("year")) + name = (row.get("school_name") or "").strip() + + # Every part of the primary key must be present, or the load is + # not idempotent. One row in the source has a blank year. + if urn is None or year is None or not name: + skipped += 1 + continue + + yield { + "urn": urn, + "year": year, + "school_name": name, + "la_code": self._int(row.get("la_code")), + "la_name": (row.get("la_name") or "").strip() or None, + "distance_value_raw": self._num(row.get("distance_value_raw")), + "distance_unit_raw": (row.get("distance_unit_raw") or "").strip() or None, + "distance_m": self._num(row.get("distance_m")), + "source_file": (row.get("source_file") or "").strip() or None, + } + yielded += 1 + + self.logger.info( + "Yielded %d distance records (%d skipped: missing urn, year or school name)", + yielded, + skipped, + ) + + +class TapUKSchoolDistance(Tap): + """Singer tap for LA-published admission cut-off distances.""" + + name = "tap-uk-school-distance" + + config_jsonschema = th.PropertiesList( + th.Property( + "download_url", + th.StringType, + required=True, + description="URL of the collected last-distance-offered CSV", + ), + ).to_dict() + + def discover_streams(self): + return [SchoolDistanceStream(self)] + + +if __name__ == "__main__": + TapUKSchoolDistance.cli() diff --git a/pipeline/transform/models/marts/_marts_schema.yml b/pipeline/transform/models/marts/_marts_schema.yml index 9869885..1aa86c1 100644 --- a/pipeline/transform/models/marts/_marts_schema.yml +++ b/pipeline/transform/models/marts/_marts_schema.yml @@ -156,6 +156,22 @@ models: - name: cross_la_offers - name: total_offers + - name: fact_admission_distance + description: > + Last distance offered — one row per URN per year. Where a school admits + through several routes (ability bands, separate reception/junior + intakes) the figure is the furthest of them and route_count says how + many were collapsed. + columns: + - name: urn + tests: [not_null] + - name: year + tests: [not_null] + - name: distance_m + tests: [not_null] + - name: route_count + tests: [not_null] + - name: fact_finance description: School financial data — one row per URN per year columns: diff --git a/pipeline/transform/models/marts/fact_admission_distance.sql b/pipeline/transform/models/marts/fact_admission_distance.sql new file mode 100644 index 0000000..dc413c4 --- /dev/null +++ b/pipeline/transform/models/marts/fact_admission_distance.sql @@ -0,0 +1,50 @@ +-- Mart: last distance offered — one row per URN per year. +-- +-- Collapses the per-route grain of stg_school_distance to the grain the site +-- serves. Where a school admits through several routes the figure shown is the +-- FURTHEST of them, which is what "last distance offered" literally means: the +-- distance of the last place the school gave out that year. +-- +-- route_count travels with it so the page can say so. A parent looking at a +-- banded secondary needs to know that 2.4 miles is the widest of four bands +-- and not the number that applies to their own child — the count is what makes +-- that sentence possible, and dropping it would make the figure quietly +-- optimistic. +-- +-- Kept separate from fact_admissions rather than joined into it. That mart is +-- EES-derived and near-complete for England; this one comes from 57 local +-- authorities that publish cut-offs at all, so folding it in would add mostly +-- null columns to every school in the country and tie two very different +-- refresh cadences together. + +with routes as ( + select * from {{ ref('stg_school_distance') }} +), + +per_year as ( + select + urn, + year, + max(distance_m) as distance_m, + count(*) as route_count, + min(la_code) as la_code, + min(la_name) as la_name, + -- The unit the council actually published in. Where routes disagree + -- (they do not in the current data) the alphabetically first is taken + -- rather than an arbitrary row, so the mart is deterministic. + min(distance_unit_raw) as distance_unit_raw, + min(source_file) as source_file + from routes + group by urn, year +) + +select + urn, + year, + distance_m, + route_count, + la_code, + la_name, + distance_unit_raw, + source_file +from per_year diff --git a/pipeline/transform/models/staging/_stg_sources.yml b/pipeline/transform/models/staging/_stg_sources.yml index b390790..6b294c4 100644 --- a/pipeline/transform/models/staging/_stg_sources.yml +++ b/pipeline/transform/models/staging/_stg_sources.yml @@ -61,3 +61,11 @@ sources: - name: idaci description: Income Deprivation Affecting Children Index lookups + + - name: school_distance_offered + description: > + Last distance offered (admission cut-offs) transcribed from local + authority admissions publications. One row per school × year × + admission route; no national source exists, so coverage is limited to + the LAs that publish. Loaded verbatim — implausible values are + filtered in stg_school_distance, not here. diff --git a/pipeline/transform/models/staging/stg_school_distance.sql b/pipeline/transform/models/staging/stg_school_distance.sql new file mode 100644 index 0000000..705ecdd --- /dev/null +++ b/pipeline/transform/models/staging/stg_school_distance.sql @@ -0,0 +1,55 @@ +-- Staging model: "last distance offered" admission cut-offs. +-- +-- One row per school × year × admission route. A school admits through +-- several routes when it bands by ability (Islington publishes four bands per +-- secondary) or takes more than one intake (Gloucestershire publishes +-- reception, junior and secondary allocations separately). The route is +-- carried in school_name, which is why it forms part of the grain here and is +-- collapsed only in fact_admission_distance. +-- +-- PLAUSIBILITY BAND (25 m – 25 km) +-- +-- The source is transcribed from council PDFs, spreadsheets and web pages, and +-- a minority of rows are transcription or unit errors rather than real +-- cut-offs. Three failure modes, all present: +-- +-- * 0.0 miles (34 rows, mostly Staffordshire). Published where a school +-- filled on a higher criterion so distance was never reached — a real +-- fact, but "0 metres" read as a cut-off would tell a parent the opposite +-- of the truth. +-- * 1–10 metre values (72 rows, e.g. Hillingdon). A cut-off inside the +-- school gate is not physically possible; these are unit slips. +-- * Values above 25 km (~240 rows), topping out at 533 miles. Genuine rural +-- secondary cut-offs reach roughly 12–15 miles, so the ceiling keeps those +-- while cutting figures that are visibly wrong. +-- +-- Together these are ~4% of rows. They are loaded verbatim into raw and +-- excluded here, so the rule is reviewable in one place and the raw table +-- stays a faithful record of what each council published. + +{% set min_plausible_m = 25 %} +{% set max_plausible_m = 25000 %} + +with source as ( + select * from {{ source('raw', 'school_distance_offered') }} +), + +renamed as ( + select + cast(urn as integer) as urn, + cast(year as integer) as year, + trim(school_name) as admission_route, + cast(la_code as integer) as la_code, + la_name, + cast(distance_value_raw as numeric) as distance_value_raw, + distance_unit_raw, + cast(distance_m as numeric) as distance_m, + source_file + from source + where urn is not null + and year is not null + and distance_m is not null + and cast(distance_m as numeric) between {{ min_plausible_m }} and {{ max_plausible_m }} +) + +select * from renamed diff --git a/pipeline/transform/tests/assert_admission_distance_plausible.sql b/pipeline/transform/tests/assert_admission_distance_plausible.sql new file mode 100644 index 0000000..45f377c --- /dev/null +++ b/pipeline/transform/tests/assert_admission_distance_plausible.sql @@ -0,0 +1,27 @@ +-- Custom test: every published cut-off distance is inside the plausibility +-- band, and there is exactly one row per school per year. +-- +-- The band is enforced in stg_school_distance; this asserts it survived the +-- max() collapse in the mart. A failure means either the staging filter was +-- edited without updating this test, or the grain broke — both of which put a +-- visibly wrong number ("533 miles") on a live school page, which is the one +-- outcome this feature cannot afford. + +with out_of_band as ( + select urn, year, distance_m, 'out of plausibility band' as problem + from {{ ref('fact_admission_distance') }} + where distance_m is null + or distance_m < 25 + or distance_m > 25000 +), + +duplicated as ( + select urn, year, max(distance_m) as distance_m, 'duplicate urn/year' as problem + from {{ ref('fact_admission_distance') }} + group by urn, year + having count(*) > 1 +) + +select * from out_of_band +union all +select * from duplicated