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 11s
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 14s
The heading sat on the right edge of the column and every value on the left. A specificity collision, not a layout problem: the two were aligned by different selectors and only one of them won. .table td (0,1,1) text-align: left <- won for the value .num (0,1,0) text-align: right <- lost .table th:last-child (0,2,1) text-align: right <- won for the heading The heading and the value cell now share one class and one rule, so they cannot drift apart again whatever else changes around them. The column also stretched to half the table. It now hugs its content with width:1% and nowrap, so the school name takes the remaining width — which is what made the gap read as misalignment on a wide screen, and what crowded the name column on a narrow one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
230 lines
7.7 KiB
TypeScript
230 lines
7.7 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 } from '@/lib/utils';
|
|
import { absoluteUrl } from '@/lib/site';
|
|
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');
|
|
}
|
|
|
|
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
|
|
const metric = METRICS[phase];
|
|
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>
|
|
</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>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
|
|
const { place, schools, averages } = detail;
|
|
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,
|
|
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}>
|
|
<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
|
|
{place.parent_authority && (
|
|
<>
|
|
{' · '}
|
|
<Link href={`/schools/authority/${authoritySlug(place.parent_authority)}`}
|
|
className={styles.inlineLink}>
|
|
{place.parent_authority}
|
|
</Link>
|
|
</>
|
|
)}
|
|
</p>
|
|
</header>
|
|
|
|
{!phase && (place.phases ?? []).length > 0 && (
|
|
<nav className={styles.phaseLinks} aria-label="By phase">
|
|
{(place.phases ?? []).map((ph) => (
|
|
<Link key={ph} href={`/schools/${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>
|
|
);
|
|
}
|