feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s

Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.

There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.

Data
  * tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
    (urn, year, school_name), because school_name carries the admission
    route: (urn, year) alone collides on 118 keys and a reload would have
    silently dropped every band but one.
  * stg_school_distance applies a 25 m – 25 km plausibility band. The source
    contains 0.0-mile rows (published where a school filled on a higher
    criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
    all of which would put a visibly wrong number on a live page.
  * fact_admission_distance collapses routes to one row per school per year
    using the furthest, and keeps route_count so the page can say the figure
    is the widest of several bands rather than the one for a given child.

Serving
  * Kept out of fact_admissions: that mart is EES-derived and near-complete
    for England, this one covers 57 LAs, and the two refresh independently.
  * Latest year only. Coverage is ragged — a school may have 2021 and 2026
    and nothing between — so a history array would invite a trend line drawn
    through gaps that are absences of publication, not of a cut-off.
  * The Admissions section now renders on either source. 3% of the schools
    that render have a cut-off and no EES admissions row, and gating on
    admissions alone would have hidden the figure on those pages.

Interface
  * The year travels with the figure everywhere it appears; a cut-off
    detached from its admissions round is not a fact about anything.
  * "Not a fixed catchment — it moves every year" sits under every instance,
    because that is the inference a parent will otherwise draw.
  * Replaces a hardcoded "Historical distance cut-off data is not available
    for this school" that appeared on every secondary page, including the
    ones whose council does publish it. The absence is now stated only when
    it is real, and names the authority that would hold it.

The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.

The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.

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-15 22:48:30 +01:00
1 parent 5156a85bd1
commit 88c653215d
31 files changed
+1753 -42

No files matched your search

@@ -12,59 +12,112 @@
*/
import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types';
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function AdmissionsSection({
admissions,
admissionsHistory,
admissionDistance,
isAllThrough,
}: {
admissions: SchoolAdmissions;
/* Nullable: the two data sources are independent. A minority of schools have
a published cut-off distance but no EES admissions row (3% of the schools
that render, sampled against staging), and for those this section exists
to carry the distance alone. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
isAllThrough: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2;
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions);
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
const title = (
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
);
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
) : null;
{/* Spans both columns rather than taking a half-width cell. This is the
figure parents come to the page for, and at tile width the two-line
"0.31 miles / September 2025" pairing wraps badly. */}
const distanceTile = cutoff && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
<dd className={styles.admissionsTileNum}>
{cutoff.primary}
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
</dd>
<dt className={styles.admissionsTileLabel}>
Last distance offered · {cutoff.entryYear}
</dt>
</div>
);
const distanceNote = cutoff && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
);
/*
* Which row template the tile grid needs.
*
* The grid reserves two equal rows so the year view matches the height of the
* trend chart beside it. With a cut-off and no EES admissions there are no
* metric tiles to fill them, and the reserved rows render as a bare block of
* the grid's own gap colour under the distance tile.
*/
const hasMetricTiles = admissions != null && (
admissions.places_offered != null
|| admissions.first_preference_applications != null
|| admissions.first_preference_offer_pct != null
|| admissions.total_applications != null
);
const tilesClass = [
styles.admissionsTiles,
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
].filter(Boolean).join(' ');
const yearView = (
<>
<dl className={styles.admissionsTiles}>
{admissions.places_offered != null && (
<dl className={tilesClass}>
{admissions?.places_offered != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt>
</div>
)}
{admissions.first_preference_applications != null && (
{admissions?.first_preference_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div>
)}
{admissions.first_preference_offer_pct != null && (
{admissions?.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
@@ -81,20 +134,25 @@ export function AdmissionsSection({
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div>
)}
{admissions.total_applications != null && (
{admissions?.total_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div>
)}
{distanceTile}
</dl>
{admissionsSummary.chip && (
{admissionsSummary?.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
{distanceNote}
</>
);
const trendView = (
{/* Guarded rather than asserted: showAdmissionsTrend already requires
admissions, and tying the two together in one expression keeps that
invariant checked by the compiler instead of assumed. */}
const trendView = admissions && (
<>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} />
@@ -13,7 +13,7 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
@@ -34,6 +34,7 @@ export interface PrimarySchoolSectionsProps {
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -42,7 +43,7 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -91,10 +92,15 @@ export function PrimarySchoolSections({
/>
)}
{admissions && (
{/* Either source is enough to justify the section. The cut-off distance
and the EES admissions figures come from different places and a
minority of schools have one without the other — gating on admissions
alone would hide a published distance on those pages. */}
{(admissions || admissionDistance) && (
<AdmissionsSection
admissions={admissions}
admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
isAllThrough={flags.isAllThrough}
/>
)}
@@ -6,17 +6,22 @@
* JavaScript at all. Server component.
*/
import type { School, SchoolAdmissions } from '@/lib/types';
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
admissions, schoolInfo, hasSixthForm,
admissions, admissionDistance, schoolInfo, hasSixthForm,
}: {
admissions: SchoolAdmissions;
/* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null;
admissionDistance: SchoolAdmissionDistance | null;
schoolInfo: School;
hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -40,32 +45,43 @@ export function SecondaryAdmissionsSection({
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
{admissions?.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
{admissions?.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
{admissions?.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
{admissions?.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
{cutoff && (
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
<div className={styles.metricLabel}>
Last distance offered · {cutoff.entryYear}
</div>
<div className={styles.metricValue}>
{cutoff.primary}
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
{admissions?.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
@@ -73,9 +89,23 @@ export function SecondaryAdmissionsSection({
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{/* Replaces a blanket "distance cut-off data is not available for this
school", which was hardcoded onto every secondary page and was untrue
wherever the local authority does publish. The absence is now stated
only when it is real, and names the authority that would hold it. */}
{cutoff ? (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
) : (
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
{schoolInfo.local_authority
? `${schoolInfo.local_authority} 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.
</p>
)}
{hasSixthForm && (
<div className={styles.sixthFormNote}>
@@ -13,7 +13,7 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
@@ -32,6 +32,7 @@ export interface SecondarySchoolSectionsProps {
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionDistance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -40,7 +41,7 @@ export interface SecondarySchoolSectionsProps {
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, deprivation, finance, nationalAvg, flags,
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -79,9 +80,12 @@ export function SecondarySchoolSections({
/>
)}
{admissions && (
{/* See PrimarySchoolSections: distance and EES admissions are independent
sources, so either one warrants the section. */}
{(admissions || admissionDistance) && (
<SecondaryAdmissionsSection
admissions={admissions}
admissionDistance={admissionDistance}
schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm}
/>
@@ -0,0 +1,61 @@
/**
* 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,
};
}
@@ -778,6 +778,38 @@
word-break: break-word;
}
/* Last distance offered, secondary template. These cards are opaque
(--bg-secondary), so the tint can be set directly here — unlike the primary
tile grid, nothing shows through between them. */
.metricCardDistance {
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
border-color: var(--brand);
/* Two columns' worth of width. Its label carries the entry year as well as
the measure, which at the 140px track width of the other cards wraps to
three lines and pushes the figure out of alignment with them. */
grid-column: span 2;
}
.metricCardDistance .metricValue {
flex-direction: column;
gap: 0.1rem;
color: var(--brand-strong);
}
.metricValueSub {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
/* Same measured reason as .admissionsTileDistance above: --text-muted falls to
4.09:1 on the tinted card in dark theme. */
.metricCardDistance .metricLabel,
.metricCardDistance .metricValueSub {
color: var(--text-secondary);
}
.metricHint {
font-size: 0.7rem;
color: var(--text-muted);
@@ -1453,6 +1485,62 @@
color: var(--brand-strong);
}
/* ── Admissions: last distance offered ──
*
* Spans the full grid width instead of taking a fifth half-cell. Two reasons:
* the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap;
* and this is the figure the section is most often opened for, which a
* half-width cell would rank below "Applied in total".
*/
.admissionsTileDistance {
grid-column: 1 / -1;
/*
* --brand-bg is translucent, and the tile grid paints --border between
* cells, so a plain `background` would let the hairline colour show through
* and read as muddy rather than tinted. The opaque card colour is laid down
* first and the tint composited over it as an image.
*/
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
}
.admissionsTileDistance .admissionsTileNum {
color: var(--brand-strong);
}
/*
* The tint costs the muted tokens their AA margin, so this tile does not use
* them.
*
* Measured on the composited backdrop, not the computed one: the tint is a
* background-image over --bg-card, so getComputedStyle reports the untinted
* card and flatters the result. Sampling the rendered pixel in dark theme gives
* a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5
* floor — while --text-secondary is 6.50:1.
*
* The label is where the year lives, so it is the last thing on the tile that
* can afford to be hard to read: a distance without its year is not a fact
* about anything.
*/
.admissionsTileDistance .admissionsTileLabel,
.admissionsTileDistance .admissionsTileSub {
color: var(--text-secondary);
}
/*
* The caveats, not a footnote in the visual-noise sense: without them the
* figure reads as a catchment boundary. Kept at body contrast rather than
* --text-muted for that reason — it qualifies the number above it, so it has
* to be as readable as the number.
*/
.admissionsDistanceNote {
margin: 0.85rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
.admissionsVerdict {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
@@ -1568,6 +1656,20 @@
grid-template-rows: 1fr 1fr;
}
/* The distance tile occupies a third row. Left implicit it would inherit the
1fr sizing of the row template and stretch to match the metric tiles above,
which are twice its content height. */
.admissionsViewYear .admissionsTilesWithDistance {
grid-template-rows: 1fr 1fr auto;
}
/* Cut-off distance but no EES admissions figures: there is nothing to put in
the two metric rows, and leaving them in the template paints an empty block
of the grid's gap colour beneath the tile. */
.admissionsViewYear .admissionsTilesDistanceOnly {
grid-template-rows: auto;
}
.admissionsChartCap {
font-size: 0.8125rem;
font-weight: 600;