feat(admissions): publish the latest cut-off only, holding history back
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
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-20 18:44:57 +01:00
1 parent 50b599a09b
commit c9a1892bfb
20 files changed
+382 -1215

No files matched your search

@@ -60,237 +60,75 @@ export function describeCutoff(
};
}
// ---------------------------------------------------------------------------
// 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
* How close a home has to be to the 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.
* 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' | 'unknown';
export interface CutoffYearComparison {
year: number;
status: CutoffYearStatus;
verdict: CutoffVerdict;
}
export type CutoffVerdict = 'inside' | 'outside' | 'too-close';
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;
verdict: CutoffVerdict;
headline: string;
detail: 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;
}
export function compareToCutoffs(
/**
* 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,
rows: CutoffYearRow[],
cutoffM: number,
year: number,
): 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 margin = cutoffM - distanceM;
const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
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`;
const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} 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`;
}
// "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 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.`);
}
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,
years,
insideCount: inside.length,
comparableCount: comparable.length,
headline,
detail: parts.join(' '),
};
return { distanceM, distanceLabel: label, verdict, headline, detail };
}
/**
@@ -324,6 +162,12 @@ export const CUTOFF_CHECK_CAVEAT =
// 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;
@@ -361,30 +205,3 @@ export function describeCutoffAbsence({
: '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;
}