2026-08-15 22:48:30 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-08-20 20:35:05 +01:00
|
|
|
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
2026-08-15 22:48:30 +01:00
|
|
|
|
|
|
|
|
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, ' +
|
2026-09-24 22:04:27 +01:00
|
|
|
'faith and EHCP were applied. It is not a fixed catchment. It moves every year ' +
|
2026-08-15 22:48:30 +01:00
|
|
|
'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
|
2026-09-24 22:04:27 +01:00
|
|
|
? `Furthest of ${routes} admission routes at this school. The one that ` +
|
2026-08-15 22:48:30 +01:00
|
|
|
'applies to your child may have had a shorter cut-off.'
|
|
|
|
|
: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// "Would we have got in?"
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-20 18:44:57 +01:00
|
|
|
* How close a home has to be to the cut-off before the comparison stops
|
2026-08-16 13:42:06 +01:00
|
|
|
* meaning anything, in metres.
|
|
|
|
|
*
|
2026-08-20 18:44:57 +01:00
|
|
|
* 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.
|
2026-08-16 13:42:06 +01:00
|
|
|
*/
|
|
|
|
|
export const CUTOFF_UNCERTAINTY_M = 100;
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
export type CutoffVerdict = 'inside' | 'outside' | 'too-close';
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
export interface CutoffCheckResult {
|
|
|
|
|
/** Straight-line metres from the given postcode to the school. */
|
|
|
|
|
distanceM: number;
|
|
|
|
|
distanceLabel: string;
|
2026-08-20 18:44:57 +01:00
|
|
|
verdict: CutoffVerdict;
|
2026-08-16 13:42:06 +01:00
|
|
|
headline: string;
|
2026-08-20 18:44:57 +01:00
|
|
|
/** 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;
|
2026-08-16 13:42:06 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
/**
|
|
|
|
|
* 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(
|
2026-08-16 13:42:06 +01:00
|
|
|
distanceM: number,
|
2026-08-20 18:44:57 +01:00
|
|
|
cutoffM: number,
|
|
|
|
|
year: number,
|
2026-08-16 13:42:06 +01:00
|
|
|
): CutoffCheckResult {
|
2026-08-20 18:44:57 +01:00
|
|
|
const margin = cutoffM - distanceM;
|
|
|
|
|
const verdict: CutoffVerdict =
|
|
|
|
|
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 20:35:05 +01:00
|
|
|
// 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);
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
// "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'
|
2026-09-24 22:04:27 +01:00
|
|
|
? `${label} away, inside ${cutoffPhrase}.`
|
2026-08-20 18:44:57 +01:00
|
|
|
: verdict === 'outside'
|
2026-09-24 22:04:27 +01:00
|
|
|
? `${label} away, beyond ${cutoffPhrase}.`
|
|
|
|
|
: `${label} away, too close to ${cutoffPhrase} to call.`;
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
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;
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
return { distanceM, distanceLabel: label, verdict, headline, detail };
|
2026-08-16 13:42:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-20 14:54:02 +01:00
|
|
|
* The limits of the figure, and of the check made against it.
|
2026-08-16 13:42:06 +01:00
|
|
|
*
|
2026-08-20 14:54:02 +01:00
|
|
|
* 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.
|
2026-08-16 13:42:06 +01:00
|
|
|
*/
|
|
|
|
|
export const CUTOFF_CHECK_CAVEAT =
|
2026-08-20 14:54:02 +01:00
|
|
|
'Distance is the last criterion applied. Places go first to children in care, '
|
2026-09-24 22:04:27 +01:00
|
|
|
+ 'EHCP places and siblings, and at faith schools by faith criteria, so a home '
|
2026-08-20 14:54:02 +01:00
|
|
|
+ '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 "
|
2026-09-24 22:04:27 +01:00
|
|
|
+ "depends on next year's applicants, so always check the school's own "
|
2026-08-20 14:54:02 +01:00
|
|
|
+ 'admissions policy.';
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// When there is no figure
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
/** The slice of an admissions year this file needs. */
|
|
|
|
|
interface AdmissionsYearLike {
|
|
|
|
|
year: number;
|
|
|
|
|
oversubscribed?: boolean | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:42:06 +01:00
|
|
|
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.';
|
|
|
|
|
}
|