PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
/**
|
|
* Town and locality pages.
|
|
*
|
|
* A place below the five-school threshold is not in the registry, so
|
|
* fetchPlace returns null and the request 404s rather than rendering a page
|
|
* with nothing to say.
|
|
*/
|
|
import { notFound, redirect } from 'next/navigation';
|
|
import type { Metadata } from 'next';
|
|
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
|
import { fetchNationalAverages } from '@/lib/api';
|
|
import { PlaceView } from '@/components/places/PlaceView';
|
|
import { absoluteUrl } from '@/lib/site';
|
|
|
|
interface Props { params: Promise<{ place: string }> }
|
|
|
|
// ISR: place aggregates change only when the pipeline runs.
|
|
export const revalidate = 604800;
|
|
export const dynamicParams = true;
|
|
|
|
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
|
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
|
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
|
if (process.env.PRERENDER_PLACES !== '1') return [];
|
|
try {
|
|
return (await fetchPlaces())
|
|
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
|
.map((p) => ({ place: p.slug }));
|
|
} catch (error) {
|
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function resolve(slug: string) {
|
|
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
|
}
|
|
|
|
/** Other towns in the same authority — the cheapest honest definition of
|
|
* "nearby", and enough to stop each place page being a dead end. */
|
|
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
|
if (!detail.place.parent_authority) return [];
|
|
const all = await fetchPlaces();
|
|
return all
|
|
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
|
.slice(0, 12);
|
|
}
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { place: slug } = await params;
|
|
const detail = await resolve(slug);
|
|
if (!detail) return { title: 'Place Not Found' };
|
|
|
|
const { name, count } = detail.place;
|
|
return {
|
|
// 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:
|
|
`Every school in ${name}, with SATs and GCSE results, Ofsted grades, the local `
|
|
+ `average against England, and how close you had to live to get a place.`,
|
|
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
|
};
|
|
}
|
|
|
|
export default async function PlacePage({ params }: Props) {
|
|
const { place: slug } = await params;
|
|
const detail = await resolve(slug);
|
|
if (!detail) notFound();
|
|
|
|
// Global constraint: no page without a local average. A place with too few
|
|
// schools carrying results has nothing to say that a list does not, so it
|
|
// defers to its authority rather than publishing a thin page.
|
|
if (detail.averages.rwm_expected_pct == null
|
|
&& detail.averages.attainment_8_score == null) {
|
|
// The API's own slug, which is null when that authority is itself under
|
|
// the threshold and has no page. Re-slugifying the name here would send
|
|
// the reader to a 404 instead of telling them the place has no page.
|
|
const target = detail.place.authorities?.[0]?.slug
|
|
?? (detail.place.parent_authority
|
|
? authoritySlug(detail.place.parent_authority)
|
|
: null);
|
|
if (target) redirect(`/schools/authority/${target}`);
|
|
notFound();
|
|
}
|
|
|
|
const national = await fetchNationalAverages().catch(() => null);
|
|
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
|
// — not flat. Reading it flat silently yields undefined and the page renders
|
|
// with no comparison, which is the one thing that makes it not a list.
|
|
return (
|
|
<PlaceView
|
|
detail={detail}
|
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
|
neighbours={await neighboursOf(detail)}
|
|
/>
|
|
);
|
|
}
|