PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
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 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the shape that belongs to towns alone. On an authority page that pointed into the town namespace. For 87 of the 151 authorities the target does not exist and the link 404s; for the other 64 it resolves to the town of the same name — a different set of schools, which is precisely the near-duplicate the two namespaces were introduced to prevent. On an outcode page it 404s outright. Two causes behind it, both a rule written twice and inherited by only one of the places that needed it. The authority phase route was in the spec and dropped by the plan, which built the three bare routes and no fourth. The sitemap is generated from the place registry, which was right about them all along, so 302 authority phase URLs have been submitted to Google and every one 404s. Adding the route makes the sitemap true and serves a real query — admissions are authority-run, so "primary schools in Kent" is how a parent searches before they have settled on a town. The outcode variants were the opposite: the registry computed phases for outcodes although the spec gives them no route, and the sitemap knew to skip them while the API did not. The registry now decides alone, and the sitemap's duplicate of that rule is gone. Also: an authority under the five-school threshold has no page, so the API sends a null slug for it and the page names it without linking. Two English authorities are in that position. It was unreachable in today's data — verified across the EC and TR outcodes — but the thin place redirect would have sent a reader to a 404 the year it isn't. The e2e journey now walks every /schools link a page of each family emits and requires a 200, which is the check that was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
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)}
|
|
/>
|
|
);
|
|
}
|