PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m44s
The location tables carried one column: a percentage. A parent shortlisting from a town page is asking a different question first — does it take my child's age, is it a faith school, does it have a nursery — and the page could not answer any of it. Primary tables gain Ages, Religious character, Nursery and Constituency; secondary tables the same minus Nursery, which is a question about a different intake. An all-through school renders in both groups, so its nursery shows under primary alone. The measure moves to the second column rather than the last. Six columns overflow a phone and .tableWrap turns that into a horizontal swipe; with the measure last, the one number the page exists for is the one scrolled off the screen. Cell rules are the ones the school page already uses, so the two surfaces cannot disagree about the same school: "Does not apply", "None" and "Not applicable" all read as no religious character, and the en-dash age normalisation moves into formatAgeSpan, which formatAgeRange now delegates to. Backend: nursery_provision and parliamentary_constituency were not in the place response. Both are optional GIAS mart columns that data_loader degrades to NULL, and the `in rows.columns` guard keeps a mart the pipeline has not rebuilt working. Also fixes a live bug on the same line: SCHOOL_COLUMNS already ends with latitude and longitude, and the endpoint concatenated them again, so pandas dropped one of every duplicated pair and warned "columns are not unique" on each request. Ordered de-duplication removes the warning and the silent drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
/**
|
|
* 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 (
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">School</th>
|
|
{/* Same class as the value cell below: one rule aligns both, so
|
|
they cannot drift apart. */}
|
|
<th scope="col" className={styles.num}>
|
|
<abbr className={styles.metricHead} title={metric.hint}>
|
|
{metric.heading}
|
|
</abbr>
|
|
</th>
|
|
{/* 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. */}
|
|
<th scope="col" className={styles.attr}>Ages</th>
|
|
<th scope="col" className={styles.attrWide}>Religious character</th>
|
|
{showNursery && <th scope="col" className={styles.attr}>Nursery</th>}
|
|
<th scope="col" className={styles.attrWide}>Constituency</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{schools.map((s) => {
|
|
const value = s[metric.key];
|
|
return (
|
|
<tr key={s.urn}>
|
|
<td>
|
|
<Link href={schoolUrl(s.urn, s.school_name)} className={styles.schoolLink}>
|
|
{s.school_name}
|
|
</Link>
|
|
</td>
|
|
<td className={styles.num}>
|
|
{value == null
|
|
? <span className={styles.noData}>Not published</span>
|
|
: `${Math.round(Number(value))}${metric.unit}`}
|
|
</td>
|
|
<td className={styles.attr}>{formatAgeSpan(s.age_range) || NO_VALUE}</td>
|
|
<td className={styles.attrWide}>{faithOf(s)}</td>
|
|
{showNursery && (
|
|
<td className={styles.attr}>
|
|
{/* 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}
|
|
</td>
|
|
)}
|
|
<td className={styles.attrWide}>
|
|
{s.parliamentary_constituency || NO_VALUE}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={styles.container}>
|
|
{/* One line, and all four place families are measured, because they all
|
|
render through this component. */}
|
|
<TrackPlaceView kind={place.kind} slug={place.slug}
|
|
count={place.count} phase={phase} />
|
|
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
|
|
<header className={styles.header}>
|
|
<h1>{phaseWord} in {place.name}</h1>
|
|
<p className={styles.summary}>
|
|
{place.count} schools
|
|
{authorities.length > 0 && (
|
|
<>
|
|
{' · '}
|
|
{/* Every authority, not just the largest. A quarter of outcodes
|
|
and a third of towns cross a boundary: SW19 is mostly Merton
|
|
but partly Wandsworth, and naming one asserts otherwise. */}
|
|
{authorities.map((a, i) => (
|
|
<span key={a.name}>
|
|
{i > 0 && (i === authorities.length - 1 ? ' and ' : ', ')}
|
|
{/* No slug means no page: City of London and the Isles of
|
|
Scilly hold too few schools for one. Saying where the
|
|
place is stays right; linking there would 404. */}
|
|
{a.slug
|
|
? (
|
|
<Link href={`/schools/authority/${a.slug}`} className={styles.inlineLink}>
|
|
{a.name}
|
|
</Link>
|
|
)
|
|
: a.name}
|
|
</span>
|
|
))}
|
|
</>
|
|
)}
|
|
</p>
|
|
</header>
|
|
|
|
{!phase && (place.phases ?? []).length > 0 && (
|
|
<nav className={styles.phaseLinks} aria-label="By phase">
|
|
{(place.phases ?? []).map((ph) => (
|
|
/* placeUrl, not a template: the bare `/schools/[slug]/[phase]`
|
|
shape belongs to towns alone, and using it everywhere sent
|
|
every authority page into the town namespace. */
|
|
<Link key={ph} href={placeUrl(place.kind, place.slug, ph)}
|
|
className={styles.phaseLink}>
|
|
{ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
{local != null && englandAverage != null && (
|
|
<p className={styles.compare} data-testid="local-vs-england">
|
|
{place.name} averages <strong>{Math.round(local)}</strong> against{' '}
|
|
<strong>{Math.round(englandAverage)}</strong> across England.
|
|
</p>
|
|
)}
|
|
|
|
{graded.length > 0 && (
|
|
<ul className={styles.ofsted} data-testid="ofsted-distribution">
|
|
{graded.map(([label, n]) => (
|
|
<li key={label}>{label}: <strong>{n}</strong></li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{groups.map(([p, list]) => (
|
|
<section key={p} className={styles.group}>
|
|
{groups.length > 1 && (
|
|
<h2 className={styles.groupHeading}>
|
|
{p === 'secondary' ? 'Secondary schools' : 'Primary schools'}
|
|
<span className={styles.groupCount}>{list.length}</span>
|
|
</h2>
|
|
)}
|
|
<SchoolTable schools={list} phase={p} />
|
|
</section>
|
|
))}
|
|
|
|
{neighbours.length > 0 && (
|
|
<nav className={styles.neighbours} aria-label="Nearby places">
|
|
<h2>Nearby</h2>
|
|
<ul>
|
|
{neighbours.map((n) => (
|
|
<li key={n.kind + n.slug}>
|
|
<Link href={placeUrl(n.kind, n.slug)} className={styles.inlineLink}>{n.name}</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|