From a72323874f7aebdb2e64b6d64a5febd61152d09d Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 16 Aug 2026 13:42:06 +0100 Subject: [PATCH] feat(admissions): add the cut-off history, map and postcode check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE --- .gitignore | 3 + backend/app.py | 1 + backend/data_loader.py | 27 +- backend/tests/test_supplementary_batch.py | 47 ++- e2e/tests/journeys.spec.ts | 118 +++++- .../components/CutoffMapPanel.test.tsx | 144 +++++++ .../components/lastDistanceOffered.test.tsx | 121 +++++- .../__tests__/lib/lastDistanceOffered.test.ts | 153 ++++++- .../__tests__/support/renderSchoolDetail.tsx | 3 + nextjs-app/app/school/[slug]/page.tsx | 5 +- nextjs-app/components/CutoffTrendChart.tsx | 123 ++++++ .../components/LeafletCutoffMapInner.tsx | 169 ++++++++ .../components/school/AdmissionsSection.tsx | 57 ++- .../school/AdmissionsViewToggle.tsx | 56 +-- .../school/CutoffDistanceDetail.tsx | 70 ++++ .../components/school/CutoffMapPanel.tsx | 205 ++++++++++ .../components/school/CutoffYearTable.tsx | 82 ++++ .../school/PrimarySchoolSections.tsx | 6 +- .../school/SecondaryAdmissionsSection.tsx | 26 +- .../school/SecondarySchoolSections.tsx | 9 +- nextjs-app/components/school/charts.tsx | 5 + .../components/school/lastDistanceOffered.ts | 316 +++++++++++++++ .../school/schoolSections.module.css | 380 ++++++++++++++++++ nextjs-app/lib/types.ts | 6 + 24 files changed, 2067 insertions(+), 65 deletions(-) create mode 100644 nextjs-app/__tests__/components/CutoffMapPanel.test.tsx create mode 100644 nextjs-app/components/CutoffTrendChart.tsx create mode 100644 nextjs-app/components/LeafletCutoffMapInner.tsx create mode 100644 nextjs-app/components/school/CutoffDistanceDetail.tsx create mode 100644 nextjs-app/components/school/CutoffMapPanel.tsx create mode 100644 nextjs-app/components/school/CutoffYearTable.tsx 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: () =>