PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
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 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 40s
The location pages were only half-tracked. Umami counts a pageview for each of the ~3,900 URLs automatically, but nothing else: components/ places contained no track() call, and place_viewed was not even a declared event name. The part that mattered was worse than a gap. getNavigationSource mapped a same-origin referrer to a funnel source and had no case for /schools/, so every school view arriving through the location layer fell through to 'direct' — the bucket you read as "typed the URL, no referrer". W2's whole purpose is funnelling search traffic onto school pages, so the one measurement that says whether it worked was reporting the wrong answer, and reporting it confidently. Verified live against staging: expected "place", received "direct". /schools/ is checked before /school/. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test in the wrong order silently merges them. place_viewed carries kind, slug, phase and school_count. kind is the reason it exists: whether to keep investing in these pages turns on which sort earns engagement, and a pageview cannot say, because all four families share the /schools/ prefix and only the registry knows which is which. It is a client component because PlaceView is a server component; one line in PlaceView covers all four families, since they all render through it. Both E2E journeys were verified failing against staging first — one because place_viewed does not exist there, the other on the exact "place" vs "direct" mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
265 lines
9.5 KiB
TypeScript
265 lines
9.5 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 { 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');
|
|
}
|
|
|
|
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;
|
|
// 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>
|
|
);
|
|
}
|