Files
school_compare/nextjs-app/components/school/lastDistanceOffered.ts
T

212 lines
8.7 KiB
TypeScript
Raw Normal View History

/**
* 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, ' +
2026-09-24 22:04:27 +01:00
'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
2026-09-24 22:04:27 +01:00
? `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'
2026-09-24 22:04:27 +01:00
? `${label} away, inside ${cutoffPhrase}.`
: verdict === 'outside'
2026-09-24 22:04:27 +01:00
? `${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, '
2026-09-24 22:04:27 +01:00
+ 'EHCP places and siblings, and at faith schools by 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 "
2026-09-24 22:04:27 +01:00
+ "depends on next year's applicants, so 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.';
}