/** * One place page, shared by all four families. * * They differ in what fills the registry, not in what the page shows, so a * second component would be a second place to forget the same change. * * The local-versus-England comparison is the reason this page is not a list: * it is the one number a parent cannot get by reading the schools one by one, * and it is what keeps the page from reading as a name dropped into a * template. */ import Link from 'next/link'; import type { PlaceDetail, PlaceSummary } from '@/lib/places'; import { placeUrl, authoritySlug } from '@/lib/places'; import type { School } from '@/lib/types'; import { schoolUrl, formatAgeSpan } from '@/lib/utils'; import { absoluteUrl } from '@/lib/site'; import { TrackPlaceView } from './TrackPlaceView'; import styles from './PlaceView.module.css'; interface Props { detail: PlaceDetail; phase?: 'primary' | 'secondary'; englandAverage: number | null; /** Nearby places, so the page links onward instead of dead-ending. */ neighbours: PlaceSummary[]; } // Ofsted grades in the order they are reported. const OFSTED_LABELS: Array<[number, string]> = [ [1, 'Outstanding'], [2, 'Good'], [3, 'Requires improvement'], [4, 'Inadequate'], ]; /** * Column headings, taken from the site's own metric dictionary rather than * invented here — see METRIC_DEFINITIONS in backend/schemas.py, surfaced at * /api/metrics. The first cut said "RWM expected", which is jargon that * appears nowhere else on the site. */ const METRICS = { primary: { key: 'rwm_expected_pct' as const, heading: 'Reading, writing & maths', hint: '% meeting the expected standard in reading, writing and maths', unit: '%', }, secondary: { key: 'attainment_8_score' as const, heading: 'Attainment 8', hint: "Average grade across a pupil's best 8 GCSEs, including English and maths", unit: '', }, }; type PhaseKey = keyof typeof METRICS; /** All-through schools sit in both phases, matching the search filters. */ function isPhase(school: School, phase: PhaseKey): boolean { const p = (school.phase ?? '').toLowerCase(); if (p === 'all-through') return true; return phase === 'secondary' ? p.includes('secondary') || p === '16 plus' : p.includes('primary') || p.includes('middle'); } /* * GIAS spells the absence of a faith as "Does not apply", and sometimes * "None" or "Not applicable" — database answers, not English ones. The school * page and the comparison already suppress all three; this is the same rule, * so the two surfaces cannot disagree about the same school. */ const NO_FAITH = /^(none|does not apply|not applicable)$/i; /** An attribute the data does not carry. Distinct from the measure's "Not * published": four of those per row would drown the row it qualifies. */ const NO_VALUE = '—'; function faithOf(school: School): string { const denom = school.religious_denomination ?? ''; return denom && !NO_FAITH.test(denom) ? denom : NO_VALUE; } function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) { const metric = METRICS[phase]; /* * Nursery is a primary question. An all-through school renders in both * groups, and its nursery belongs to the primary reading of it — under * "Secondary schools" the column would be a fact about a different intake. */ const showNursery = phase === 'primary'; return (
{/* Same class as the value cell below: one rule aligns both, so they cannot drift apart. */} {/* The measure sits second, not last. Six columns overflow a phone and .tableWrap turns that into a swipe; last would put the one number the page exists for off the screen. */} {showNursery && } {schools.map((s) => { const value = s[metric.key]; return ( {showNursery && ( )} ); })}
School {metric.heading} Ages Religious characterNurseryConstituency
{s.school_name} {value == null ? Not published : `${Math.round(Number(value))}${metric.unit}`} {formatAgeSpan(s.age_range) || NO_VALUE} {faithOf(s)} {/* Undefined is a mart the pipeline has not rebuilt, and false is a school without one. Neither is a "Yes", and neither is worth two different words. */} {s.nursery_provision ? 'Yes' : NO_VALUE} {s.parliamentary_constituency || NO_VALUE}
); } export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) { const { place, schools, averages } = detail; // Fall back to the single parent when the API predates the authorities // field, so a stale cache never blanks the line entirely. const authorities = place.authorities?.length ? place.authorities : place.parent_authority ? [{ name: place.parent_authority, slug: authoritySlug(place.parent_authority), count: 0 }] : []; const local = averages[METRICS[phase ?? 'primary'].key]; const phaseWord = phase === 'secondary' ? 'Secondary schools' : phase === 'primary' ? 'Primary schools' : 'Schools'; const graded = OFSTED_LABELS .map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const) .filter(([, n]) => n > 0); /* * An unphased page holds both primaries and secondaries, and they are * scored on different measures — a percentage and a 0-90 score. Showing one * column for both left 30% of rows blank on /schools/brentwood and put two * incomparable scales in one column when it did not. * * So the phases get a table each. A blank cell inside one now means the * school genuinely has no published result, which is worth saying. */ const groups: Array<[PhaseKey, School[]]> = phase ? [[phase, schools]] : (['primary', 'secondary'] as PhaseKey[]) .map((p) => [p, schools.filter((s) => isPhase(s, p))] as [PhaseKey, School[]]) .filter(([, list]) => list.length > 0); const jsonLd = { '@context': 'https://schema.org', '@graph': [ { '@type': 'ItemList', name: `${phaseWord} in ${place.name}`, numberOfItems: schools.length, // Alphabetical, and said so. Without this an ItemList carrying // `position` reads as a ranking, which would be a claim the page // stopped making when the table became A-Z. itemListOrder: 'https://schema.org/ItemListOrderAscending', itemListElement: schools.slice(0, 20).map((s, i) => ({ '@type': 'ListItem', position: i + 1, url: absoluteUrl(schoolUrl(s.urn, s.school_name)), name: s.school_name, })), }, { '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Schools', item: absoluteUrl('/') }, { '@type': 'ListItem', position: 2, name: place.name }, ], }, ], }; return (
{/* One line, and all four place families are measured, because they all render through this component. */}