PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.
The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.
Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.
What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.
Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.
One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.
The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
208 lines
8.4 KiB
TypeScript
208 lines
8.4 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// "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';
|
|
|
|
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
|
|
const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} m`;
|
|
|
|
// "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.';
|
|
}
|