From ea5249a2ea6faf6bfa5a1522644387ba59770ec8 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 20 Aug 2026 20:35:05 +0100 Subject: [PATCH] fix(admissions): state distance in miles throughout, never mixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postcode check read "69 m away — inside the September 2026 cut-off of 0.17 miles". Both numbers are right and the sentence is still useless: the reader has to convert one of them to check a comparison we had already made for them. The cause was a readability rule of mine in formatCutoffDistance, which swapped to metres below 100m on the grounds that "0.04 miles" carries less than "69 m". Taken one figure at a time that holds. Taken in a sentence containing two figures it guarantees a mismatch whenever they fall either side of the threshold — and a 270m cut-off with a nearby home does exactly that. Miles now lead everywhere. It is the unit UK school admissions runs on: councils publish cut-offs in miles (90% of the collected source rows), and it is what a parent has already been quoted in their booklet and offer letter. The metric figure survives only as support beside the miles figure on the Admissions tile, where it converts the same value rather than presenting a second one to compare. Below 0.01 miles the decimal places run out rather than the unit being wrong, so a very short distance is described — "under 0.01 miles" — instead of rounding to a flat "0.00 miles", which would read as no distance at all. Both figures in the verdict now go through one formatter with no fallback that could reach for another unit. The old `?? "N m"` fallbacks on that line were a second route to the same defect and are gone. Covered by a sweep over sixty home/cut-off combinations spanning the old switch point, asserting no verdict contains a metric reading and that exactly two miles figures appear; plus the reported case pinned verbatim, and an e2e guard on the rendered verdict. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE --- e2e/tests/journeys.spec.ts | 11 +++++ .../__tests__/lib/lastDistanceOffered.test.ts | 47 ++++++++++++++++-- .../components/school/lastDistanceOffered.ts | 11 +++-- nextjs-app/lib/utils.ts | 48 ++++++++++++------- 4 files changed, 94 insertions(+), 23 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 70d48d2..9c18dc8 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1364,6 +1364,17 @@ test('the postcode check answers for the published year, and names it', async ({ // A verdict without its year is a number a parent cannot place. await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`)); await expect(result).toContainText(/inside|beyond|too close/); + + // Both figures in the sentence must be in the same unit. The verdict used + // to render the home distance in metres and the cut-off in miles — "69 m + // away — inside the September 2026 cut-off of 0.17 miles" — which asks the + // reader to convert between units to check a comparison already made for + // them. Miles throughout: the unit councils publish and quote. + const headline = (await result.textContent()) ?? ''; + const verdictLine = headline.split('.')[0]; + expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/); + expect((verdictLine.match(/miles/g) ?? []).length, + `verdict should quote two figures in miles: ${verdictLine}`).toBe(2); } }); diff --git a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts index 09278ae..2f7e640 100644 --- a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts +++ b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts @@ -7,7 +7,7 @@ * place — so both are pinned here rather than left to the component. */ -import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; +import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils'; import { describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M, } from '@/components/school/lastDistanceOffered'; @@ -32,8 +32,19 @@ describe('formatCutoffDistance', () => { 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('stays in miles at short range, where it used to swap to metres', () => { + // The swap made a single number easier to read and a comparison harder: + // "69 m away — inside the cut-off of 0.17 miles" asked the reader to + // convert between units to check a claim we had already made for them. + expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' }); + expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/); + }); + + it('describes a distance too short for two decimal places', () => { + // Rather than a flat "0.00 miles", which reads as no distance at all. + expect(formatMiles(5)).toBe('under 0.01 miles'); + expect(formatMiles(0)).toBe('under 0.01 miles'); + expect(formatMiles(20)).toBe('0.01 miles'); }); it('returns null rather than a zero cut-off', () => { @@ -84,6 +95,36 @@ describe('describeCutoff', () => { // ── "Would we have got in?" ──────────────────────────────────────────── describe('compareToCutoff', () => { + it('never states the two figures in different units', () => { + /* + * The reported defect: "69 m away — inside the September 2026 cut-off of + * 0.17 miles". Both numbers are correct and the sentence is still useless, + * because checking it means converting one of them. + * + * Swept across the range where the old formatter switched units, so a + * future readability tweak to one figure cannot reintroduce the mismatch + * in the other. + */ + const mixed: string[] = []; + for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) { + for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) { + const { headline } = compareToCutoff(homeM, cutoffM, 2026); + const hasMetres = /\d\s?m\b/.test(headline); + const milesCount = (headline.match(/miles/g) ?? []).length; + // Two figures, both in miles, and no metric reading anywhere near them. + if (hasMetres || milesCount !== 2) { + mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`); + } + } + } + expect(mixed).toEqual([]); + }); + + it('reads back the reported case in one unit', () => { + expect(compareToCutoff(69, 270, 2026).headline) + .toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.'); + }); + it('calls a clearly nearer home inside, and names the year', () => { const r = compareToCutoff(300, 800, 2026); expect(r.verdict).toBe('inside'); diff --git a/nextjs-app/components/school/lastDistanceOffered.ts b/nextjs-app/components/school/lastDistanceOffered.ts index a3cb6e4..cf90a39 100644 --- a/nextjs-app/components/school/lastDistanceOffered.ts +++ b/nextjs-app/components/school/lastDistanceOffered.ts @@ -16,7 +16,7 @@ */ import type { SchoolAdmissionDistance } from '@/lib/types'; -import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; +import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils'; export interface CutoffDisplay { /** Headline figure, e.g. "0.31 miles". */ @@ -108,8 +108,13 @@ export function compareToCutoff( const verdict: CutoffVerdict = Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside'; - const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`; - const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} m`; + // Both sides through the same formatter, with no fallback that could reach + // for a different unit: the sentence compares these two numbers directly, so + // they have to be in the same one. formatCutoffDistance returns null for a + // zero or negative figure, and its old "N m" fallback here was one of the + // ways a metres reading used to appear next to a miles one. + const label = formatMiles(distanceM); + const cutoffLabel = formatMiles(cutoffM); // "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles // cut-off for September 2026": the figure carries its own unit word, which diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 45f3e78..2d7e931 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri const METRES_PER_MILE = 1609.344; /** - * Format an admission cut-off distance. + * A distance in miles, the unit UK school admissions is conducted in. * - * Miles lead because that is the unit councils publish in — 90% of the source - * rows are miles — and because it is the unit a parent has already been quoted - * if they have read their council's booklet. The metric figure follows as - * support rather than replacing it. + * Councils publish cut-offs in miles — 90% of the collected source rows — and + * it is the unit a parent has already been quoted in their council's booklet + * and offer letter. Everything a reader is asked to compare therefore renders + * through this one function, so two figures in the same sentence can never + * arrive in different units. * - * Below 100 m the miles figure stops carrying information ("0.03 miles"), so - * the two swap round and metres lead instead. + * That was not previously true. This formatter used to swap to metres below + * 100m, on the reasoning that "0.04 miles" carries less for a reader than + * "69 m". Taken one figure at a time that holds; taken in a sentence it + * produced "69 m away — inside the September 2026 cut-off of 0.17 miles", + * which asks the reader to convert between units to understand a comparison we + * had already made for them. Legibility of a single number lost to coherence + * of the pair. + * + * Below 0.01 miles the two decimal places run out rather than the unit being + * wrong, so the figure is described instead of rounded to a flat "0.00 miles". + */ +export function formatMiles(metres: number): string { + const miles = metres / METRES_PER_MILE; + if (miles < 0.01) return 'under 0.01 miles'; + return `${miles.toFixed(2)} miles`; +} + +/** + * Format an admission cut-off distance: the headline figure in miles, and a + * metric equivalent to support it. + * + * The metric figure is support, not an alternative — it appears beside the + * miles figure, never instead of it, so nothing a reader compares is ever in + * two units at once. */ export function formatCutoffDistance( metres: number | null | undefined ): { primary: string; secondary: string } | null { if (metres == null || !Number.isFinite(metres) || metres <= 0) return null; - const miles = metres / METRES_PER_MILE; - - if (metres < 100) { - return { - primary: `${Math.round(metres)} m`, - secondary: `${miles.toFixed(2)} miles`, - }; - } - return { - primary: `${miles.toFixed(2)} miles`, + primary: formatMiles(metres), secondary: metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`,