Reported from staging: a Catholic primary showed six Catholic primaries, none of them close enough to be a real option, and omitted the community school down the road. Three causes, compounding. Ranking put tier before distance, so a faith match at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule — added so a cap of six would not drag in weak distant matches — filled the row from the best tier before it ever widened, which is what made every card Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city for a primary, whose catchments are routinely under a mile. The premise was backwards. For a parent, distance is a constraint and intake is a preference; a school beyond a primary catchment is not a weaker option, it is not an option. So distance now decides the order and nothing else does. The hard filters are untouched — they were always where the defensibility lived. Similarity survives as chips on the card: reported, so a reader applies their own weighting, rather than ranked, so we apply ours for them. Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity bound, not a target: ordering already handles density, so the cap only decides what happens where an area is sparse. A primary with nothing inside two miles now renders no section, which is the honest answer. Deleted: the tier system, the stopping rule, the tier-dependent lede, the `tier` field, the tier-3 fallback chip and its style. select_similar also stops taking is_secondary — it reads the phase from the subject's own row, so no caller can hand it one that disagrees with the data. The heading is now "Other schools nearby". The hard filters still guarantee a comparable set, but nothing ranks on likeness, so the heading no longer says it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
12 KiB
TypeScript
276 lines
12 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,
|
||
SchoolDestinations, DestinationPhase,
|
||
} 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;
|
||
destinations?: SchoolDestinations | 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;
|
||
/** Whether the school has coordinates — the Distance section needs them to
|
||
* measure a postcode, so the nav must gate on them too or it will link to an
|
||
* anchor that was never rendered. */
|
||
hasLocation?: boolean;
|
||
/** Whether the nearby-schools section will render. Optional for the same
|
||
* reason hasLocation is: the nav must never link to an anchor that was not
|
||
* rendered, and absent has to mean "no section". */
|
||
hasNearbySchools?: boolean;
|
||
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, hasLocation,
|
||
hasNearbySchools, 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 (admissionDistance?.distance_m != null && hasLocation) {
|
||
navItems.push({ id: 'distance', label: 'Distance' });
|
||
}
|
||
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' });
|
||
// Last, because the section renders last — the scroll-spy reads this order.
|
||
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||
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;
|
||
/** Whether a destinations phase has anything to render. A block can exist
|
||
* with empty groups when the pipeline has run but the school has no rows,
|
||
* and a nav entry for a section that never renders links to nothing. */
|
||
hasKs4Destinations: boolean;
|
||
hasKs5Destinations: boolean;
|
||
}
|
||
|
||
/** A phase is renderable only if some pupil group actually carries categories. */
|
||
function phaseHasContent(phase: DestinationPhase | null | undefined): boolean {
|
||
if (!phase) return false;
|
||
return Object.values(phase.groups ?? {})
|
||
.some(group => (group?.categories?.length ?? 0) > 0);
|
||
}
|
||
|
||
export function computeSecondaryFlags({
|
||
schoolInfo, yearlyData, deprivation, finance, destinations,
|
||
}: 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,
|
||
hasKs4Destinations: phaseHasContent(destinations?.ks4),
|
||
hasKs5Destinations: phaseHasContent(destinations?.ks5),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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, hasLocation,
|
||
hasNearbySchools, yearlyDataLength,
|
||
}: NavItemsInput,
|
||
): NavItem[] {
|
||
const navItems: NavItem[] = [];
|
||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||
// Destinations sit straight after attainment: they answer "and then what
|
||
// happened", which only makes sense once the results are in view.
|
||
if (flags.hasKs4Destinations) navItems.push({ id: 'destinations', label: 'After Year 11' });
|
||
if (flags.hasKs5Destinations) {
|
||
navItems.push({ id: 'post16-destinations', label: 'After sixth form' });
|
||
}
|
||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||
if (admissionDistance?.distance_m != null && hasLocation) {
|
||
navItems.push({ id: 'distance', label: 'Distance' });
|
||
}
|
||
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' });
|
||
// Last, because the section renders last — the scroll-spy reads this order.
|
||
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||
return navItems;
|
||
}
|