PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page is a section with a nav entry, and "how close did we need to live, and would we have got in?" is a topic, not a variant reading of the intake figures. The headline number stays on the Admissions tile where the intake story is; the record behind it now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
248 lines
11 KiB
TypeScript
248 lines
11 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';
|
||
import { cutoffYearRows, hasCutoffDetail } from '@/components/school/lastDistanceOffered';
|
||
|
||
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;
|
||
/** Drives the separate Distance section. Gated through hasCutoffDetail, the
|
||
* same predicate the section itself uses, so the nav can never link to an
|
||
* anchor that was not rendered. */
|
||
admissionDistanceHistory?: SchoolAdmissionDistance[];
|
||
admissionsHistory?: SchoolAdmissions[];
|
||
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,
|
||
admissionDistanceHistory, admissionsHistory, 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 (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
|
||
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' });
|
||
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,
|
||
admissionDistanceHistory, admissionsHistory, 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 (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
|
||
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' });
|
||
return navItems;
|
||
}
|