feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-16 13:42:06 +01:00
1 parent 5a71f54d94
commit a72323874f
24 files changed
+2067 -65

No files matched your search

@@ -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: () => <div data-testid="cutoff-map" />,
}));
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(
<CutoffMapPanel schoolInfo={SCHOOL} rows={cutoffYearRows(history, admissions)} />,
);
}
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(
<CutoffMapPanel
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
rows={cutoffYearRows([d(2024, 800)])}
/>,
);
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();
});
});
@@ -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();
});
});
@@ -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> = {}): 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');
});
});
@@ -45,6 +45,7 @@ export function renderSchoolDetail(fixture: any) {
>
<PrimarySchoolSections
{...fixture}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
@@ -72,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
>
<SecondarySchoolSections
{...fixture}
admissionsHistory={fixture.admissionsHistory ?? []}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>