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

+89 -134
View File
@@ -1,33 +1,36 @@
'use client';
/**
* CutoffMapPanel — "Where the last place went".
* CutoffMapPanel — "How far away are you?"
*
* The rings and the postcode check live in one component because they are one
* question asked twice: the form answers "how far are we?" and the map shows
* that answer against the cut-offs. Entering a postcode drops a pin on the same
* rings rather than producing a separate verdict elsewhere on the page.
* Measures a postcode against the one cut-off we publish, and will draw that
* cut-off as a ring around the school on request.
*
* The map is not rendered until asked for. Before a postcode is entered it is a
* It used to compare against every published year and show a set of shrinking
* rings. Earlier years are now held back as a paid feature and no longer leave
* the API, so this answers one question about one year — which makes the
* verdict sharper to state, and puts more weight on qualifying it properly,
* since there is no run of years left to soften a single close call.
*
* The map is not rendered until asked for: before a postcode is entered it is a
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
* section height to say that; a successful check opens it automatically,
* because that is the point at which it starts answering something.
* height to say that. A successful check opens it automatically, because that
* is the point at which it starts answering something.
*
* The postcode never leaves the browser except to postcodes.io for a lat/long,
* and nothing is stored — this is a client-side measurement, not a lookup
* against the family.
*/
import { useState, useMemo, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import dynamic from 'next/dynamic';
import type { School } from '@/lib/types';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils';
import {
compareToCutoffs,
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
compareToCutoff, CUTOFF_CHECK_CAVEAT,
type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered';
import type { CutoffRing } from '../LeafletCutoffMapInner';
import styles from './schoolSections.module.css';
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
@@ -35,65 +38,35 @@ const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
});
const VERDICT_MARK: Record<CutoffVerdict, string> = {
inside: '✓',
outside: '✕',
'too-close': '≈',
unknown: '–',
};
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
inside: 'Inside that year’s cut-off',
outside: 'Outside that year’s cut-off',
'too-close': 'Too close to the cut-off to say',
unknown: 'No published figure for this year',
};
// Written out rather than composed from the verdict string. A computed
// `styles[...]` key silently yields undefined when a class is renamed, and an
// unstyled "outside" chip would look exactly like an "inside" one.
// unstyled "outside" result would look exactly like an "inside" one.
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
inside: styles.cutoffYearInside,
outside: styles.cutoffYearOutside,
'too-close': styles.cutoffYearTooClose,
unknown: styles.cutoffYearUnknown,
inside: styles.cutoffResultInside,
outside: styles.cutoffResultOutside,
'too-close': styles.cutoffResultTooClose,
};
export function CutoffMapPanel({
schoolInfo,
rows,
cutoff,
}: {
schoolInfo: School;
rows: CutoffYearRow[];
cutoff: SchoolAdmissionDistance;
}) {
const [postcode, setPostcode] = useState('');
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
const [result, setResult] = useState<CutoffCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
/* The map is not rendered until it is asked for. It costs a Leaflet bundle
and 240px of section height, and before a postcode is entered it is a
circle drawn round a school — it only starts answering a question once
there is a home to put beside the rings. A successful check therefore
opens it automatically. */
const [mapOpen, setMapOpen] = useState(false);
const lat = schoolInfo.latitude;
const lng = schoolInfo.longitude;
const cutoffM = cutoff.distance_m;
const rings: CutoffRing[] = useMemo(() => {
const published = rows.filter((r) => r.distanceM != null);
return published.map((r, i) => ({
year: r.year,
radiusM: r.distanceM as number,
// rows are newest-first, so the first published row is the current year.
latest: i === 0,
}));
}, [rows]);
// Without coordinates there is nothing to draw rings around and nothing to
// measure a postcode against.
if (lat == null || lng == null || rings.length === 0) return null;
// Without coordinates there is nothing to measure against and nothing to draw.
if (lat == null || lng == null || cutoffM == null) return null;
const onCheck = async (e: FormEvent) => {
e.preventDefault();
@@ -121,7 +94,7 @@ export function CutoffMapPanel({
setMapOpen(true);
// calculateDistance returns kilometres; everything here is metres.
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
setResult(compareToCutoffs(metres, rows));
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
} catch {
setError('Something went wrong looking up that postcode. Try again in a moment.');
setResult(null);
@@ -131,96 +104,78 @@ export function CutoffMapPanel({
}
};
const latestRing = rings[0];
return (
<div className={styles.cutoffMapPanel}>
{/* No "Where the last place went" heading above this any more. It
introduced a map that now sits behind a button, so it named nothing,
and it echoed the section title directly above it. */}
<div className={styles.cutoffCheck}>
<h3 className={styles.cutoffCheckTitle}>How far away are you?</h3>
<p className={styles.cutoffCheckSub}>
Straight-line distance from your postcode, compared with each year’s
cut-off. Not stored.
</p>
<div className={styles.cutoffCheck}>
<p className={styles.cutoffCheckSub}>
Straight-line distance from your postcode, compared with the September{' '}
{cutoff.year} cut-off. Not stored.
</p>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
<input
id="cutoff-postcode"
type="text"
inputMode="text"
autoComplete="postal-code"
spellCheck={false}
placeholder="e.g. SE23 3NA"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
className={styles.cutoffCheckInput}
/>
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
{busy ? 'Checking…' : 'Check'}
</button>
</form>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
<input
id="cutoff-postcode"
type="text"
inputMode="text"
autoComplete="postal-code"
spellCheck={false}
placeholder="e.g. SE23 3NA"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
className={styles.cutoffCheckInput}
/>
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
{busy ? 'Checking…' : 'Check'}
</button>
</form>
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
{result && (
<div className={styles.cutoffCheckResult} role="status">
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
{result && (
<div
className={`${styles.cutoffCheckResult} ${VERDICT_CLASS[result.verdict]}`}
role="status"
>
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
</div>
)}
<ul className={styles.cutoffYearStrip}>
{[...result.years].reverse().map((y) => (
<li
key={y.year}
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
title={VERDICT_TITLE[y.verdict]}
>
<span className={styles.cutoffYearChipYear}>{y.year}</span>
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
</li>
))}
</ul>
{mapOpen ? (
<div className={styles.cutoffMapReveal}>
<div className={styles.cutoffMapFigure}>
<CutoffMap
lat={lat}
lng={lng}
rings={[{ year: cutoff.year, radiusM: cutoffM, latest: true }]}
home={home}
interactive={false}
/>
</div>
)}
{mapOpen ? (
<div className={styles.cutoffMapReveal}>
<div className={styles.cutoffMapFigure}>
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
</div>
<ul className={styles.cutoffMapLegend}>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
September {cutoff.year} cut-off
</li>
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
{latestRing.year} cut-off
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
{rings.length > 1 && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
</li>
)}
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
</div>
) : (
<button
type="button"
className={styles.cutoffMapToggle}
onClick={() => setMapOpen(true)}
>
Show these distances on a map
</button>
)}
)}
</ul>
</div>
) : (
<button
type="button"
className={styles.cutoffMapToggle}
onClick={() => setMapOpen(true)}
>
Show this distance on a map
</button>
)}
</div>
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
</div>
);
}