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
232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
/**
|
|
* Last distance offered — formatting and the caveats attached to the figure.
|
|
*
|
|
* The assertions about the route note and the year are not cosmetic. A cut-off
|
|
* shown without its year, or a banded school's widest cut-off shown as if it
|
|
* were the only one, tells a parent something false about their chances of a
|
|
* place — so both are pinned here rather than left to the component.
|
|
*/
|
|
|
|
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
|
import {
|
|
describeCutoff, 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 => ({
|
|
year: 2025,
|
|
distance_m: 500,
|
|
route_count: 1,
|
|
la_name: 'Camden',
|
|
distance_unit_raw: 'miles',
|
|
...over,
|
|
});
|
|
|
|
describe('formatCutoffDistance', () => {
|
|
it('leads with miles, the unit councils publish in', () => {
|
|
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
|
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
|
});
|
|
|
|
it('switches to kilometres for the support figure above a kilometre', () => {
|
|
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
|
});
|
|
|
|
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
|
|
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
|
|
});
|
|
|
|
it('returns null rather than a zero cut-off', () => {
|
|
// 0.0 miles appears in the source where a school filled on a higher
|
|
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
|
expect(formatCutoffDistance(0)).toBeNull();
|
|
expect(formatCutoffDistance(null)).toBeNull();
|
|
expect(formatCutoffDistance(undefined)).toBeNull();
|
|
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('formatEntryYear', () => {
|
|
it('names the intake, not the academic year', () => {
|
|
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
|
// school year rather than the September a child started.
|
|
expect(formatEntryYear(2025)).toBe('September 2025');
|
|
expect(formatEntryYear(null)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('describeCutoff', () => {
|
|
it('always carries the entry year alongside the figure', () => {
|
|
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
|
expect(d).not.toBeNull();
|
|
expect(d!.primary).toBe('0.48 miles');
|
|
expect(d!.entryYear).toBe('September 2024');
|
|
});
|
|
|
|
it('says nothing about routes for a school with one', () => {
|
|
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
|
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
|
});
|
|
|
|
it('warns that a banded school\'s figure is the widest of several', () => {
|
|
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
|
expect(note).toContain('4 admission routes');
|
|
expect(note).toContain('shorter cut-off');
|
|
});
|
|
|
|
it('is null when there is nothing publishable', () => {
|
|
expect(describeCutoff(null)).toBeNull();
|
|
expect(describeCutoff(undefined)).toBeNull();
|
|
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── 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');
|
|
});
|
|
});
|