Compare commits

...
Author SHA1 Message Date
Tudor 3c69b65ff0 fix(places): phase-grouped tables, plain-English measures, styled links
Three presentation faults on the place pages, all found by looking at a
rendered page rather than at a test.

An unphased place page showed one primary-only measure for a list holding both
phases: 8 of 27 rows on /schools/brentwood were blank, because secondaries
have no reading-writing-maths score. Picking the other measure would only have
inverted which rows were empty, and putting both in one column would have
mixed a percentage with a 0-90 score. Each phase now gets its own table, so a
blank cell means the school genuinely has no published result — which is worth
saying, and now says "Not published" rather than a bare dash.

"RWM expected" was invented here. The site already names the measure in
METRIC_DEFINITIONS, surfaced at /api/metrics: "Reading, Writing & Maths
Combined %". The heading now reads "Reading, writing & maths" with the full
definition in the tooltip.

Links carried no class at all, so they rendered as default blue underlined
browser links beside a site that styles table links as body colour with a
brand hover. They now follow RankingsView's convention, and running-copy links
take the brand colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:49:39 +01:00
TudorandClaude Opus 5 24e114dee7 fix(places): stop the place titles doubling the brand
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
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 27s
Every place page shipped as 'Schools in Brentwood - Compare 27 Schools |
schoolcompare | schoolcompare'. The root layout's title template appends
'| schoolcompare' to any plain-string title, and all four place routes already
carried the brand. W8 opted the other routes out with an absolute title; the
place routes were written afterwards and did not inherit the lesson.

~2,600 titles affected, and the repetition pushed them past Google's
truncation point, so the doubled brand displaced real words in the result.

An e2e journey now asserts no title repeats the brand, across the static
routes and a place page, so this cannot come back on a route added later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 21:43:32 +01:00
tudor 6c5db0c266 Merge pull request 'fix(places): submit and link the phase variants' (#116) from fix/place-phase-variants into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s
Reviewed-on: #116
2026-08-21 19:46:55 +00:00
tudor d423826840 Merge pull request 'fix(places): a locality collision must not break the sitemap' (#115) from fix/locality-collision-skip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m38s
Reviewed-on: #115
2026-08-21 19:26:02 +00:00
9 changed files with 290 additions and 51 deletions

No files matched your search

+17
View File
@@ -1868,3 +1868,20 @@ test('phase variants are submitted in the places sitemap', async ({ page }) => {
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text(); const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</); expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
}); });
test('no page title repeats the brand', async ({ page }) => {
// The root layout appends '| schoolcompare' to a plain-string title. Any
// route whose title already carries the brand must opt out with
// `absolute`, or it ships '... | schoolcompare | schoolcompare' — which is
// how ~2,600 place pages first went out.
const res = await page.request.get('/api/places');
const { places } = await res.json();
const town = places.find((p: { kind: string }) => p.kind === 'town');
for (const path of ['/', '/rankings', '/admissions', `/schools/${town.slug}`]) {
await page.goto(path);
const title = await page.title();
const brands = (title.match(/schoolcompare/gi) ?? []).length;
expect(brands, `${path} repeats the brand: ${title}`).toBeLessThanOrEqual(1);
}
});
+13 -1
View File
@@ -14,7 +14,7 @@ jest.mock('@/lib/places', () => ({
describe('place page metadata', () => { describe('place page metadata', () => {
it('titles the page the way the place is searched', async () => { it('titles the page the way the place is searched', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) }); const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
expect(m.title).toMatch(/schools in brentwood/i); expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
}); });
it('canonicalises to its own path on the www host', async () => { it('canonicalises to its own path on the www host', async () => {
@@ -23,6 +23,18 @@ describe('place page metadata', () => {
.toBe('https://www.schoolcompare.co.uk/schools/brentwood'); .toBe('https://www.schoolcompare.co.uk/schools/brentwood');
}); });
it('opts out of the layout template, which would double the brand', () => {
// The root layout appends '| schoolcompare' to a plain string title, and
// these titles already carry it — every place page shipped reading
// '... | schoolcompare | schoolcompare' until this was made absolute.
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
.then((m) => {
expect(typeof m.title).toBe('object');
expect((m.title as { absolute: string }).absolute)
.not.toMatch(/schoolcompare.*schoolcompare/);
});
});
it('an unknown place gets a not-found title rather than inventing one', async () => { it('an unknown place gets a not-found title rather than inventing one', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) }); const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
expect(m.title).toMatch(/not found/i); expect(m.title).toMatch(/not found/i);
@@ -7,9 +7,9 @@ const detail: PlaceDetail = {
parent_authority: 'Essex', phases: ['primary'] }, parent_authority: 'Essex', phases: ['primary'] },
schools: [ schools: [
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82, { urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
ofsted_grade: 1 } as never, ofsted_grade: 1, phase: 'Primary' } as never,
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44, { urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
ofsted_grade: 3 } as never, ofsted_grade: 3, phase: 'Primary' } as never,
], ],
averages: { rwm_expected_pct: 63, attainment_8_score: null }, averages: { rwm_expected_pct: 63, attainment_8_score: null },
}; };
@@ -112,3 +112,59 @@ describe('PlaceView phase variants', () => {
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
}); });
describe('PlaceView presentation', () => {
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
// one primary-only measure for a list that also holds secondaries.
const mixed: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
schools: [
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null } as never,
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
rwm_expected_pct: null, attainment_8_score: 47 } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
};
it('gives each phase its own table rather than one column of blanks', () => {
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
expect(screen.getByText('82%')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
});
it('names the measure in plain words, not jargon', () => {
// The first cut said "RWM expected", which appears nowhere else on the site.
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
});
it('says a missing result is unpublished rather than showing a bare dash', () => {
const noResult: PlaceDetail = {
...mixed,
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
rwm_expected_pct: null, attainment_8_score: null } as never],
};
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
expect(screen.getByText('Not published')).toBeInTheDocument();
});
it('styles school links to the site convention rather than browser default', () => {
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
neighbours={[]} />);
const link = container.querySelector('a[href^="/school/"]');
expect(link?.className).toBeTruthy();
});
it('a phased page shows one table and no phase headings', () => {
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
.not.toBeInTheDocument();
});
});
@@ -37,7 +37,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const word = phase === 'secondary' ? 'Secondary' : 'Primary'; const word = phase === 'secondary' ? 'Secondary' : 'Primary';
const { name } = detail.place; const { name } = detail.place;
return { return {
title: `${word} Schools in ${name} — Ranked | schoolcompare`, title: { absolute: `${word} Schools in ${name} — Ranked | schoolcompare` },
description: description:
`Every ${phase} school in ${name} ranked by results, with Ofsted grades and ` `Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
+ `the local average against England.`, + `the local average against England.`,
+4 -1
View File
@@ -53,7 +53,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { name, count } = detail.place; const { name, count } = detail.place;
return { return {
title: `Schools in ${name} — Compare ${count} Schools | schoolcompare`, // absolute: the root layout's template appends '| schoolcompare' to a
// plain string, and this title already carries it. Without this every
// place title read '... | schoolcompare | schoolcompare'.
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
description: description:
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, ` `Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
+ `the local average against England, and how close you had to live to get a place.`, + `the local average against England, and how close you had to live to get a place.`,
@@ -43,7 +43,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { name, count } = detail.place; const { name, count } = detail.place;
return { return {
title: `Schools in ${name} — Local Authority | schoolcompare`, title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
description: description:
`All ${count} schools in the ${name} local authority, ranked by SATs and GCSE ` `All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
+ `results, with Ofsted grades and the authority average against England.`, + `results, with Ofsted grades and the authority average against England.`,
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { name, count } = detail.place; const { name, count } = detail.place;
return { return {
title: `Schools near ${name} | schoolcompare`, title: { absolute: `Schools near ${name} | schoolcompare` },
description: description:
`${count} schools in the ${name} postcode district, ranked by results, with ` `${count} schools in the ${name} postcode district, ranked by results, with `
+ `Ofsted grades and how close you had to live to get a place.`, + `Ofsted grades and how close you had to live to get a place.`,
@@ -1,4 +1,8 @@
/* Tokens only — see globals.css. Matches RankingsView's conventions. */ /* Tokens only — see globals.css. Follows RankingsView's conventions, and in
particular its link treatment: table links take --text-primary with no
underline and a brand-coloured hover, not the browser default. The first
cut used bare <Link> with no class at all, which rendered as default blue
underlined links and read as unstyled beside the rest of the site. */
.container { .container {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -24,6 +28,44 @@
line-height: 1.6; line-height: 1.6;
} }
/* Links in running copy: brand colour, underline on hover only. */
.inlineLink {
color: var(--brand);
text-decoration: none;
transition: color 0.2s ease;
}
.inlineLink:hover {
color: var(--brand-strong);
text-decoration: underline;
}
/* Phase variants are separate indexable pages, so the bare place page has to
link them — a sitemap entry alone leaves them with no internal path in. */
.phaseLinks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin: 0 0 1.25rem;
}
.phaseLink {
display: inline-block;
padding: 0.4rem 0.875rem;
border: 1px solid var(--border-strong);
border-radius: 999px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
transition: border-color 0.2s ease, color 0.2s ease;
}
.phaseLink:hover {
border-color: var(--brand);
color: var(--brand-strong);
}
/* The one number a list cannot give you, so it gets its own band. */ /* The one number a list cannot give you, so it gets its own band. */
.compare { .compare {
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -46,6 +88,30 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.group {
margin-bottom: 2rem;
}
.groupHeading {
display: flex;
align-items: baseline;
gap: 0.625rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-display);
margin: 0 0 0.75rem;
}
.groupCount {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
/* Wide content scrolls in its own container so the page body never does. */ /* Wide content scrolls in its own container so the page body never does. */
.tableWrap { .tableWrap {
overflow-x: auto; overflow-x: auto;
@@ -72,8 +138,6 @@
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
font-size: 0.8125rem; font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.table tbody tr:last-child td { .table tbody tr:last-child td {
@@ -86,6 +150,30 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* The measure is spelled out; the tooltip carries the definition. */
.metricHead {
text-decoration: none;
cursor: help;
border-bottom: 1px dotted var(--border-strong);
}
/* Table links: site convention is body colour, brand on hover. */
.schoolLink {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolLink:hover {
color: var(--brand-strong);
}
/* "Not published" is a fact about the school, not an error. */
.noData {
color: var(--text-muted);
font-size: 0.8125rem;
}
.neighbours { .neighbours {
margin-top: 2rem; margin-top: 2rem;
} }
@@ -106,13 +194,3 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
/* Phase variants are separate indexable pages, so the bare place page has to
link them — a sitemap entry alone leaves them with no internal path in. */
.phaseLinks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin: 0 0 1.25rem;
font-size: 0.9375rem;
}
+104 -31
View File
@@ -12,6 +12,7 @@
import Link from 'next/link'; import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places'; import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places'; import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils'; import { schoolUrl } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site'; import { absoluteUrl } from '@/lib/site';
import styles from './PlaceView.module.css'; import styles from './PlaceView.module.css';
@@ -30,19 +31,101 @@ const OFSTED_LABELS: Array<[number, string]> = [
[3, 'Requires improvement'], [4, 'Inadequate'], [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>
<th scope="col">
<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) { export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
const { place, schools, averages } = detail; const { place, schools, averages } = detail;
const metric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; const local = averages[METRICS[phase ?? 'primary'].key];
const local = averages[metric];
const phaseWord = phase === 'secondary' ? 'Secondary schools' const phaseWord = phase === 'secondary' ? 'Secondary schools'
: phase === 'primary' ? 'Primary schools' : 'Schools'; : phase === 'primary' ? 'Primary schools' : 'Schools';
const graded = OFSTED_LABELS const graded = OFSTED_LABELS
.map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const) .map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
.filter(([, n]) => n > 0); .filter(([, n]) => n > 0);
// ItemList tells Google this page is a ranked set rather than prose; /*
// BreadcrumbList puts the place in a hierarchy. Capped at 20 because that * An unphased page holds both primaries and secondaries, and they are
// is what the page shows above the fold and what the markup should mirror. * 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 = { const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@graph': [ '@graph': [
@@ -60,8 +143,7 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
{ {
'@type': 'BreadcrumbList', '@type': 'BreadcrumbList',
itemListElement: [ itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Schools', { '@type': 'ListItem', position: 1, name: 'Schools', item: absoluteUrl('/') },
item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: place.name }, { '@type': 'ListItem', position: 2, name: place.name },
], ],
}, },
@@ -74,6 +156,7 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<header className={styles.header}> <header className={styles.header}>
<h1>{phaseWord} in {place.name}</h1> <h1>{phaseWord} in {place.name}</h1>
<p className={styles.summary}> <p className={styles.summary}>
@@ -81,7 +164,8 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
{place.parent_authority && ( {place.parent_authority && (
<> <>
{' · '} {' · '}
<Link href={`/schools/authority/${authoritySlug(place.parent_authority)}`}> <Link href={`/schools/authority/${authoritySlug(place.parent_authority)}`}
className={styles.inlineLink}>
{place.parent_authority} {place.parent_authority}
</Link> </Link>
</> </>
@@ -92,7 +176,7 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
{!phase && (place.phases ?? []).length > 0 && ( {!phase && (place.phases ?? []).length > 0 && (
<nav className={styles.phaseLinks} aria-label="By phase"> <nav className={styles.phaseLinks} aria-label="By phase">
{(place.phases ?? []).map((ph) => ( {(place.phases ?? []).map((ph) => (
<Link key={ph} href={`/schools/${place.slug}/${ph}`}> <Link key={ph} href={`/schools/${place.slug}/${ph}`} className={styles.phaseLink}>
{ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name} {ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name}
</Link> </Link>
))} ))}
@@ -114,28 +198,17 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
</ul> </ul>
)} )}
<div className={styles.tableWrap}> {groups.map(([p, list]) => (
<table className={styles.table}> <section key={p} className={styles.group}>
<thead> {groups.length > 1 && (
<tr> <h2 className={styles.groupHeading}>
<th>School</th> {p === 'secondary' ? 'Secondary schools' : 'Primary schools'}
<th>{phase === 'secondary' ? 'Attainment 8' : 'RWM expected'}</th> <span className={styles.groupCount}>{list.length}</span>
</tr> </h2>
</thead> )}
<tbody> <SchoolTable schools={list} phase={p} />
{schools.map((s) => ( </section>
<tr key={s.urn}>
<td>
<Link href={schoolUrl(s.urn, s.school_name)}>{s.school_name}</Link>
</td>
<td className={styles.num}>
{s[metric] == null ? '—' : Math.round(Number(s[metric]))}
</td>
</tr>
))} ))}
</tbody>
</table>
</div>
{neighbours.length > 0 && ( {neighbours.length > 0 && (
<nav className={styles.neighbours} aria-label="Nearby places"> <nav className={styles.neighbours} aria-label="Nearby places">
@@ -143,7 +216,7 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
<ul> <ul>
{neighbours.map((n) => ( {neighbours.map((n) => (
<li key={n.kind + n.slug}> <li key={n.kind + n.slug}>
<Link href={placeUrl(n.kind, n.slug)}>{n.name}</Link> <Link href={placeUrl(n.kind, n.slug)} className={styles.inlineLink}>{n.name}</Link>
</li> </li>
))} ))}
</ul> </ul>