diff --git a/.gitignore b/.gitignore index 4f969df..8c128e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ pipeline/transform/.user.yml # Playwright MCP scratch output (screenshots, console logs, page snapshots) .playwright-mcp/ + +# Playwright run artefacts written when the suite is run from the repo root +test-results/ diff --git a/backend/app.py b/backend/app.py index 20d9570..f819bf6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -633,6 +633,7 @@ async def get_school_details(request: Request, urn: int): "admissions": supplementary.get("admissions"), "admissions_history": supplementary.get("admissions_history") or [], "admission_distance": supplementary.get("admission_distance"), + "admission_distance_history": supplementary.get("admission_distance_history") or [], "sen_detail": supplementary.get("sen_detail"), "phonics": supplementary.get("phonics"), "deprivation": supplementary.get("deprivation"), diff --git a/backend/data_loader.py b/backend/data_loader.py index 0640b76..b27873a 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -771,6 +771,7 @@ def _empty_supplementary() -> dict: "admissions": None, "admissions_history": [], "admission_distance": None, + "admission_distance_history": [], "sen_detail": None, "phonics": None, "deprivation": None, @@ -849,25 +850,29 @@ 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. + # Last distance offered — all years per URN, oldest first, with the latest + # also exposed on its own. # - # 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. + # The history was deliberately withheld at first: coverage is ragged (a + # school may have 2021 and 2026 and nothing between), and a plain series + # would draw a trend line straight through gaps that are absences of + # publication rather than absences of a cut-off. It is served now because + # the client distinguishes those gaps explicitly — see cutoffYearRows in + # lastDistanceOffered.ts, which classifies every year in the range and + # breaks the line rather than interpolating across it. def _admission_distance(): rows = ( db.query(FactAdmissionDistance) .filter(FactAdmissionDistance.urn.in_(urns)) - .order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc()) + .order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.asc()) .all() ) - seen = set() + history: dict = {urn: [] for urn in urns} for d in rows: - if d.urn in seen: - continue - seen.add(d.urn) - result[d.urn]["admission_distance"] = _admission_distance_dict(d) + history[d.urn].append(_admission_distance_dict(d)) + for urn, rows_for_urn in history.items(): + result[urn]["admission_distance_history"] = rows_for_urn + result[urn]["admission_distance"] = rows_for_urn[-1] if rows_for_urn else None _safe(_admission_distance) # Deprivation — one row per URN. diff --git a/backend/tests/test_supplementary_batch.py b/backend/tests/test_supplementary_batch.py index 2bdbdcd..c48d11f 100644 --- a/backend/tests/test_supplementary_batch.py +++ b/backend/tests/test_supplementary_batch.py @@ -8,8 +8,31 @@ from backend import data_loader from backend.data_loader import get_supplementary_data_batch +def _sort_key(criterion): + """(column name, descending) for a SQLAlchemy order_by argument. + + A bare column (Model.year) arrives as an InstrumentedAttribute carrying + .key; Model.year.desc() wraps it in a UnaryExpression whose column sits on + .element. + """ + name = getattr(criterion, "key", None) + if name is not None: + return name, False + element = getattr(criterion, "element", None) + name = getattr(element, "key", None) + return name, "DESC" in str(criterion).upper() + + class _FakeQuery: - """Records that a query ran and serves canned rows filtered by an in-list.""" + """Records that a query ran and serves canned rows filtered by an in-list. + + order_by is honoured rather than ignored. The batch loader picks a row per + URN by position — first for "latest Ofsted", last for "latest cut-off + distance" — which is only correct because the database returned them + sorted. A double that drops the ORDER BY makes those picks depend on + fixture insertion order instead, so the test would pass with the sort + reversed or removed and prove nothing about the query. + """ def __init__(self, recorder, model_name, rows): self._rec = recorder @@ -19,7 +42,20 @@ class _FakeQuery: def filter(self, *args, **kwargs): return self - def order_by(self, *args, **kwargs): + def order_by(self, *criteria): + for crit in reversed(criteria): # reversed = stable multi-key sort + name, descending = _sort_key(crit) + if not name: + continue + values = [getattr(r, name, None) for r in self._rows] + # Only sort on plainly comparable values. Some fixtures stand dates + # up as namespace objects, which raise on <; leaving those in their + # given order matches what the real query would produce for them. + if not all(isinstance(v, (int, float, str)) for v in values): + continue + self._rows = sorted( + self._rows, key=lambda r: getattr(r, name), reverse=descending + ) return self def all(self): @@ -115,8 +151,10 @@ 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. + # Cut-off distance: full history oldest-first, with the newest year also + # exposed on its own. The fixture rows are deliberately out of order, so + # this only passes if the query's ORDER BY is doing the work. + assert [r["year"] for r in out[1]["admission_distance_history"]] == [2024, 2025, 2026] 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 @@ -133,3 +171,4 @@ def test_single_wrapper_matches_batch(monkeypatch): assert single["ofsted"]["overall_effectiveness"] == 2 assert single["admissions_history"] == [] assert single["admission_distance"] is None + assert single["admission_distance_history"] == [] diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 07c59a8..1d36ffb 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1226,14 +1226,21 @@ const CUTOFF_CANDIDATE_URNS = [ 101099, 100553, 102574, 100769, // mixed ]; -async function schoolWithCutoff(page: Page) { +async function schoolWithCutoff(page: Page, minPublishedYears = 1) { 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 }; - } + if (body?.admission_distance?.distance_m == null) continue; + const published = (body.admission_distance_history ?? []) + .filter((h: { distance_m?: number | null }) => h.distance_m != null); + if (published.length < minPublishedYears) continue; + return { + urn, + distance: body.admission_distance, + history: published, + phase: body.school_info?.phase, + }; } return null; } @@ -1307,3 +1314,106 @@ test('no school page shows an implausible cut-off distance', async ({ page }) => } test.skip(checked === 0, 'no cut-off distances available to check yet'); }); + +// ── The Distance view, map and postcode check ──────────────────────────── + +test('a school with several published years gets the Distance view', async ({ page }) => { + const found = await schoolWithCutoff(page, 2); + test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); + + await page.goto(`/school/${found!.urn}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + + const isSecondary = /secondary/i.test(found!.phase ?? ''); + if (!isSecondary) { + // Primary pages carry a segmented control; the detail sits behind it. + const distanceTab = page.getByRole('button', { name: 'Distance' }); + await expect(distanceTab).toBeVisible(); + await distanceTab.click(); + await expect(distanceTab).toHaveAttribute('aria-pressed', 'true'); + } + + // Every published year must appear as a row, whichever template rendered it. + for (const h of found!.history as { year: number }[]) { + await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible(); + } +}); + +test('the year table never leaves a gap unexplained', async ({ page }) => { + // A blank row would tell a parent nothing; each gap must name its reason. + const found = await schoolWithCutoff(page, 2); + test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); + + await page.goto(`/school/${found!.urn}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + if (!/secondary/i.test(found!.phase ?? '')) { + await page.getByRole('button', { name: 'Distance' }).click(); + } + + const statuses = await page.locator('[class*="cutoffPill"]').allTextContents(); + expect(statuses.length).toBeGreaterThan(0); + for (const s of statuses) { + expect(s.trim(), 'every row carries a status').not.toBe(''); + } + // The stronger claim the data cannot support must never appear. + await expect(page.getByText(/every applicant was offered/i)).toHaveCount(0); +}); + +test('the postcode check answers with a distance and per-year verdicts', async ({ page }) => { + const found = await schoolWithCutoff(page, 2); + test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); + + await page.goto(`/school/${found!.urn}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + if (!/secondary/i.test(found!.phase ?? '')) { + await page.getByRole('button', { name: 'Distance' }).click(); + } + + const input = page.getByLabel('Your postcode'); + await expect(input).toBeVisible(); + await input.fill('SW1A 1AA'); + await page.getByRole('button', { name: 'Check' }).click(); + + // Either a verdict or a plain error — never a silent no-op. + const result = page.getByRole('status'); + const error = page.getByRole('alert'); + await expect(result.or(error)).toBeVisible({ timeout: 20_000 }); + + if (await result.isVisible()) { + await expect(result).toContainText(/mile|m\b/); + } +}); + +test('the postcode check states its limits before it is used', async ({ page }) => { + // The caveat is not revealed with the answer — it is there while the parent + // is deciding whether to trust the answer at all. + const found = await schoolWithCutoff(page, 2); + test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); + + await page.goto(`/school/${found!.urn}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + if (!/secondary/i.test(found!.phase ?? '')) { + await page.getByRole('button', { name: 'Distance' }).click(); + } + + await expect(page.getByText(/An indication only/)).toBeVisible(); + await expect(page.getByText(/not a catchment boundary/)).toBeVisible(); +}); + +test('the distance section never scrolls the page sideways', async ({ page }) => { + const found = await schoolWithCutoff(page, 2); + test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/school/${found!.urn}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + if (!/secondary/i.test(found!.phase ?? '')) { + await page.getByRole('button', { name: 'Distance' }).click(); + } + + // The year table is deliberately wider than a phone; its own wrapper has to + // absorb that, or the whole page slides under the reader's thumb. + const overflow = await page.evaluate(() => + document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1); +}); diff --git a/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx b/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx new file mode 100644 index 0000000..ad8c253 --- /dev/null +++ b/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx @@ -0,0 +1,144 @@ +/** + * The postcode check. + * + * This is the one place on the site that answers a question about a specific + * family rather than about a school, so the tests here are mostly about what it + * refuses to say: no verdict without a published figure, and no verdict at all + * when the margin is inside the error of a postcode centroid. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CutoffMapPanel } from '@/components/school/CutoffMapPanel'; +import { cutoffYearRows, CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered'; +import type { School, SchoolAdmissionDistance } from '@/lib/types'; + +// Leaflet needs a real layout box and network tiles; neither exists in jsdom. +// The panel's logic is independent of it, so the map is stubbed out. +jest.mock('@/components/LeafletCutoffMapInner', () => ({ + __esModule: true, + default: () =>
, +})); + +const mockGeocode = jest.fn(); +jest.mock('@/lib/api', () => ({ + ...jest.requireActual('@/lib/api'), + geocodePostcode: (pc: string) => mockGeocode(pc), +})); + +const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School; + +const d = (year: number, distance_m: number): SchoolAdmissionDistance => ({ + year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles', +}); + +/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */ +function northOf(metres: number) { + return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! }; +} + +function renderPanel(history: SchoolAdmissionDistance[], admissions: { year: number; oversubscribed?: boolean }[] = []) { + return render( + , + ); +} + +async function check(postcode: string) { + fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } }); + fireEvent.click(screen.getByRole('button', { name: 'Check' })); +} + +beforeEach(() => mockGeocode.mockReset()); + +describe('CutoffMapPanel', () => { + it('renders nothing without a published figure to draw', () => { + const { container } = renderPanel([]); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the school has no coordinates', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('rejects a malformed postcode without calling the geocoder', async () => { + renderPanel([d(2024, 800)]); + await check('not a postcode'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/); + expect(mockGeocode).not.toHaveBeenCalled(); + }); + + it('reports a home clearly inside every published year', async () => { + mockGeocode.mockResolvedValue(northOf(200)); + renderPanel([d(2022, 1000), d(2024, 800)]); + await check('SE23 3NA'); + + expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/); + }); + + it('reports a home clearly outside every published year', async () => { + mockGeocode.mockResolvedValue(northOf(5000)); + renderPanel([d(2022, 1000), d(2024, 800)]); + await check('SE23 3NA'); + + expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/); + }); + + it('declines to call a result that sits inside the measurement error', async () => { + // The home is nominally inside 2024's 800 m cut-off, but only by half the + // uncertainty band — which a postcode centroid cannot resolve. + mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2)); + renderPanel([d(2024, 800)]); + await check('SE23 3NA'); + + const result = await screen.findByRole('status'); + expect(result).toHaveTextContent(/too close to call/); + expect(result).not.toHaveTextContent(/inside the cut-off in all/); + }); + + it('counts an unpublished year as unknown rather than as a pass', async () => { + mockGeocode.mockResolvedValue(northOf(200)); + renderPanel([d(2022, 1000), d(2024, 800)], [{ year: 2023, oversubscribed: false }]); + await check('SE23 3NA'); + + const result = await screen.findByRole('status'); + expect(result).toHaveTextContent(/inside the cut-off in all 2 years/); + expect(result).toHaveTextContent(/no published figure/); + }); + + it('surfaces a postcode the geocoder cannot find', async () => { + mockGeocode.mockResolvedValue(null); + renderPanel([d(2024, 800)]); + await check('ZZ99 9ZZ'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/); + }); + + it('recovers from a geocoder failure instead of leaving a stale verdict', async () => { + mockGeocode.mockResolvedValue(northOf(200)); + renderPanel([d(2024, 800)]); + await check('SE23 3NA'); + await screen.findByRole('status'); + + mockGeocode.mockRejectedValue(new Error('network')); + await check('SE23 3NB'); + + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/); + }); + + it('always states the limits of the check', async () => { + renderPanel([d(2024, 800)]); + // Present before any postcode is entered, not revealed with the answer. + expect(screen.getByText(/An indication only/)).toBeInTheDocument(); + // The two caveats divide the work: the map says the rings are not a + // boundary, the check says the measurement may not be a straight line. + expect(screen.getByText(/not a catchment boundary/)).toBeInTheDocument(); + expect(screen.getByText(/walking route rather than a straight line/)).toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx index 7472c5f..375883f 100644 --- a/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx +++ b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx @@ -8,7 +8,7 @@ * sentence appears only when it is actually true. */ -import { screen } from '@testing-library/react'; +import { screen, fireEvent } from '@testing-library/react'; import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail'; import { primaryFixture, secondaryFixture } from '../support/schoolFixtures'; import type { SchoolAdmissionDistance } from '@/lib/types'; @@ -88,3 +88,122 @@ describe('secondary detail page', () => { expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument(); }); }); + +// ── The Distance view ────────────────────────────────────────────────── + +const history = (pts: [number, number][]): SchoolAdmissionDistance[] => + pts.map(([year, distance_m]) => ({ + year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles', + })); + +describe('primary Distance view', () => { + it('appears once there are two or more published years', () => { + renderSchoolDetail({ + ...primaryFixture, + admissionDistance: cutoff({ distance_m: 700, year: 2024 }), + admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]), + }); + + expect(screen.getByRole('button', { name: 'Distance' })).toBeInTheDocument(); + }); + + it('stays hidden on a single published year', () => { + // One point is a fact, not a history; a chart of it invites a trend reading + // that is not there. + renderSchoolDetail({ + ...primaryFixture, + admissionDistance: cutoff({ distance_m: 700, year: 2024 }), + admissionDistanceHistory: history([[2024, 700]]), + }); + + expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument(); + }); + + it('lists every year in the span, including the ones with no figure', () => { + renderSchoolDetail({ + ...primaryFixture, + admissionDistance: cutoff({ distance_m: 700, year: 2024 }), + admissionDistanceHistory: history([[2021, 1000], [2024, 700]]), + }); + + // The view is server-rendered but starts hidden behind its tab, and role + // queries skip hidden subtrees — so open it the way a reader would. + fireEvent.click(screen.getByRole('button', { name: 'Distance' })); + + // 2022 and 2023 were never published but must still appear as rows, or the + // gap in the chart has nothing explaining it. + for (const year of ['2021', '2022', '2023', '2024']) { + expect(screen.getByRole('rowheader', { name: year })).toBeInTheDocument(); + } + expect(screen.getAllByText('Not published').length).toBe(2); + }); + + it('never claims everyone was offered a place', () => { + // fact_admissions.oversubscribed is about first preferences only, so the + // stronger claim is not supported by the data behind it. + renderSchoolDetail({ + ...primaryFixture, + admissionDistance: cutoff({ distance_m: 700, year: 2024 }), + admissionDistanceHistory: history([[2021, 1000], [2024, 700]]), + admissionsHistory: [ + ...primaryFixture.admissionsHistory, + { year: 2022, oversubscribed: false, places_offered: 60 }, + ], + }); + + expect(screen.getByText(/Places available on first preferences/)).toBeInTheDocument(); + expect(screen.queryByText(/every applicant/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/all offered/i)).not.toBeInTheDocument(); + }); + + it('withholds a trend reading while the record is thin', () => { + renderSchoolDetail({ + ...primaryFixture, + admissionDistance: cutoff({ distance_m: 700, year: 2024 }), + admissionDistanceHistory: history([[2023, 800], [2024, 700]]), + }); + + expect(screen.getByText(/too few to read as a trend/)).toBeInTheDocument(); + expect(screen.queryByText(/Across \d+ published years/)).not.toBeInTheDocument(); + }); +}); + +describe('secondary Distance detail', () => { + it('renders inline, with no extra tab, once there are two published years', () => { + renderSecondarySchoolDetail({ + ...secondaryFixture, + admissionDistance: cutoff({ distance_m: 3472.96 }), + admissionDistanceHistory: history([[2023, 3800], [2024, 3472.96]]), + }); + + expect(screen.getByText(/Last distance offered, by year/)).toBeInTheDocument(); + expect(screen.getByRole('rowheader', { name: '2023' })).toBeInTheDocument(); + }); + + it('explains a selective school by how it admits rather than as missing data', () => { + renderSecondarySchoolDetail({ + ...secondaryFixture, + schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' }, + admissionDistance: null, + admissionDistanceHistory: [], + }); + + expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument(); + expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument(); + }); + + it('reads a consistently undersubscribed school as good news', () => { + renderSecondarySchoolDetail({ + ...secondaryFixture, + admissionDistance: null, + admissionDistanceHistory: [], + admissionsHistory: [ + { year: 2022, oversubscribed: false }, + { year: 2023, oversubscribed: false }, + { year: 2024, oversubscribed: false }, + ], + }); + + expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts index 0de75d0..0059303 100644 --- a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts +++ b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts @@ -8,7 +8,10 @@ */ import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; -import { describeCutoff } from '@/components/school/lastDistanceOffered'; +import { + describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote, + describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M, +} from '@/components/school/lastDistanceOffered'; import type { SchoolAdmissionDistance } from '@/lib/types'; const distance = (over: Partial = {}): SchoolAdmissionDistance => ({ @@ -78,3 +81,151 @@ describe('describeCutoff', () => { expect(describeCutoff(distance({ distance_m: null }))).toBeNull(); }); }); + +// ── Year-by-year history ─────────────────────────────────────────────── + +const d = (year: number, distance_m: number | null, route_count = 1): SchoolAdmissionDistance => ({ + year, distance_m, route_count, la_name: 'Camden', distance_unit_raw: 'miles', +}); + +describe('entryYearOf', () => { + it('reduces EES academic codes and plain entry years to one key', () => { + // Without this the two histories never join and every year looks unpublished. + expect(entryYearOf(202425)).toBe(2024); + expect(entryYearOf(2024)).toBe(2024); + }); +}); + +describe('cutoffYearRows', () => { + it('emits a row for every year in the span, including the empty ones', () => { + const rows = cutoffYearRows([d(2021, 900), d(2024, 700)]); + expect(rows.map((r) => r.year)).toEqual([2024, 2023, 2022, 2021]); + expect(rows.map((r) => r.status)).toEqual([ + 'published', 'not-published', 'not-published', 'published', + ]); + }); + + it('separates "nothing published" from "was not oversubscribed"', () => { + // The two look identical in the distance data and mean opposite things. + const rows = cutoffYearRows( + [d(2021, 900), d(2023, 700)], + [{ year: 2022, oversubscribed: false }], + ); + expect(rows.find((r) => r.year === 2022)!.status).toBe('not-oversubscribed'); + }); + + it('matches EES six-digit years against plain cut-off years', () => { + const rows = cutoffYearRows( + [d(2023, 700)], + [{ year: 202324, places_offered: 60, oversubscribed: true }], + ); + expect(rows.find((r) => r.year === 2023)!.placesOffered).toBe(60); + }); + + it('does not stretch the span back over admissions years with no cut-offs', () => { + // EES reaches back further than councils publish; padding the chart with a + // decade of blanks would bury the years that carry a figure. + const rows = cutoffYearRows( + [d(2024, 700)], + [{ year: 2015, oversubscribed: true }, { year: 2024, oversubscribed: true }], + ); + expect(rows.map((r) => r.year)).toEqual([2024]); + }); + + it('is safe when the backend does not send the field at all', () => { + expect(cutoffYearRows(undefined as never)).toEqual([]); + expect(cutoffYearRows([], [])).toEqual([]); + }); +}); + +describe('cutoffTrendSummary', () => { + const rowsFor = (pts: [number, number][]) => cutoffYearRows(pts.map(([y, m]) => d(y, m))); + + it('says nothing below four published points', () => { + expect(cutoffTrendSummary(rowsFor([[2022, 900], [2023, 800], [2024, 700]]))).toBeNull(); + }); + + it('names both endpoints and their years rather than passing a verdict', () => { + const s = cutoffTrendSummary(rowsFor([ + [2021, 1000], [2022, 900], [2023, 800], [2024, 500], + ])); + expect(s).toContain('2021'); + expect(s).toContain('2024'); + expect(s).toContain('tightened'); + }); + + it('does not call a small wobble a direction', () => { + const s = cutoffTrendSummary(rowsFor([ + [2021, 1000], [2022, 1010], [2023, 990], [2024, 1030], + ])); + expect(s).toContain('stayed broadly the same'); + }); +}); + +describe('cutoffCoverageNote', () => { + it('warns while the record is too thin to read as a trend', () => { + expect(cutoffCoverageNote(cutoffYearRows([d(2024, 700)]))).toContain('Only 1 year'); + expect(cutoffCoverageNote(cutoffYearRows([ + d(2021, 1000), d(2022, 900), d(2023, 800), d(2024, 700), + ]))).toBeNull(); + }); +}); + +describe('describeCutoffAbsence', () => { + it('explains a selective school by how it admits, not by missing data', () => { + const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' }); + expect(s).toContain('entrance test'); + expect(s).not.toContain('has not published'); + }); + + it('reads a consistently undersubscribed school as good news', () => { + const s = describeCutoffAbsence({ + localAuthority: 'Camden', + admissionsHistory: [ + { year: 2022, oversubscribed: false }, + { year: 2023, oversubscribed: false }, + { year: 2024, oversubscribed: false }, + ], + }); + expect(s).toContain('has not needed a distance cut-off'); + }); + + it('otherwise names the authority that would hold the figure', () => { + expect(describeCutoffAbsence({ localAuthority: 'Camden' })) + .toContain('Camden has not published'); + }); +}); + +describe('compareToCutoffs', () => { + const rows = cutoffYearRows( + [d(2022, 1000), d(2024, 800)], + [{ year: 2023, oversubscribed: false }], + ); + + it('marks a clearly nearer home as inside every comparable year', () => { + const r = compareToCutoffs(300, rows); + expect(r.insideCount).toBe(2); + expect(r.comparableCount).toBe(2); + expect(r.headline).toContain('inside the cut-off in all 2 years'); + }); + + it('marks a clearly further home as outside', () => { + const r = compareToCutoffs(4000, rows); + expect(r.insideCount).toBe(0); + expect(r.headline).toContain('outside the cut-off in every year'); + }); + + it('refuses to call a result inside the measurement error', () => { + // A postcode centroid covers several addresses; claiming a place on a 20 m + // margin would be inventing precision the inputs do not have. + const r = compareToCutoffs(800 - CUTOFF_UNCERTAINTY_M / 2, rows); + expect(r.years.find((y) => y.year === 2024)!.verdict).toBe('too-close'); + expect(r.detail).toContain('too close to call'); + }); + + it('never counts an unpublished year as a pass or a fail', () => { + const r = compareToCutoffs(300, rows); + expect(r.years.find((y) => y.year === 2023)!.verdict).toBe('unknown'); + expect(r.detail).toContain('no published figure'); + }); +}); diff --git a/nextjs-app/__tests__/support/renderSchoolDetail.tsx b/nextjs-app/__tests__/support/renderSchoolDetail.tsx index 9dc43bd..bc5b87b 100644 --- a/nextjs-app/__tests__/support/renderSchoolDetail.tsx +++ b/nextjs-app/__tests__/support/renderSchoolDetail.tsx @@ -45,6 +45,7 @@ export function renderSchoolDetail(fixture: any) { > @@ -72,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) { > diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 55a156d..b29ac71 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, admission_distance, deprivation, finance } = data; + const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, admission_distance_history, deprivation, finance } = data; // Redirect bare URN to canonical slug URL const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -229,7 +229,9 @@ export default async function SchoolPage({ params }: SchoolPageProps) { ofsted={ofsted ?? null} census={census ?? null} admissions={admissions ?? null} + admissionsHistory={admissions_history ?? []} admissionDistance={admission_distance ?? null} + admissionDistanceHistory={admission_distance_history ?? []} deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} @@ -252,6 +254,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { admissions={admissions ?? null} admissionsHistory={admissions_history ?? []} admissionDistance={admission_distance ?? null} + admissionDistanceHistory={admission_distance_history ?? []} deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} diff --git a/nextjs-app/components/CutoffTrendChart.tsx b/nextjs-app/components/CutoffTrendChart.tsx new file mode 100644 index 0000000..cf22a4b --- /dev/null +++ b/nextjs-app/components/CutoffTrendChart.tsx @@ -0,0 +1,123 @@ +'use client'; + +/** + * CutoffTrendChart + * The last distance offered, year by year. + * + * Two things this chart deliberately does not do. + * + * It does not join across gaps (`spanGaps: false`). A missing year is usually a + * year the authority published nothing, and a line drawn through it would + * assert a cut-off that was never measured. + * + * It does not plot the non-distance outcomes. A year in which the school was + * not oversubscribed has no distance — that is the point of it — and giving it + * a y-position would put a number on the axis that does not exist. The mockup + * floated such years at the top of the plot; here they are a gap in the line + * and a labelled row in the table underneath, which is where a reason can be + * stated in words rather than implied by a coordinate. + */ + +import { Line } from 'react-chartjs-2'; +import { ChartOptions } from 'chart.js'; +import '@/lib/chartSetup'; +import { useThemeTokens, alpha } from '@/lib/theme'; +import type { CutoffYearRow } from './school/lastDistanceOffered'; +import styles from './AdmissionsTrendChart.module.css'; + +const METRES_PER_MILE = 1609.344; + +export default function CutoffTrendChart({ rows }: { rows: CutoffYearRow[] }) { + // rows arrive newest-first; a time axis reads oldest-first. + const axis = [...rows].reverse(); + const published = axis.filter((r) => r.distanceM != null); + if (published.length < 2) return null; + + const labels = axis.map((r) => String(r.year)); + const values: (number | null)[] = axis.map((r) => + r.distanceM != null ? r.distanceM / METRES_PER_MILE : null, + ); + + const present = values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0); + const lastIdx = present[present.length - 1]; + + const numeric = values.filter((v): v is number => v != null); + const lo = Math.min(...numeric); + const hi = Math.max(...numeric); + // Headroom proportional to the spread, with a floor so a flat series does not + // collapse onto a single gridline and read as more precise than it is. + const pad = Math.max(0.05, (hi - lo) * 0.25); + const yMin = Math.max(0, lo - pad); + const yMax = hi + pad; + + const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens( + '--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card', + ); + + const options: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + layout: { padding: { top: 8 } }, + plugins: { + legend: { display: false }, + title: { display: false }, + tooltip: { + backgroundColor: cInverse, + titleColor: cInverseText, + bodyColor: cInverseText, + padding: 10, + titleFont: { size: 12 }, + bodyFont: { size: 12 }, + callbacks: { + label: (ctx) => + ctx.parsed.y == null ? '' : `Last distance offered: ${ctx.parsed.y.toFixed(2)} miles`, + }, + }, + }, + scales: { + y: { + min: yMin, + max: yMax, + grid: { color: cGrid }, + ticks: { + font: { size: 11 }, + maxTicksLimit: 5, + callback: (v) => `${Number(v).toFixed(2)} mi`, + }, + }, + x: { + grid: { display: false }, + ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 }, + }, + }, + }; + + const data = { + labels, + datasets: [ + { + label: 'Last distance offered', + data: values, + clip: false as const, + spanGaps: false, + borderColor: cBrand, + backgroundColor: alpha('--brand', 0.10), + borderWidth: 2.5, + tension: 0.3, + fill: true, + pointRadius: values.map((v, i) => (v == null ? 0 : i === lastIdx ? 5 : 3)), + pointBackgroundColor: cBrand, + pointBorderColor: cCard, + pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)), + pointHoverRadius: 6, + }, + ], + }; + + return ( +
+ +
+ ); +} diff --git a/nextjs-app/components/LeafletCutoffMapInner.tsx b/nextjs-app/components/LeafletCutoffMapInner.tsx new file mode 100644 index 0000000..8a469d8 --- /dev/null +++ b/nextjs-app/components/LeafletCutoffMapInner.tsx @@ -0,0 +1,169 @@ +/** + * LeafletCutoffMapInner + * The cut-off distances drawn around the school. + * + * L.circle takes a radius in metres and projects it properly, which is exactly + * what these rings are: a straight-line distance from the school. (L.circleMarker + * would take pixels and would silently stop meaning anything as the user zoomed.) + * + * The latest year is filled and labelled; earlier years are hairlines behind it, + * so a tightening cut-off reads as a set of shrinking circles without needing a + * legend to decode it. The map auto-fits the widest ring, so the whole history + * is in frame whatever the distances are. + */ + +'use client'; + +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; + +export interface CutoffRing { + year: number; + radiusM: number; + latest: boolean; +} + +interface Props { + lat: number; + lng: number; + rings: CutoffRing[]; + /** The parent's geocoded postcode, once they have entered one. */ + home: { lat: number; lng: number } | null; + interactive: boolean; +} + +const SCHOOL_PIN = ` +
+ + +
`; + +// A square rotated 45° — a different SHAPE from the school's circle, not just a +// different colour, so the two are still distinguishable to anyone who cannot +// separate the hues. +const HOME_PIN = ` +
+ +
`; + +function cssVar(name: string, fallback: string): string { + if (typeof window === 'undefined') return fallback; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) { + const elRef = useRef(null); + const mapRef = useRef(null); + const layersRef = useRef([]); + const zoomCtrlRef = useRef(null); + + useEffect(() => { + if (!elRef.current || mapRef.current) return; + const map = L.map(elRef.current, { + zoomControl: false, + attributionControl: true, + dragging: false, + scrollWheelZoom: false, + doubleClickZoom: false, + boxZoom: false, + keyboard: false, + touchZoom: false, + }).setView([lat, lng], 14); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map); + + mapRef.current = map; + setTimeout(() => map.invalidateSize(), 60); + + return () => { + map.remove(); + mapRef.current = null; + layersRef.current = []; + zoomCtrlRef.current = null; + }; + }, [lat, lng]); + + // Redraw rings, pins and the viewport whenever the data changes. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + layersRef.current.forEach((l) => l.remove()); + layersRef.current = []; + + const brand = cssVar('--brand', '#0F766E'); + const above = cssVar('--status-above', '#2F855A'); + + // Widest first, so the small recent ring is not buried under the old ones. + const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM); + for (const r of ordered) { + const circle = L.circle([lat, lng], { + radius: r.radiusM, + color: brand, + weight: r.latest ? 3 : 1.5, + opacity: r.latest ? 1 : 0.45, + fill: r.latest, + fillColor: brand, + fillOpacity: r.latest ? 0.12 : 0, + interactive: false, + }).addTo(map); + layersRef.current.push(circle); + } + + const school = L.marker([lat, lng], { + icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }), + keyboard: false, + interactive: false, + }).addTo(map); + layersRef.current.push(school); + + if (home) { + const pin = L.marker([home.lat, home.lng], { + icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }), + keyboard: false, + interactive: false, + }).addTo(map); + layersRef.current.push(pin); + + const line = L.polyline([[lat, lng], [home.lat, home.lng]], { + color: above, + weight: 2, + dashArray: '5 5', + opacity: 0.85, + interactive: false, + }).addTo(map); + layersRef.current.push(line); + } + + // Frame the widest ring — and the home pin when it falls outside it, so a + // family beyond every cut-off can still see where they sit. + const widest = ordered[0]?.radiusM ?? 500; + let bounds = L.latLng(lat, lng).toBounds(widest * 2.2); + if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng)); + map.fitBounds(bounds, { padding: [16, 16] }); + }, [lat, lng, rings, home]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const handlers = [ + map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom, + ]; + handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } }); + + if (interactive && !zoomCtrlRef.current) { + zoomCtrlRef.current = L.control.zoom({ position: 'topleft' }); + zoomCtrlRef.current.addTo(map); + } else if (!interactive && zoomCtrlRef.current) { + zoomCtrlRef.current.remove(); + zoomCtrlRef.current = null; + } + setTimeout(() => map.invalidateSize(), 80); + }, [interactive]); + + return
; +} diff --git a/nextjs-app/components/school/AdmissionsSection.tsx b/nextjs-app/components/school/AdmissionsSection.tsx index 4b9f6c8..9cb9d24 100644 --- a/nextjs-app/components/school/AdmissionsSection.tsx +++ b/nextjs-app/components/school/AdmissionsSection.tsx @@ -12,19 +12,25 @@ */ import type { ReactNode } from 'react'; -import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types'; +import type { School, 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 { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle'; import { AdmissionsTrendChart } from './charts'; -import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered'; +import { CutoffDistanceDetail } from './CutoffDistanceDetail'; +import { + describeCutoff, cutoffYearRows, + CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE, +} from './lastDistanceOffered'; export function AdmissionsSection({ admissions, admissionsHistory, admissionDistance, + admissionDistanceHistory, + schoolInfo, isAllThrough, }: { /* Nullable: the two data sources are independent. A minority of schools have @@ -34,9 +40,18 @@ export function AdmissionsSection({ admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; admissionDistance: SchoolAdmissionDistance | null; + admissionDistanceHistory: SchoolAdmissionDistance[]; + schoolInfo: School; isAllThrough: boolean; }) { const cutoff = describeCutoff(admissionDistance); + // Every year in the span, including the ones with nothing in them — the + // classification of a missing year is what the Distance view is mostly for. + const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory); + const publishedYears = cutoffRows.filter((r) => r.status === 'published').length; + // One published year is a fact, not a history: the tile already says it, and + // a chart of a single point invites a trend reading that is not there. + const showDistanceView = publishedYears >= 2; // Trend toggle only appears with ≥2 years carrying an offer rate. const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; @@ -167,18 +182,36 @@ export function AdmissionsSection({ ); + const distanceView = showDistanceView && ( + + ); + + const views: AdmissionsView[] = [ + { id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear }, + ]; + if (showAdmissionsTrend) { + views.push({ + id: 'trend', + label: `${admissionsHistory.length}-year trend`, + content: trendView, + className: styles.admissionsViewTrend, + }); + } + if (showDistanceView) { + views.push({ + id: 'distance', + label: 'Distance', + content: distanceView, + className: styles.admissionsViewTrend, + }); + } + return (
- {showAdmissionsTrend ? ( - + {views.length > 1 ? ( + ) : ( - /* No trend data — render statically, with no client component at all. */ + /* One view — render statically, with no client component at all. */ <>

{title}

diff --git a/nextjs-app/components/school/AdmissionsViewToggle.tsx b/nextjs-app/components/school/AdmissionsViewToggle.tsx index 000920f..2f08770 100644 --- a/nextjs-app/components/school/AdmissionsViewToggle.tsx +++ b/nextjs-app/components/school/AdmissionsViewToggle.tsx @@ -3,14 +3,25 @@ import { useState, type ReactNode } from 'react'; import styles from './schoolSections.module.css'; +export interface AdmissionsView { + id: string; + /** Button text. "This year" and the "N-year trend" label are asserted by the + * e2e journey, so they are not free to drift. */ + label: string; + content: ReactNode; + /** The year view sizes its tile grid to match the chart beside it, so each + * view keeps its own class rather than sharing one wrapper style. */ + className?: string; +} + /** * The only interactive part of the primary admissions section, and the only * client component in components/school/. * - * Both views are always present in the DOM and visibility is toggled with the - * `hidden` attribute — matching the previous behaviour exactly — so the - * server-rendered markup passed in as yearView/trendView never ships as client - * JavaScript. + * Every view is always present in the DOM and visibility is toggled with the + * `hidden` attribute, so the server-rendered markup passed in as content never + * ships as client JavaScript — this component carries the state and nothing + * else. * * It spans the header and the viewport because the segmented control sits * inside .admissionsHeader beside the

while the viewport is a sibling @@ -19,39 +30,38 @@ import styles from './schoolSections.module.css'; export function AdmissionsViewToggle({ title, subtitle, - trendLabel, - yearView, - trendView, + views, }: { title: ReactNode; subtitle: ReactNode; - trendLabel: string; - yearView: ReactNode; - trendView: ReactNode; + views: AdmissionsView[]; }) { - const [view, setView] = useState<'year' | 'trend'>('year'); + const [active, setActive] = useState(views[0]?.id); return ( <>

{title}

- - + {views.map((v) => ( + + ))}
{subtitle}
- - + {views.map((v) => ( + + ))}
); diff --git a/nextjs-app/components/school/CutoffDistanceDetail.tsx b/nextjs-app/components/school/CutoffDistanceDetail.tsx new file mode 100644 index 0000000..88093d0 --- /dev/null +++ b/nextjs-app/components/school/CutoffDistanceDetail.tsx @@ -0,0 +1,70 @@ +/** + * CutoffDistanceDetail — the full year-by-year cut-off story. + * + * Chart, table, map and postcode check, in that order: the shape of the trend, + * then what happened in each year, then the same numbers over real streets, + * then the reader's own address measured against them. + * + * Shared by both templates because the content is identical, but placed + * differently by each. The primary page has a segmented control and gives this + * its own "Distance" tab; the secondary page is one flat panel by design, so it + * renders inline beneath the metric cards. Extracting it keeps the caveats and + * the ordering from drifting apart between the two. + * + * Server component — the map and postcode form are the only client parts, and + * they carry their own boundary. + */ + +import type { School } from '@/lib/types'; +import { sectionStyles as styles } from './sectionShared'; +import { CutoffTrendChart } from './charts'; +import { CutoffYearTable } from './CutoffYearTable'; +import { CutoffMapPanel } from './CutoffMapPanel'; +import { + cutoffTrendSummary, cutoffCoverageNote, + CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE, + type CutoffYearRow, +} from './lastDistanceOffered'; + +export function CutoffDistanceDetail({ + rows, + schoolInfo, + /** Repeat the "not a catchment" caveat here. On the primary page this view is + * behind a tab, so a reader can reach it without ever seeing the tile that + * carries the caveat; on the secondary page the caveat is already directly + * above, and repeating it verbatim reads as a stutter. */ + showCaveat = true, +}: { + rows: CutoffYearRow[]; + schoolInfo: School; + showCaveat?: boolean; +}) { + const publishedYears = rows.filter((r) => r.status === 'published').length; + if (publishedYears < 2) return null; + + const trendSummary = cutoffTrendSummary(rows); + const coverageNote = cutoffCoverageNote(rows); + + return ( + <> +
Last distance offered, by year
+ +

+ The line breaks where no figure was published — it is never drawn across + a gap. The table below says what happened in each of those years. +

+ {trendSummary &&

{trendSummary}

} + + + {coverageNote &&

{coverageNote}

} + + + + {showCaveat && ( +

+ {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE} +

+ )} + + ); +} diff --git a/nextjs-app/components/school/CutoffMapPanel.tsx b/nextjs-app/components/school/CutoffMapPanel.tsx new file mode 100644 index 0000000..d03c295 --- /dev/null +++ b/nextjs-app/components/school/CutoffMapPanel.tsx @@ -0,0 +1,205 @@ +'use client'; + +/** + * CutoffMapPanel — "Where the last place went". + * + * The rings and the postcode check live in one component because they are one + * question asked twice: the map answers "how far is that?" and the form answers + * "how far are we?". Entering a postcode drops a pin on the same rings rather + * than producing a separate verdict somewhere else on the page. + * + * The postcode never leaves the browser except to postcodes.io for a lat/long, + * and nothing is stored — this is a client-side measurement, not a lookup + * against the family. + */ + +import { useState, useMemo, type FormEvent } from 'react'; +import dynamic from 'next/dynamic'; +import type { School } from '@/lib/types'; +import { geocodePostcode, calculateDistance } from '@/lib/api'; +import { isValidPostcode } from '@/lib/utils'; +import { + compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT, + type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict, +} from './lastDistanceOffered'; +import type { CutoffRing } from '../LeafletCutoffMapInner'; +import styles from './schoolSections.module.css'; + +const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), { + ssr: false, + loading: () =>