These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
) : 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 && (
+
+ {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
+ {cutoff.routeNote && <> {cutoff.routeNote}>}
+
+ );
+
+ /*
+ * 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 = (
<>
-
- {admissions.places_offered != null && (
+
+ {admissions?.places_offered != null && (
- {admissions.places_offered}
- Places offered
)}
- {admissions.first_preference_applications != null && (
+ {admissions?.first_preference_applications != null && (
- {admissions.first_preference_applications}
- Wanted it first
)}
- {admissions.first_preference_offer_pct != null && (
+ {admissions?.first_preference_offer_pct != null && (
-
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
@@ -81,20 +134,25 @@ export function AdmissionsSection({
- Got their first choice
)}
- {admissions.total_applications != null && (
+ {admissions?.total_applications != null && (
- {admissions.total_applications.toLocaleString()}
- Applied in total
)}
+ {distanceTile}
- {admissionsSummary.chip && (
+ {admissionsSummary?.chip && (
{admissionsSummary.chip.text}
)}
+ {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 && (
<>
First-choice offer rate
diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx
index 2cafb22..b2bff36 100644
--- a/nextjs-app/components/school/PrimarySchoolSections.tsx
+++ b/nextjs-app/components/school/PrimarySchoolSections.tsx
@@ -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) && (
)}
diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
index bd0fbf8..7e8fdb3 100644
--- a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
+++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
@@ -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({
)}
- {admissions.places_offered != null && (
+ {admissions?.places_offered != null && (
Year 7 places offered
{admissions.places_offered}
)}
- {admissions.total_applications != null && (
+ {admissions?.total_applications != null && (
Total applications
{admissions.total_applications.toLocaleString()}
)}
- {admissions.first_preference_applications != null && (
+ {admissions?.first_preference_applications != null && (
1st preference applications
{admissions.first_preference_applications.toLocaleString()}
)}
- {admissions.first_preference_offer_pct != null && (
+ {admissions?.first_preference_offer_pct != null && (
Families who got their first choice
{formatPercentage(admissions.first_preference_offer_pct)}
)}
+ {cutoff && (
+
+
+ Last distance offered · {cutoff.entryYear}
+
+
+ {cutoff.primary}
+ {cutoff.secondary}
+
+
+ )}
- {admissions.oversubscribed != null && (
+ {admissions?.oversubscribed != null && (
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
@@ -73,9 +89,23 @@ export function SecondaryAdmissionsSection({
)}
-
- Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
-
+ {/* 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 ? (
+
+ {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
+ {cutoff.routeNote && <> {cutoff.routeNote}>}
+
+ ) : (
+
+ {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.
+
+ )}
{hasSixthForm && (
diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx
index 4dd0925..9c95439 100644
--- a/nextjs-app/components/school/SecondarySchoolSections.tsx
+++ b/nextjs-app/components/school/SecondarySchoolSections.tsx
@@ -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) && (
diff --git a/nextjs-app/components/school/lastDistanceOffered.ts b/nextjs-app/components/school/lastDistanceOffered.ts
new file mode 100644
index 0000000..5523384
--- /dev/null
+++ b/nextjs-app/components/school/lastDistanceOffered.ts
@@ -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,
+ };
+}
diff --git a/nextjs-app/components/school/schoolSections.module.css b/nextjs-app/components/school/schoolSections.module.css
index 6ea4edc..a9f4515 100644
--- a/nextjs-app/components/school/schoolSections.module.css
+++ b/nextjs-app/components/school/schoolSections.module.css
@@ -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;
diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts
index e11b614..4a4dbc5 100644
--- a/nextjs-app/lib/schoolSections.ts
+++ b/nextjs-app/lib/schoolSections.ts
@@ -9,7 +9,7 @@
import type {
School, SchoolResult, AbsenceData, SchoolCensus,
- OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
+ OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
} from './types';
import { isSpecialSchool } from './utils';
@@ -119,6 +119,9 @@ export function computeSchoolFlags({
export interface NavItemsInput {
ofsted: OfstedInspection | null;
admissions: SchoolAdmissions | null;
+ /** A published cut-off distance renders the Admissions section on its own,
+ * so it has to open the nav entry too. */
+ admissionDistance: SchoolAdmissionDistance | null;
yearlyDataLength: number;
}
@@ -133,7 +136,7 @@ export interface NavItemsInput {
*/
export function buildNavItems(
flags: SchoolFlags,
- { ofsted, admissions, yearlyDataLength }: NavItemsInput,
+ { ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -143,7 +146,7 @@ export function buildNavItems(
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
});
}
- if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
+ if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
@@ -213,12 +216,12 @@ export function computeSecondaryFlags({
*/
export function buildSecondaryNavItems(
flags: SecondaryFlags,
- { ofsted, admissions, yearlyDataLength }: NavItemsInput,
+ { ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
- if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
+ if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index 5778f06..32ff142 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -163,6 +163,30 @@ export interface SchoolAdmissions {
}
+/**
+ * Last distance offered — how far from the school the last child to be given a
+ * place that year lived.
+ *
+ * Not a catchment: it is the outcome of one year's applications and moves every
+ * year. Only published by the local authorities that choose to, so most schools
+ * have none of this at all.
+ */
+export interface SchoolAdmissionDistance {
+ /** Entry year the cut-off belongs to — always shown alongside the figure. */
+ year: number;
+ /** Straight-line distance in metres. */
+ distance_m: number | null;
+ /**
+ * Admission routes collapsed into this figure (ability bands, or separate
+ * reception and junior intakes). Above 1 the distance is the furthest of
+ * several, which the page has to say out loud.
+ */
+ route_count: number | null;
+ la_name: string | null;
+ /** The unit the council published in ("miles", "meters", "km"). */
+ distance_unit_raw: string | null;
+}
+
export interface SchoolDeprivation {
lsoa_code: string | null;
idaci_score: number | null;
@@ -330,6 +354,8 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
+ /** Latest published cut-off distance, or null where the LA publishes none. */
+ admission_distance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts
index fcbaf41..45f3e78 100644
--- a/nextjs-app/lib/utils.ts
+++ b/nextjs-app/lib/utils.ts
@@ -502,6 +502,54 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
}
}
+const METRES_PER_MILE = 1609.344;
+
+/**
+ * Format an admission cut-off distance.
+ *
+ * Miles lead because that is the unit councils publish in — 90% of the source
+ * rows are miles — and because it is the unit a parent has already been quoted
+ * if they have read their council's booklet. The metric figure follows as
+ * support rather than replacing it.
+ *
+ * Below 100 m the miles figure stops carrying information ("0.03 miles"), so
+ * the two swap round and metres lead instead.
+ */
+export function formatCutoffDistance(
+ metres: number | null | undefined
+): { primary: string; secondary: string } | null {
+ if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
+
+ const miles = metres / METRES_PER_MILE;
+
+ if (metres < 100) {
+ return {
+ primary: `${Math.round(metres)} m`,
+ secondary: `${miles.toFixed(2)} miles`,
+ };
+ }
+
+ return {
+ primary: `${miles.toFixed(2)} miles`,
+ secondary: metres < 1000
+ ? `${Math.round(metres / 10) * 10} m`
+ : `${(metres / 1000).toFixed(1)} km`,
+ };
+}
+
+/**
+ * The entry point a cut-off belongs to, phrased the way councils phrase it.
+ *
+ * The distance year is a plain entry year (2025 = the September 2025 intake),
+ * not the six-digit academic year EES uses, so formatAcademicYear would render
+ * it as "2025/26" and invite the reader to think of it as a school year rather
+ * than an application round.
+ */
+export function formatEntryYear(year: number | null | undefined): string {
+ if (year == null) return '';
+ return `September ${year}`;
+}
+
export function formatAcademicYear(year: number | null | undefined): string {
if (year == null) return '';
const s = year.toString();
diff --git a/pipeline/dags/school_data_pipeline.py b/pipeline/dags/school_data_pipeline.py
index 0278b5d..82ad099 100644
--- a/pipeline/dags/school_data_pipeline.py
+++ b/pipeline/dags/school_data_pipeline.py
@@ -224,3 +224,68 @@ with DAG(
)
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
+
+
+# ── Annual DAG (Last distance offered) ────────────────────────────────
+
+with DAG(
+ dag_id="school_data_annual_distance",
+ default_args=default_args,
+ description="Last distance offered (LA admission cut-offs) extraction and transform",
+ # Councils publish on allocation day (March for secondary, April for
+ # primary) and each one on its own timetable, so there is no date worth
+ # scheduling against. Triggered manually after a collection run refreshes
+ # the CSV.
+ schedule=None,
+ start_date=datetime(2025, 1, 1),
+ catchup=False,
+ tags=["school-compare", "annual"],
+) as annual_distance_dag:
+
+ extract_distance = BashOperator(
+ task_id="extract_distance",
+ bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres",
+ )
+
+ # Coverage is the thing that silently rots here: the CSV is assembled by
+ # hand from council publications, so a collection run that half-failed
+ # produces a valid file with a fraction of the schools in it. A row count
+ # alone would not catch that — losing an entire local authority leaves the
+ # total looking healthy — so the floor is checked on distinct LAs too.
+ validate_distance = BashOperator(
+ task_id="validate_distance",
+ bash_command=f"""
+ cd {PIPELINE_DIR} && python -c "
+import psycopg2, os, sys
+conn = psycopg2.connect(
+ host=os.environ.get('PG_HOST', 'localhost'),
+ port=os.environ.get('PG_PORT', '5432'),
+ user=os.environ.get('PG_USER', 'postgres'),
+ password=os.environ.get('PG_PASSWORD', 'postgres'),
+ dbname=os.environ.get('PG_DATABASE', 'school_compare'),
+)
+cur = conn.cursor()
+cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered')
+rows, urns, las = cur.fetchone()
+conn.close()
+print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities')
+if rows < 7000 or urns < 3000 or las < 45:
+ print('ERROR: distance extract is short of expected coverage '
+ '(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr)
+ sys.exit(1)
+print('Validation passed')
+"
+ """,
+ )
+
+ dbt_build_distance = BashOperator(
+ task_id="dbt_build",
+ bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+",
+ )
+
+ invalidate_cache_distance = BashOperator(
+ task_id="invalidate_cache",
+ bash_command=INVALIDATE_CACHE_CMD,
+ )
+
+ extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance
diff --git a/pipeline/meltano.yml b/pipeline/meltano.yml
index 856ac86..65c8199 100644
--- a/pipeline/meltano.yml
+++ b/pipeline/meltano.yml
@@ -67,6 +67,21 @@ plugins:
pip_url: ./plugins/extractors/tap-uk-idaci
executable: tap-uk-idaci
+ # Last distance offered — admission cut-offs collected from LA
+ # publications. There is no national source for this, so the URL points at
+ # the collected CSV rather than at a government endpoint (same pattern as
+ # the legacy KS2/KS4 URLs above).
+ - name: tap-uk-school-distance
+ namespace: uk_school_distance
+ pip_url: ./plugins/extractors/tap-uk-school-distance
+ executable: tap-uk-school-distance
+ settings:
+ - name: download_url
+ kind: string
+ description: URL of the collected last-distance-offered CSV
+ config:
+ download_url: http://10.0.1.224:8081/filebrowser/api/public/dl/UU_oEqVr?inline=true
+
loaders:
- name: target-postgres
variant: meltanolabs
diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml b/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml
new file mode 100644
index 0000000..b363be1
--- /dev/null
+++ b/pipeline/plugins/extractors/tap-uk-school-distance/pyproject.toml
@@ -0,0 +1,16 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "tap-uk-school-distance"
+version = "0.1.0"
+description = "Singer tap for last-distance-offered admission cut-offs collected from LA admissions publications"
+requires-python = ">=3.10"
+dependencies = [
+ "singer-sdk~=0.53",
+ "requests>=2.31",
+]
+
+[project.scripts]
+tap-uk-school-distance = "tap_uk_school_distance.tap:TapUKSchoolDistance.cli"
diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py
new file mode 100644
index 0000000..c4c3617
--- /dev/null
+++ b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/__init__.py
@@ -0,0 +1 @@
+"""tap-uk-school-distance: Singer tap for LA "last distance offered" admission cut-offs."""
diff --git a/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py
new file mode 100644
index 0000000..2445141
--- /dev/null
+++ b/pipeline/plugins/extractors/tap-uk-school-distance/tap_uk_school_distance/tap.py
@@ -0,0 +1,135 @@
+"""Singer tap for "last distance offered" school admission cut-offs.
+
+Unlike every other tap in this project, the upstream here is not a single
+government endpoint. There is no national dataset of admission cut-off
+distances: each local authority publishes its own, in its own format
+(PDF booklets, XLSX allocation tables, HTML pages), and only some publish
+at all. The CSV this tap reads is the output of that collection work —
+one row per school per year per admission route, already normalised to
+metres in `distance_m`.
+
+The tap deliberately does no cleaning. Implausible values (0.0 miles,
+1-metre cut-offs, a 533-mile outlier) are loaded verbatim into `raw` and
+filtered in `stg_school_distance`, so the raw table stays a faithful
+record of what the councils published and the plausibility rules live in
+one reviewable place.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+
+import requests
+from singer_sdk import Stream, Tap
+from singer_sdk import typing as th
+
+REQUEST_TIMEOUT = 300
+
+
+class SchoolDistanceStream(Stream):
+ """Stream: last distance offered, one row per school × year × admission route."""
+
+ name = "school_distance_offered"
+ # school_name is part of the key, not decoration: where a school admits
+ # through several routes the route is encoded in the name ("… Band 1",
+ # "… Band 2"), and (urn, year) alone is not unique. Verified against the
+ # source: (urn, year) collides on 118 keys, (urn, year, school_name) on
+ # none. Without school_name in the key a reload would silently drop the
+ # other bands.
+ primary_keys = ["urn", "year", "school_name"]
+ replication_key = None
+
+ schema = th.PropertiesList(
+ th.Property("urn", th.IntegerType, required=True),
+ th.Property("year", th.IntegerType, required=True),
+ th.Property("school_name", th.StringType, required=True),
+ th.Property("la_code", th.IntegerType),
+ th.Property("la_name", th.StringType),
+ # The figure as the council published it, before unit conversion —
+ # kept so a surprising metre value can be traced back to "0.31 miles"
+ # without reopening the source PDF.
+ th.Property("distance_value_raw", th.NumberType),
+ th.Property("distance_unit_raw", th.StringType),
+ th.Property("distance_m", th.NumberType),
+ # Path of the council publication the row was read from. This is the
+ # provenance trail for a figure we cannot re-derive from an API.
+ th.Property("source_file", th.StringType),
+ ).to_dict()
+
+ @staticmethod
+ def _num(value: str) -> float | None:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+ @staticmethod
+ def _int(value: str) -> int | None:
+ try:
+ return int(float(value))
+ except (TypeError, ValueError):
+ return None
+
+ def get_records(self, context):
+ url = self.config["download_url"]
+ self.logger.info("Downloading school distance data from %s", url)
+
+ resp = requests.get(url, timeout=REQUEST_TIMEOUT)
+ resp.raise_for_status()
+
+ reader = csv.DictReader(io.StringIO(resp.text))
+
+ yielded = 0
+ skipped = 0
+ for row in reader:
+ urn = self._int(row.get("urn"))
+ year = self._int(row.get("year"))
+ name = (row.get("school_name") or "").strip()
+
+ # Every part of the primary key must be present, or the load is
+ # not idempotent. One row in the source has a blank year.
+ if urn is None or year is None or not name:
+ skipped += 1
+ continue
+
+ yield {
+ "urn": urn,
+ "year": year,
+ "school_name": name,
+ "la_code": self._int(row.get("la_code")),
+ "la_name": (row.get("la_name") or "").strip() or None,
+ "distance_value_raw": self._num(row.get("distance_value_raw")),
+ "distance_unit_raw": (row.get("distance_unit_raw") or "").strip() or None,
+ "distance_m": self._num(row.get("distance_m")),
+ "source_file": (row.get("source_file") or "").strip() or None,
+ }
+ yielded += 1
+
+ self.logger.info(
+ "Yielded %d distance records (%d skipped: missing urn, year or school name)",
+ yielded,
+ skipped,
+ )
+
+
+class TapUKSchoolDistance(Tap):
+ """Singer tap for LA-published admission cut-off distances."""
+
+ name = "tap-uk-school-distance"
+
+ config_jsonschema = th.PropertiesList(
+ th.Property(
+ "download_url",
+ th.StringType,
+ required=True,
+ description="URL of the collected last-distance-offered CSV",
+ ),
+ ).to_dict()
+
+ def discover_streams(self):
+ return [SchoolDistanceStream(self)]
+
+
+if __name__ == "__main__":
+ TapUKSchoolDistance.cli()
diff --git a/pipeline/transform/models/marts/_marts_schema.yml b/pipeline/transform/models/marts/_marts_schema.yml
index 9869885..1aa86c1 100644
--- a/pipeline/transform/models/marts/_marts_schema.yml
+++ b/pipeline/transform/models/marts/_marts_schema.yml
@@ -156,6 +156,22 @@ models:
- name: cross_la_offers
- name: total_offers
+ - name: fact_admission_distance
+ description: >
+ Last distance offered — one row per URN per year. Where a school admits
+ through several routes (ability bands, separate reception/junior
+ intakes) the figure is the furthest of them and route_count says how
+ many were collapsed.
+ columns:
+ - name: urn
+ tests: [not_null]
+ - name: year
+ tests: [not_null]
+ - name: distance_m
+ tests: [not_null]
+ - name: route_count
+ tests: [not_null]
+
- name: fact_finance
description: School financial data — one row per URN per year
columns:
diff --git a/pipeline/transform/models/marts/fact_admission_distance.sql b/pipeline/transform/models/marts/fact_admission_distance.sql
new file mode 100644
index 0000000..dc413c4
--- /dev/null
+++ b/pipeline/transform/models/marts/fact_admission_distance.sql
@@ -0,0 +1,50 @@
+-- Mart: last distance offered — one row per URN per year.
+--
+-- Collapses the per-route grain of stg_school_distance to the grain the site
+-- serves. Where a school admits through several routes the figure shown is the
+-- FURTHEST of them, which is what "last distance offered" literally means: the
+-- distance of the last place the school gave out that year.
+--
+-- route_count travels with it so the page can say so. A parent looking at a
+-- banded secondary needs to know that 2.4 miles is the widest of four bands
+-- and not the number that applies to their own child — the count is what makes
+-- that sentence possible, and dropping it would make the figure quietly
+-- optimistic.
+--
+-- Kept separate from fact_admissions rather than joined into it. That mart is
+-- EES-derived and near-complete for England; this one comes from 57 local
+-- authorities that publish cut-offs at all, so folding it in would add mostly
+-- null columns to every school in the country and tie two very different
+-- refresh cadences together.
+
+with routes as (
+ select * from {{ ref('stg_school_distance') }}
+),
+
+per_year as (
+ select
+ urn,
+ year,
+ max(distance_m) as distance_m,
+ count(*) as route_count,
+ min(la_code) as la_code,
+ min(la_name) as la_name,
+ -- The unit the council actually published in. Where routes disagree
+ -- (they do not in the current data) the alphabetically first is taken
+ -- rather than an arbitrary row, so the mart is deterministic.
+ min(distance_unit_raw) as distance_unit_raw,
+ min(source_file) as source_file
+ from routes
+ group by urn, year
+)
+
+select
+ urn,
+ year,
+ distance_m,
+ route_count,
+ la_code,
+ la_name,
+ distance_unit_raw,
+ source_file
+from per_year
diff --git a/pipeline/transform/models/staging/_stg_sources.yml b/pipeline/transform/models/staging/_stg_sources.yml
index b390790..6b294c4 100644
--- a/pipeline/transform/models/staging/_stg_sources.yml
+++ b/pipeline/transform/models/staging/_stg_sources.yml
@@ -61,3 +61,11 @@ sources:
- name: idaci
description: Income Deprivation Affecting Children Index lookups
+
+ - name: school_distance_offered
+ description: >
+ Last distance offered (admission cut-offs) transcribed from local
+ authority admissions publications. One row per school × year ×
+ admission route; no national source exists, so coverage is limited to
+ the LAs that publish. Loaded verbatim — implausible values are
+ filtered in stg_school_distance, not here.
diff --git a/pipeline/transform/models/staging/stg_school_distance.sql b/pipeline/transform/models/staging/stg_school_distance.sql
new file mode 100644
index 0000000..705ecdd
--- /dev/null
+++ b/pipeline/transform/models/staging/stg_school_distance.sql
@@ -0,0 +1,55 @@
+-- Staging model: "last distance offered" admission cut-offs.
+--
+-- One row per school × year × admission route. A school admits through
+-- several routes when it bands by ability (Islington publishes four bands per
+-- secondary) or takes more than one intake (Gloucestershire publishes
+-- reception, junior and secondary allocations separately). The route is
+-- carried in school_name, which is why it forms part of the grain here and is
+-- collapsed only in fact_admission_distance.
+--
+-- PLAUSIBILITY BAND (25 m – 25 km)
+--
+-- The source is transcribed from council PDFs, spreadsheets and web pages, and
+-- a minority of rows are transcription or unit errors rather than real
+-- cut-offs. Three failure modes, all present:
+--
+-- * 0.0 miles (34 rows, mostly Staffordshire). Published where a school
+-- filled on a higher criterion so distance was never reached — a real
+-- fact, but "0 metres" read as a cut-off would tell a parent the opposite
+-- of the truth.
+-- * 1–10 metre values (72 rows, e.g. Hillingdon). A cut-off inside the
+-- school gate is not physically possible; these are unit slips.
+-- * Values above 25 km (~240 rows), topping out at 533 miles. Genuine rural
+-- secondary cut-offs reach roughly 12–15 miles, so the ceiling keeps those
+-- while cutting figures that are visibly wrong.
+--
+-- Together these are ~4% of rows. They are loaded verbatim into raw and
+-- excluded here, so the rule is reviewable in one place and the raw table
+-- stays a faithful record of what each council published.
+
+{% set min_plausible_m = 25 %}
+{% set max_plausible_m = 25000 %}
+
+with source as (
+ select * from {{ source('raw', 'school_distance_offered') }}
+),
+
+renamed as (
+ select
+ cast(urn as integer) as urn,
+ cast(year as integer) as year,
+ trim(school_name) as admission_route,
+ cast(la_code as integer) as la_code,
+ la_name,
+ cast(distance_value_raw as numeric) as distance_value_raw,
+ distance_unit_raw,
+ cast(distance_m as numeric) as distance_m,
+ source_file
+ from source
+ where urn is not null
+ and year is not null
+ and distance_m is not null
+ and cast(distance_m as numeric) between {{ min_plausible_m }} and {{ max_plausible_m }}
+)
+
+select * from renamed
diff --git a/pipeline/transform/tests/assert_admission_distance_plausible.sql b/pipeline/transform/tests/assert_admission_distance_plausible.sql
new file mode 100644
index 0000000..45f377c
--- /dev/null
+++ b/pipeline/transform/tests/assert_admission_distance_plausible.sql
@@ -0,0 +1,27 @@
+-- Custom test: every published cut-off distance is inside the plausibility
+-- band, and there is exactly one row per school per year.
+--
+-- The band is enforced in stg_school_distance; this asserts it survived the
+-- max() collapse in the mart. A failure means either the staging filter was
+-- edited without updating this test, or the grain broke — both of which put a
+-- visibly wrong number ("533 miles") on a live school page, which is the one
+-- outcome this feature cannot afford.
+
+with out_of_band as (
+ select urn, year, distance_m, 'out of plausibility band' as problem
+ from {{ ref('fact_admission_distance') }}
+ where distance_m is null
+ or distance_m < 25
+ or distance_m > 25000
+),
+
+duplicated as (
+ select urn, year, max(distance_m) as distance_m, 'duplicate urn/year' as problem
+ from {{ ref('fact_admission_distance') }}
+ group by urn, year
+ having count(*) > 1
+)
+
+select * from out_of_band
+union all
+select * from duplicated