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 = `
+
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
>
);
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: () => ,
+});
+
+const VERDICT_MARK: Record = {
+ inside: '✓',
+ outside: '✕',
+ 'too-close': '≈',
+ unknown: '–',
+};
+
+const VERDICT_TITLE: Record = {
+ inside: 'Inside that year’s cut-off',
+ outside: 'Outside that year’s cut-off',
+ 'too-close': 'Too close to the cut-off to say',
+ unknown: 'No published figure for this year',
+};
+
+// Written out rather than composed from the verdict string. A computed
+// `styles[...]` key silently yields undefined when a class is renamed, and an
+// unstyled "outside" chip would look exactly like an "inside" one.
+const VERDICT_CLASS: Record = {
+ inside: styles.cutoffYearInside,
+ outside: styles.cutoffYearOutside,
+ 'too-close': styles.cutoffYearTooClose,
+ unknown: styles.cutoffYearUnknown,
+};
+
+export function CutoffMapPanel({
+ schoolInfo,
+ rows,
+}: {
+ schoolInfo: School;
+ rows: CutoffYearRow[];
+}) {
+ const [postcode, setPostcode] = useState('');
+ const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const lat = schoolInfo.latitude;
+ const lng = schoolInfo.longitude;
+
+ const rings: CutoffRing[] = useMemo(() => {
+ const published = rows.filter((r) => r.distanceM != null);
+ return published.map((r, i) => ({
+ year: r.year,
+ radiusM: r.distanceM as number,
+ // rows are newest-first, so the first published row is the current year.
+ latest: i === 0,
+ }));
+ }, [rows]);
+
+ // Without coordinates there is nothing to draw rings around and nothing to
+ // measure a postcode against.
+ if (lat == null || lng == null || rings.length === 0) return null;
+
+ const onCheck = async (e: FormEvent) => {
+ e.preventDefault();
+ const value = postcode.trim();
+ if (!value) return;
+
+ if (!isValidPostcode(value)) {
+ setError('That does not look like a UK postcode. Try one like SE23 3NA.');
+ setResult(null);
+ setHome(null);
+ return;
+ }
+
+ setBusy(true);
+ setError(null);
+ try {
+ const point = await geocodePostcode(value);
+ if (!point) {
+ setError('We could not find that postcode. Check it and try again.');
+ setResult(null);
+ setHome(null);
+ return;
+ }
+ setHome({ lat: point.latitude, lng: point.longitude });
+ // calculateDistance returns kilometres; everything here is metres.
+ const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
+ setResult(compareToCutoffs(metres, rows));
+ } catch {
+ setError('Something went wrong looking up that postcode. Try again in a moment.');
+ setResult(null);
+ setHome(null);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const latestRing = rings[0];
+
+ return (
+
+
Where the last place went
+
+
+
+
+
+
+
+
+ {latestRing.year} cut-off
+
+ {rings.length > 1 && (
+
+
+ Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
+
+ )}
+ {home && (
+
+
+ Your postcode
+
+ )}
+
+
+
{CUTOFF_MAP_CAVEAT}
+
+
+
How far away are you?
+
+ We measure a straight line from your postcode to the school and compare
+ it with each year’s cut-off. Your postcode is not stored.
+
+ );
+}
diff --git a/nextjs-app/components/school/CutoffYearTable.tsx b/nextjs-app/components/school/CutoffYearTable.tsx
new file mode 100644
index 0000000..2f1e4fd
--- /dev/null
+++ b/nextjs-app/components/school/CutoffYearTable.tsx
@@ -0,0 +1,82 @@
+/**
+ * CutoffYearTable — the last distance offered, year by year.
+ *
+ * The table is where the chart's gaps get explained. Each year says which of
+ * the three things happened, in words, because "no figure" alone is the least
+ * useful thing we could tell a parent about a year.
+ *
+ * Server component: it is static markup over data the page already holds.
+ */
+
+import type { CutoffYearRow, CutoffYearStatus } from './lastDistanceOffered';
+import { sectionStyles as styles } from './sectionShared';
+
+const STATUS_LABEL: Record = {
+ published: 'Oversubscribed',
+ // Precisely what the EES field supports, and no more: first preferences did
+ // not exceed places. It does not establish that every applicant was offered
+ // a place, so it must not be worded as though it did.
+ 'not-oversubscribed': 'Places available on first preferences',
+ 'not-published': 'Not published',
+};
+
+const STATUS_CLASS: Record = {
+ published: styles.cutoffPillOver,
+ 'not-oversubscribed': styles.cutoffPillOk,
+ 'not-published': styles.cutoffPillNa,
+};
+
+const DISTANCE_TEXT: Record = {
+ published: '',
+ 'not-oversubscribed': 'No cut-off needed',
+ 'not-published': '—',
+};
+
+export function CutoffYearTable({ rows }: { rows: CutoffYearRow[] }) {
+ if (rows.length === 0) return null;
+
+ const showPlaces = rows.some((r) => r.placesOffered != null);
+
+ return (
+
+
+
+ Last distance offered by year, with the reason where no figure was published
+
+ );
+}
diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx
index b2bff36..8afd49f 100644
--- a/nextjs-app/components/school/PrimarySchoolSections.tsx
+++ b/nextjs-app/components/school/PrimarySchoolSections.tsx
@@ -35,6 +35,7 @@ export interface PrimarySchoolSectionsProps {
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
+ admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -43,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
- admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
+ admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
+ deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -101,6 +103,8 @@ export function PrimarySchoolSections({
admissions={admissions}
admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
+ admissionDistanceHistory={admissionDistanceHistory}
+ schoolInfo={schoolInfo}
isAllThrough={flags.isAllThrough}
/>
)}
diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
index 7e8fdb3..1d73387 100644
--- a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
+++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
@@ -9,19 +9,27 @@
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
-import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
+import { CutoffDistanceDetail } from './CutoffDistanceDetail';
+import {
+ describeCutoff, cutoffYearRows, describeCutoffAbsence,
+ CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
+} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
- admissions, admissionDistance, schoolInfo, hasSixthForm,
+ admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
+ schoolInfo, hasSixthForm,
}: {
/* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null;
+ admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
+ admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School;
hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
+ const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -100,13 +108,19 @@ export function SecondaryAdmissionsSection({
) : (
- {schoolInfo.local_authority
- ? `${schoolInfo.local_authority} has not published a cut-off distance for this school.`
- : 'No cut-off distance has been published for this school.'}{' '}
- Contact the admissions authority for its oversubscription criteria.
+ {describeCutoffAbsence({
+ localAuthority: schoolInfo.local_authority,
+ admissionsPolicy: schoolInfo.admissions_policy,
+ admissionsHistory,
+ })}
)}
+ {/* Inline rather than behind a tab: this template is one flat panel by
+ design and has no segmented control to hang a third view on. The
+ caveat is suppressed because it is already directly above. */}
+
+
{hasSixthForm && (
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx
index 9c95439..aa9b37e 100644
--- a/nextjs-app/components/school/SecondarySchoolSections.tsx
+++ b/nextjs-app/components/school/SecondarySchoolSections.tsx
@@ -32,7 +32,11 @@ export interface SecondarySchoolSectionsProps {
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
+ /** Needed to tell a year with no published cut-off apart from a year the
+ * school simply was not oversubscribed. */
+ admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
+ admissionDistanceHistory: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -41,7 +45,8 @@ export interface SecondarySchoolSectionsProps {
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
- admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
+ admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
+ deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -86,6 +91,8 @@ export function SecondarySchoolSections({
diff --git a/nextjs-app/components/school/charts.tsx b/nextjs-app/components/school/charts.tsx
index 83535a0..1cfe7b3 100644
--- a/nextjs-app/components/school/charts.tsx
+++ b/nextjs-app/components/school/charts.tsx
@@ -24,3 +24,8 @@ export const AdmissionsTrendChart = dynamic(
() => import('../AdmissionsTrendChart'),
{ ssr: false },
);
+
+export const CutoffTrendChart = dynamic(
+ () => import('../CutoffTrendChart'),
+ { ssr: false },
+);
diff --git a/nextjs-app/components/school/lastDistanceOffered.ts b/nextjs-app/components/school/lastDistanceOffered.ts
index 5523384..035d458 100644
--- a/nextjs-app/components/school/lastDistanceOffered.ts
+++ b/nextjs-app/components/school/lastDistanceOffered.ts
@@ -59,3 +59,319 @@ export function describeCutoff(
: null,
};
}
+
+// ---------------------------------------------------------------------------
+// Year-by-year history
+// ---------------------------------------------------------------------------
+
+/**
+ * Why a year has no cut-off figure.
+ *
+ * The distinction matters more than the figure does. A missing year is not one
+ * fact but three, and collapsing them into "no data" throws away the most
+ * reassuring case (the school had places for everyone who put it first) and
+ * the most important caveat (nobody published anything, so we cannot say).
+ */
+export type CutoffYearStatus =
+ /** The authority published a cut-off for this year. */
+ | 'published'
+ /**
+ * No cut-off published, and EES shows fewer first-preference applications
+ * than places offered. Note the precise claim: this says the school was not
+ * oversubscribed ON FIRST PREFERENCES, which is what the data supports. It
+ * does NOT establish that every applicant was offered a place — total
+ * applications can still exceed places — so no copy here may say so.
+ */
+ | 'not-oversubscribed'
+ /** Nothing published, and nothing in the admissions data to explain why. */
+ | 'not-published';
+
+export interface CutoffYearRow {
+ year: number;
+ status: CutoffYearStatus;
+ /** Straight-line metres, present only when status is 'published'. */
+ distanceM: number | null;
+ /** Formatted figure, present only when status is 'published'. */
+ figure: string | null;
+ placesOffered: number | null;
+ routeCount: number | null;
+}
+
+interface AdmissionsYearLike {
+ year: number;
+ places_offered?: number | null;
+ oversubscribed?: boolean | null;
+}
+
+/**
+ * EES years are six-digit academic codes (202425); cut-off years are plain
+ * entry years (2024). Both denote the same September intake, so they have to be
+ * reduced to a common key before they can be matched.
+ */
+export function entryYearOf(year: number): number {
+ const s = String(year);
+ return s.length === 6 ? Number(s.slice(0, 4)) : year;
+}
+
+/**
+ * One row per year across the full span the school has any record for, newest
+ * first. Years with no record at all still appear — an unbroken axis is what
+ * makes a gap legible as a gap.
+ */
+export function cutoffYearRows(
+ /* Defaulted, not merely optional: admission_distance_history is an additive
+ API field, so a frontend deployed ahead of its backend receives undefined
+ here. Defaulting keeps that a section that renders nothing rather than a
+ page that throws. */
+ history: SchoolAdmissionDistance[] = [],
+ admissionsHistory: AdmissionsYearLike[] = [],
+): CutoffYearRow[] {
+ const published = new Map();
+ for (const d of history ?? []) {
+ if (d.distance_m != null) published.set(entryYearOf(d.year), d);
+ }
+
+ const admissions = new Map();
+ for (const a of admissionsHistory ?? []) admissions.set(entryYearOf(a.year), a);
+
+ const years = [...published.keys(), ...admissions.keys()];
+ if (years.length === 0) return [];
+
+ // The span is bounded by the cut-off data, not by admissions: EES reaches
+ // back further than councils publish, and padding the chart with a decade of
+ // "not published" would bury the years that carry a figure.
+ const publishedYears = [...published.keys()];
+ const from = publishedYears.length ? Math.min(...publishedYears) : Math.min(...years);
+ const to = Math.max(...years);
+
+ const rows: CutoffYearRow[] = [];
+ for (let y = to; y >= from; y -= 1) {
+ const d = published.get(y);
+ const a = admissions.get(y);
+
+ let status: CutoffYearStatus = 'not-published';
+ if (d) status = 'published';
+ else if (a?.oversubscribed === false) status = 'not-oversubscribed';
+
+ rows.push({
+ year: y,
+ status,
+ distanceM: d?.distance_m ?? null,
+ figure: d ? formatCutoffDistance(d.distance_m)?.primary ?? null : null,
+ placesOffered: a?.places_offered ?? null,
+ routeCount: d?.route_count ?? null,
+ });
+ }
+ return rows;
+}
+
+/**
+ * A factual summary of how the cut-off has moved.
+ *
+ * Deliberately not a verdict. It names both endpoints and their years and lets
+ * the reader draw the conclusion, because the series is short, gappy, and
+ * driven by things outside the school's control — one large sibling cohort
+ * moves it. Withheld below four published points, where a swing between two
+ * years is noise wearing the clothes of a trend.
+ */
+export function cutoffTrendSummary(rows: CutoffYearRow[]): string | null {
+ const pts = rows.filter((r) => r.status === 'published' && r.distanceM != null);
+ if (pts.length < 4) return null;
+
+ // rows are newest-first
+ const latest = pts[0];
+ const earliest = pts[pts.length - 1];
+ const a = formatCutoffDistance(earliest.distanceM)?.primary;
+ const b = formatCutoffDistance(latest.distanceM)?.primary;
+ if (!a || !b) return null;
+
+ const change = latest.distanceM! - earliest.distanceM!;
+ // A tenth of the earlier figure — below that the endpoints are effectively
+ // the same and calling it a direction would be reading noise.
+ const meaningful = Math.abs(change) > earliest.distanceM! * 0.1;
+
+ const direction = !meaningful
+ ? 'has stayed broadly the same'
+ : change < 0
+ ? 'has tightened'
+ : 'has widened';
+
+ return `Across ${pts.length} published years the cut-off ${direction}: `
+ + `${a} in ${earliest.year}, ${b} in ${latest.year}.`;
+}
+
+// ---------------------------------------------------------------------------
+// "Would we have got in?"
+// ---------------------------------------------------------------------------
+
+/**
+ * How close a home has to be to a year's cut-off before the comparison stops
+ * meaning anything, in metres.
+ *
+ * A UK postcode unit covers roughly fifteen addresses and postcodes.io returns
+ * its centroid, so the home point carries error of this order before anything
+ * else is considered. Against a cut-off that is often only 500 m, that is a
+ * fifth of the whole distance. Inside this band the honest answer is that we
+ * cannot tell, and saying "you would have been offered a place" would be
+ * inventing precision the inputs do not have.
+ */
+export const CUTOFF_UNCERTAINTY_M = 100;
+
+export type CutoffVerdict = 'inside' | 'outside' | 'too-close' | 'unknown';
+
+export interface CutoffYearComparison {
+ year: number;
+ status: CutoffYearStatus;
+ verdict: CutoffVerdict;
+}
+
+export interface CutoffCheckResult {
+ /** Straight-line metres from the given postcode to the school. */
+ distanceM: number;
+ distanceLabel: string;
+ years: CutoffYearComparison[];
+ /** Years the home is clearly inside, out of those with a published figure. */
+ insideCount: number;
+ comparableCount: number;
+ headline: string;
+ detail: string;
+}
+
+export function compareToCutoffs(
+ distanceM: number,
+ rows: CutoffYearRow[],
+): CutoffCheckResult {
+ const years: CutoffYearComparison[] = rows.map((r) => {
+ if (r.status !== 'published' || r.distanceM == null) {
+ return { year: r.year, status: r.status, verdict: 'unknown' as const };
+ }
+ const margin = r.distanceM - distanceM;
+ const verdict: CutoffVerdict =
+ Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
+ return { year: r.year, status: r.status, verdict };
+ });
+
+ const comparable = years.filter((y) => y.verdict !== 'unknown');
+ const inside = comparable.filter((y) => y.verdict === 'inside');
+ const tooClose = comparable.filter((y) => y.verdict === 'too-close');
+ const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
+
+ let headline: string;
+ if (comparable.length === 0) {
+ headline = `${label} from the school`;
+ } else if (inside.length === comparable.length) {
+ headline = `${label} away — inside the cut-off in all ${comparable.length} `
+ + `${comparable.length === 1 ? 'year' : 'years'} with a published figure`;
+ } else if (inside.length === 0 && tooClose.length === 0) {
+ headline = `${label} away — outside the cut-off in every year with a published figure`;
+ } else {
+ headline = `${label} away — inside the cut-off in ${inside.length} of `
+ + `${comparable.length} years with a published figure`;
+ }
+
+ const parts: string[] = [];
+ if (tooClose.length > 0) {
+ parts.push(
+ `${tooClose.length} ${tooClose.length === 1 ? 'year is' : 'years are'} too close to call: `
+ + 'your postcode is a centroid covering several addresses, so a margin under '
+ + `${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`,
+ );
+ }
+ const unknown = years.length - comparable.length;
+ if (unknown > 0) {
+ parts.push(`${unknown} ${unknown === 1 ? 'year has' : 'years have'} no published figure to compare against.`);
+ }
+
+ return {
+ distanceM,
+ distanceLabel: label,
+ years,
+ insideCount: inside.length,
+ comparableCount: comparable.length,
+ headline,
+ detail: parts.join(' '),
+ };
+}
+
+/**
+ * The limits of the check, stated where a parent will act on it.
+ *
+ * Every clause is load-bearing. Distance is the last criterion applied, not the
+ * first; the authority's own measurement may be a walking route rather than a
+ * straight line, which is always longer for the same home; and a past cut-off
+ * constrains next year's not at all.
+ */
+export const CUTOFF_CHECK_CAVEAT =
+ 'An indication only. Places go first to children in care, EHCP places, '
+ + 'siblings and — at faith schools — on faith criteria, so a home inside the '
+ + 'distance can still miss out. Some authorities measure a walking route '
+ + 'rather than a straight line, which is always longer for the same home, and '
+ + "next year's cut-off depends on next year's applicants. Always check the "
+ + "school's own admissions policy.";
+
+/**
+ * The rings are a drawing of a number, not a boundary anyone administers.
+ *
+ * The walking-route caveat belongs to CUTOFF_CHECK_CAVEAT immediately below
+ * this on the page, which states it more usefully ("always longer for the same
+ * home"). Saying it in both places read as a stutter.
+ */
+export const CUTOFF_MAP_CAVEAT =
+ 'Each ring is the straight-line cut-off for that year, drawn around the '
+ + 'school. It illustrates the distance — it is not a catchment boundary.';
+
+// ---------------------------------------------------------------------------
+// When there is no figure
+// ---------------------------------------------------------------------------
+
+interface AbsenceInput {
+ localAuthority?: string | null;
+ admissionsPolicy?: string | null;
+ admissionsHistory?: AdmissionsYearLike[];
+}
+
+/**
+ * Why this school has no cut-off distance, in the most useful terms available.
+ *
+ * "No data" is the least informative thing we could say, and for two of these
+ * cases it is also the most pessimistic reading of a fact that is either
+ * neutral or good news. The order matters: a selective school's absence is
+ * explained by how it admits, which outranks anything the publication record
+ * says.
+ */
+export function describeCutoffAbsence({
+ localAuthority,
+ admissionsPolicy,
+ admissionsHistory = [],
+}: AbsenceInput): string {
+ const policy = (admissionsPolicy ?? '').toLowerCase();
+ if (policy.includes('selective')) {
+ return 'Places at this school are ranked by the entrance test rather than by '
+ + 'distance, so no cut-off distance applies.';
+ }
+
+ const known = admissionsHistory.filter((a) => a.oversubscribed != null);
+ if (known.length >= 3 && known.every((a) => a.oversubscribed === false)) {
+ return `First preferences have not exceeded places in any of the last ${known.length} `
+ + 'years, so this school has not needed a distance cut-off.';
+ }
+
+ return (localAuthority
+ ? `${localAuthority} has not published a cut-off distance for this school.`
+ : 'No cut-off distance has been published for this school.')
+ + ' Contact the admissions authority for its oversubscription criteria.';
+}
+
+/**
+ * A short note on how thin the record is.
+ *
+ * Four points is the same threshold the trend summary uses: below it the series
+ * is too short to carry a direction, and saying so is more useful than leaving
+ * the reader to count the rows.
+ */
+export function cutoffCoverageNote(rows: CutoffYearRow[]): string | null {
+ const published = rows.filter((r) => r.status === 'published').length;
+ if (published === 0 || published >= 4) return null;
+ return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, `
+ + 'which is too few to read as a trend.';
+}
diff --git a/nextjs-app/components/school/schoolSections.module.css b/nextjs-app/components/school/schoolSections.module.css
index a9f4515..066ec49 100644
--- a/nextjs-app/components/school/schoolSections.module.css
+++ b/nextjs-app/components/school/schoolSections.module.css
@@ -2064,3 +2064,383 @@
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
}
+
+/* ══════════════════════════════════════════════════════════════════════
+ Last distance offered — the Distance view, map and postcode check
+ ══════════════════════════════════════════════════════════════════════ */
+
+/* Visually hidden but still read aloud. The year strip encodes its verdict as
+ a glyph, so every chip carries a spelled-out equivalent here — a screen
+ reader announcing "2025 ✓" tells you nothing. */
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+.cutoffChartKey {
+ margin: 0.6rem 0 0;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ max-width: 68ch;
+}
+
+/* ── Year-by-year table ── */
+
+.cutoffTableWrap {
+ margin-top: 1.25rem;
+ overflow-x: auto;
+}
+
+.cutoffTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ /* Below this the four columns crush; the wrapper scrolls instead of the
+ page, so the section itself never scrolls sideways. */
+ min-width: 26rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.cutoffTable thead th {
+ text-align: right;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ padding: 0 0.625rem 0.55rem;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+
+.cutoffTable thead th:first-child,
+.cutoffTable tbody th {
+ text-align: left;
+}
+
+.cutoffTable tbody td,
+.cutoffTable tbody th {
+ text-align: right;
+ padding: 0.65rem 0.625rem;
+ border-bottom: 1px solid var(--border);
+ font-weight: 400;
+ color: var(--text-primary);
+}
+
+.cutoffTable tbody th {
+ font-weight: 600;
+}
+
+.cutoffTable tbody tr:last-child td,
+.cutoffTable tbody tr:last-child th {
+ border-bottom: none;
+}
+
+.cutoffRowLatest td,
+.cutoffRowLatest th {
+ background: var(--bg-secondary);
+}
+
+.cutoffCellMissing {
+ color: var(--text-secondary);
+}
+
+.cutoffRouteFlag {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.cutoffPill {
+ display: inline-block;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ padding: 0.15rem 0.55rem;
+ border-radius: 999px;
+ white-space: nowrap;
+}
+
+.cutoffPillOver {
+ background: var(--brand-bg);
+ color: var(--brand-strong);
+}
+
+.cutoffPillOk {
+ background: var(--status-above-bg);
+ color: var(--status-above);
+}
+
+.cutoffPillNa {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+}
+
+/* ── Map panel ── */
+
+.cutoffMapPanel {
+ margin-top: 1.75rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--border);
+}
+
+.cutoffMapTitle {
+ margin: 0 0 0.85rem;
+ font-family: var(--font-display);
+ font-size: 1.1rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--text-primary);
+}
+
+.cutoffMapFigure {
+ height: 320px;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+}
+
+.cutoffMapSkeleton {
+ width: 100%;
+ height: 100%;
+ background: var(--bg-secondary);
+}
+
+.cutoffMapLegend {
+ list-style: none;
+ margin: 0.85rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1.25rem;
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.cutoffMapLegend li {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.cutoffSwatch {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ flex: none;
+}
+
+.cutoffSwatchNow {
+ background: var(--brand-bg);
+ box-shadow: inset 0 0 0 2px var(--brand);
+}
+
+.cutoffSwatchPast {
+ background: transparent;
+ box-shadow: inset 0 0 0 1.5px var(--brand);
+ opacity: 0.5;
+}
+
+.cutoffSwatchHome {
+ width: 11px;
+ height: 11px;
+ border-radius: 2px;
+ background: var(--status-above);
+ transform: rotate(45deg);
+}
+
+.cutoffMapCaveat {
+ margin: 0.75rem 0 0;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ max-width: 68ch;
+}
+
+/* ── Postcode check ── */
+
+.cutoffCheck {
+ margin-top: 1.5rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--border);
+}
+
+.cutoffCheckTitle {
+ margin: 0 0 0.25rem;
+ font-family: var(--font-display);
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.cutoffCheckSub {
+ margin: 0 0 0.85rem;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ max-width: 62ch;
+}
+
+.cutoffCheckForm {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.cutoffCheckInput {
+ font: inherit;
+ font-size: 1rem;
+ padding: 0.6rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ text-transform: uppercase;
+ /* Grows into the row but never past it — at 320px a fixed width would push
+ the button out of the viewport. */
+ flex: 1 1 9rem;
+ /* Stops well short of full width on desktop, where a postcode field
+ spanning the whole card reads as a search box. */
+ max-width: 16rem;
+ min-width: 0;
+}
+
+.cutoffCheckInput::placeholder {
+ text-transform: none;
+ color: var(--text-secondary);
+}
+
+.cutoffCheckInput:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 1px;
+ border-color: var(--brand);
+}
+
+.cutoffCheckButton {
+ font: inherit;
+ font-weight: 600;
+ font-size: 1rem;
+ padding: 0.6rem 1.25rem;
+ border: none;
+ border-radius: var(--radius-md);
+ background: var(--brand);
+ color: var(--brand-on);
+ cursor: pointer;
+ flex: none;
+ transition: background var(--transition);
+}
+
+.cutoffCheckButton:hover:not(:disabled) {
+ background: var(--brand-strong);
+}
+
+.cutoffCheckButton:disabled {
+ opacity: 0.65;
+ cursor: progress;
+}
+
+.cutoffCheckButton:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+
+.cutoffCheckError {
+ margin: 0.75rem 0 0;
+ font-size: 0.875rem;
+ color: var(--status-below);
+}
+
+.cutoffCheckResult {
+ margin-top: 0.9rem;
+ padding: 0.9rem 1rem;
+ border-radius: var(--radius-md);
+ background-color: var(--bg-card);
+ background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
+}
+
+.cutoffCheckHeadline {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.05rem;
+ font-weight: 700;
+ line-height: 1.35;
+ color: var(--text-primary);
+ text-wrap: balance;
+}
+
+.cutoffCheckDetail {
+ margin: 0.35rem 0 0;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+}
+
+.cutoffYearStrip {
+ list-style: none;
+ margin: 0.75rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.cutoffYearChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.2rem 0.5rem;
+ border-radius: 999px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.cutoffYearChipYear {
+ font-weight: 700;
+}
+
+.cutoffYearInside {
+ background: var(--status-above-bg);
+ color: var(--status-above);
+}
+
+.cutoffYearOutside {
+ background: var(--status-below-bg);
+ color: var(--status-below);
+}
+
+/* Deliberately neutral rather than a warning colour: "we cannot tell" is not a
+ bad outcome, and tinting it amber would read as one. */
+.cutoffYearTooClose {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ box-shadow: inset 0 0 0 1px var(--border);
+}
+
+.cutoffYearUnknown {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+}
+
+.cutoffCheckCaveat {
+ margin: 0.9rem 0 0;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ max-width: 68ch;
+}
+
+@media (max-width: 640px) {
+ .cutoffMapFigure {
+ height: 260px;
+ }
+}
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index 32ff142..426bf85 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -356,6 +356,12 @@ export interface SchoolDetailsResponse {
admissions_history: SchoolAdmissions[];
/** Latest published cut-off distance, or null where the LA publishes none. */
admission_distance: SchoolAdmissionDistance | null;
+ /**
+ * Every published cut-off year, oldest first. Sparse by nature — a school may
+ * have 2021 and 2026 and nothing between, so consumers must classify the
+ * missing years rather than joining the points they do have.
+ */
+ admission_distance_history: SchoolAdmissionDistance[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}