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
230 lines
9.8 KiB
TypeScript
230 lines
9.8 KiB
TypeScript
/**
|
||
* Derived data-shape logic for the school detail pages.
|
||
*
|
||
* Pure functions with no React dependency, so the server route can decide
|
||
* which sections exist without pulling in a client component. Extracted from
|
||
* SchoolDetailView, which owned this logic inline while it was a client
|
||
* component.
|
||
*/
|
||
|
||
import type {
|
||
School, SchoolResult, AbsenceData, SchoolCensus,
|
||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
||
} from './types';
|
||
import { isSpecialSchool } from './utils';
|
||
|
||
export interface SchoolFlagsInput {
|
||
schoolInfo: School;
|
||
yearlyData: SchoolResult[];
|
||
absenceData: AbsenceData | null;
|
||
census: SchoolCensus | null;
|
||
deprivation: SchoolDeprivation | null;
|
||
finance: SchoolFinance | null;
|
||
}
|
||
|
||
export interface SchoolFlags {
|
||
latestResults: SchoolResult | null;
|
||
isAllThrough: boolean;
|
||
isSecondary: boolean;
|
||
isPrimary: boolean;
|
||
hasGenderSplit: boolean;
|
||
hasInclusionData: boolean;
|
||
hasSchoolLife: boolean;
|
||
hasDeprivation: boolean;
|
||
hasFinance: boolean;
|
||
hasLocation: boolean;
|
||
hasKS2Results: boolean;
|
||
hasKS4Results: boolean;
|
||
hasAnyResults: boolean;
|
||
isSpecial: boolean;
|
||
ks2Placeholder: boolean;
|
||
suppressKs2Comparison: boolean;
|
||
suppressKs4Comparison: boolean;
|
||
}
|
||
|
||
export interface NavItem {
|
||
id: string;
|
||
label: string;
|
||
}
|
||
|
||
export function computeSchoolFlags({
|
||
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
|
||
}: SchoolFlagsInput): SchoolFlags {
|
||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||
|
||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
|
||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||
const phase = schoolInfo.phase ?? '';
|
||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||
const isPrimary = !isSecondary;
|
||
|
||
// Gender split availability (only meaningful for Mixed schools with census data)
|
||
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||
const hasGenderSplit = !!(isMixedSchool
|
||
&& census?.female_pupils != null
|
||
&& census?.male_pupils != null
|
||
&& (census.female_pupils + census.male_pupils) > 0);
|
||
|
||
// Guard for Pupils & Inclusion — only show if at least one metric is available
|
||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||
|| (latestResults?.eal_pct != null)
|
||
|| (latestResults?.sen_support_pct != null)
|
||
|| hasGenderSplit;
|
||
|
||
const hasSchoolLife = absenceData != null;
|
||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||
|
||
// Determine whether this school has KS2 or KS4 results to show
|
||
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
|
||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||
|
||
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||
// reach the mainstream "expected standard", so a 0% headline and an England
|
||
// comparison portray them as failing against a benchmark that doesn't fit.
|
||
const isSpecial = isSpecialSchool(schoolInfo);
|
||
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||
// special/suppressed signature) is a placeholder, not a real result. This
|
||
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||
// combined (some pupils met individual subjects but not all three) stays
|
||
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||
// to form such a signature, so KS4 keys off establishment type only — a
|
||
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||
const ks2Placeholder = latestResults != null
|
||
&& latestResults.rwm_expected_pct === 0
|
||
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||
// Whether to drop the England-average deltas / national markers / "below"
|
||
// framing on the attainment measures.
|
||
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||
const suppressKs4Comparison = isSpecial;
|
||
|
||
return {
|
||
latestResults,
|
||
isAllThrough, isSecondary, isPrimary,
|
||
hasGenderSplit, hasInclusionData, hasSchoolLife,
|
||
hasDeprivation, hasFinance, hasLocation,
|
||
hasKS2Results, hasKS4Results, hasAnyResults,
|
||
isSpecial, ks2Placeholder,
|
||
suppressKs2Comparison, suppressKs4Comparison,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Build section nav items dynamically — only sections with data.
|
||
* Order is engagement-led (from section_nav_used analytics): the most-sought
|
||
* sections — results, admissions, inclusion, history — sit near the top,
|
||
* after the recognised Ofsted badge; low-demand context sections stay last.
|
||
*
|
||
* These conditions MUST match the conditions the section composers use to
|
||
* render, or the nav will link to sections that do not exist.
|
||
*/
|
||
export function buildNavItems(
|
||
flags: SchoolFlags,
|
||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
||
): NavItem[] {
|
||
const navItems: NavItem[] = [];
|
||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||
if (flags.hasAnyResults) {
|
||
navItems.push({
|
||
id: 'results',
|
||
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
||
});
|
||
}
|
||
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' });
|
||
if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||
return navItems;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Secondary pages
|
||
//
|
||
// The secondary view is not a variant of the primary one: it has its own
|
||
// section ids ('gcse', 'wellbeing'), its own flags, and gates History on
|
||
// MORE THAN ONE year rather than at least one. Kept as separate functions so
|
||
// neither phase's behaviour bends to accommodate the other.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface SecondaryFlags {
|
||
latestResults: SchoolResult | null;
|
||
hasSixthForm: boolean;
|
||
hasFinance: boolean;
|
||
hasDeprivation: boolean;
|
||
hasLocation: boolean;
|
||
hasWellbeing: boolean;
|
||
hasResults: boolean;
|
||
/** Progress 8 was suspended from the 2024/25 cohort onwards. */
|
||
p8Suspended: boolean;
|
||
isSpecial: boolean;
|
||
suppressComparison: boolean;
|
||
}
|
||
|
||
export function computeSecondaryFlags({
|
||
schoolInfo, yearlyData, deprivation, finance,
|
||
}: Omit<SchoolFlagsInput, 'absenceData' | 'census'>): SecondaryFlags {
|
||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||
|
||
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
|
||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||
const hasResults = latestResults?.attainment_8_score != null;
|
||
|
||
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||
// so their headline attainment is far below the mainstream average by design.
|
||
// Drop the England comparison + "below" framing so the page doesn't portray
|
||
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||
// mainstream school still shows its real value and comparison.
|
||
const isSpecial = isSpecialSchool(schoolInfo);
|
||
const suppressComparison = isSpecial;
|
||
|
||
return {
|
||
latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation,
|
||
hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build nav items for a secondary page.
|
||
* Engagement-led order (matches the primary page): recognised Ofsted badge,
|
||
* then the most-sought sections — results, admissions, history — with the
|
||
* experience and context sections following.
|
||
*/
|
||
export function buildSecondaryNavItems(
|
||
flags: SecondaryFlags,
|
||
{ 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 || 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' });
|
||
return navItems;
|
||
}
|