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';
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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<number, SchoolAdmissionDistance>();
|
|
|
|
|
for (const d of history ?? []) {
|
|
|
|
|
if (d.distance_m != null) published.set(entryYearOf(d.year), d);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const admissions = new Map<number, AdmissionsYearLike>();
|
|
|
|
|
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(' '),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
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, '
|
|
|
|
|
+ '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.';
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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.';
|
|
|
|
|
}
|
2026-08-20 14:54:02 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|