/** * Shared logic and copy for the "last distance offered" figure. * * The primary and secondary admissions sections lay their metrics out * differently (a seamless tile grid vs. metric cards), so the markup is not * shared — but the words are. Every caveat below is doing a specific job, and * a figure that appeared on one template without them would be misleading in a * way the other template was not: * * * The year, because a cut-off is the outcome of one particular admissions * round and means nothing detached from it. * * "not a fixed catchment", because that is the inference a parent will * otherwise draw, and it is wrong — the distance moves every year. * * The route count, because on a banded school the headline is the widest * of several cut-offs and does not apply to every child. */ import type { SchoolAdmissionDistance } from '@/lib/types'; import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils'; export interface CutoffDisplay { /** Headline figure, e.g. "0.31 miles". */ primary: string; /** Supporting conversion, e.g. "500 m". */ secondary: string; /** Entry point the figure belongs to, e.g. "September 2025". */ entryYear: string; /** Present only where several admission routes were collapsed into one figure. */ routeNote: string | null; } /** What the cut-off measures, and what it does not. Identical on both templates. */ export const CUTOFF_NOTE = 'The furthest home offered a place, after higher priorities such as siblings, ' + 'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' + 'with the number of applications.'; /** Straight-line, because that is how councils measure it. */ export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.'; export function describeCutoff( distance: SchoolAdmissionDistance | null | undefined ): CutoffDisplay | null { if (!distance) return null; const figure = formatCutoffDistance(distance.distance_m); if (!figure) return null; const routes = distance.route_count ?? 1; return { primary: figure.primary, secondary: figure.secondary, entryYear: formatEntryYear(distance.year), routeNote: routes > 1 ? `Furthest of ${routes} admission routes at this school — the one that ` + 'applies to your child may have had a shorter cut-off.' : null, }; } // --------------------------------------------------------------------------- // "Would we have got in?" // --------------------------------------------------------------------------- /** * How close a home has to be to the cut-off before the comparison stops * meaning anything, in metres. * * postcodes.io returns the centroid of a postcode unit covering roughly fifteen * addresses, 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'; export interface CutoffCheckResult { /** Straight-line metres from the given postcode to the school. */ distanceM: number; distanceLabel: string; verdict: CutoffVerdict; headline: string; /** Why we cannot call it, on the one verdict that needs explaining. Kept * apart from the headline so it does not run at headline weight. */ detail: string | null; } /** * Compare a home against the one cut-off we publish. * * Only the latest year is compared because only the latest year is served: * earlier years are held back as a paid feature and no longer leave the API. * A single year makes the verdict sharper to state and easier to qualify — one * distance, one year, one margin — but it also removes the reassurance a run of * years gave, so the year is named in the headline rather than left implied. */ export function compareToCutoff( distanceM: number, cutoffM: number, year: number, ): CutoffCheckResult { const margin = cutoffM - distanceM; const verdict: CutoffVerdict = Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside'; // 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 // reads wrong used attributively. const cutoffPhrase = `the September ${year} cut-off of ${cutoffLabel}`; const headline = verdict === 'inside' ? `${label} away — inside ${cutoffPhrase}.` : verdict === 'outside' ? `${label} away — beyond ${cutoffPhrase}.` : `${label} away — too close to ${cutoffPhrase} to call.`; const detail = verdict === 'too-close' ? 'Your postcode is a centroid covering several addresses, so a margin ' + `under ${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.` : null; return { distanceM, distanceLabel: label, verdict, headline, detail }; } /** * The limits of the figure, and of the check made against it. * * One caveat, rendered once at the end of the section. It was previously three * paragraphs — under the map, under the check, and a trailing "not a catchment" * line — which took ~180px between them, said walking-route twice, and made * the same point about priorities in two voices. * * Phrased to stand up whether or not the postcode check is on the page: it * opens on the figure rather than on "your result", because a school with no * coordinates renders the table with no check beneath it. * * Every claim is still here: * * distance is the last criterion applied, not the first; * * the figures and rings are straight-line, and not a boundary; * * some authorities measure a walking route, always longer for the same home; * * a past cut-off constrains next year's not at all. */ export const CUTOFF_CHECK_CAVEAT = 'Distance is the last criterion applied. 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. Figures are straight-line distances ' + 'and not a catchment boundary; some authorities measure a walking route ' + "instead, which is always longer for the same home. Next year's cut-off " + "depends on next year's applicants — always check the school's own " + 'admissions policy.'; // --------------------------------------------------------------------------- // When there is no figure // --------------------------------------------------------------------------- /** The slice of an admissions year this file needs. */ interface AdmissionsYearLike { year: number; oversubscribed?: boolean | null; } 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.'; }