Files
school_compare/nextjs-app/components/school/lastDistanceOffered.ts
T
TudorandClaude Opus 5 94151c58ea
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
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) Successful in 1m59s
fix(admissions): move the cut-off detail into its own section
The admissions card measured 1503px on a live school page — half the height
of every section put together, and nearly three times the next largest — with
its default view rendering as four tiles adrift in about 1080px of blank card.

The cause was a layout trick meeting content it was never sized for. The
admissions views are stacked in one grid cell so switching them never shifts
layout, and the hidden ones keep their box: only visibility is dropped. That
works while the views are comparable. The distance view added in #102 carries
a chart, a table and a map, came to 1402px against the tile grid's 316px, and
pinned every other view to its height — including the one that renders by
default, which nobody had clicked.

Rather than only unpinning it, the detail moves out. Every other topic on the
page is a section with a nav entry, and "how close did we need to live, and
would we have got in?" is a topic, not a variant reading of the intake
figures. The headline number stays on the Admissions tile where the intake
story is; the record behind it now lives in a Distance section directly below.

  admissions   1503px -> 554px
  distance        new -> 743px   (median section on the page is ~528px)

Three further changes, each of which also makes the content better rather
than only shorter:

  * The map renders on request. Before a postcode is entered it is a circle
    drawn round a school, and it costs a Leaflet bundle and 240px to say so;
    a successful check opens it automatically, which is the point at which it
    starts answering something. Map height 320px -> 240px.
  * The chart appears only at the four published years that let the summary
    state a direction. Below that we already refuse to call the series a
    trend, and a line through three points asserts one regardless of what the
    sentence beneath it admits. The table carries those years anyway, with
    the reasons a line cannot show.
  * Three caveat paragraphs become one. They said walking-route twice and
    made the same point about priorities in two voices. It now sits in
    CutoffDistanceDetail rather than inside the check, so it still renders
    for a school with coordinates missing, where there is a table but no map
    and no check.

The new e2e guard asserts no section exceeds 2.5x the median section height.
Measuring the card's internals cannot catch this: the tile grid is flex: 1,
so it absorbs the stretch and every box still looks full. The first version
of this test targeted an arbitrary primary, passed against the live bug, and
proved nothing; pointed at a school that actually holds cut-off history it
fails on staging with "#admissions is 1459px against a 526px median".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 14:54:02 +01:00

391 lines
15 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,
};
}
// ---------------------------------------------------------------------------
// 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(' '),
};
}
/**
* 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
// ---------------------------------------------------------------------------
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.';
}
/**
* 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;
}