/** * 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, 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, }; } // --------------------------------------------------------------------------- // 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 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 // --------------------------------------------------------------------------- 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.'; } /** * Whether there is enough of a record to justify a section of its own. * * Two published years is the floor: one is a fact the Admissions tile already * states, and a chart of a single point invites a trend reading that is not * there. Shared so the section, its nav entry and the detail component cannot * disagree about when it exists — a nav link to a section that did not render * is exactly the failure this codebase keeps warning about. */ export function hasCutoffDetail(rows: CutoffYearRow[]): boolean { return rows.filter((r) => r.status === 'published').length >= 2; }