diff --git a/docs/superpowers/plans/2026-08-21-w2-location-layer.md b/docs/superpowers/plans/2026-08-21-w2-location-layer.md index fd101ac..7dbbd98 100644 --- a/docs/superpowers/plans/2026-08-21-w2-location-layer.md +++ b/docs/superpowers/plans/2026-08-21-w2-location-layer.md @@ -1362,10 +1362,14 @@ export async function generateStaticParams(): Promise> // 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 []; - const { fetchPlaces } = await import('@/lib/places'); - return (await fetchPlaces()) - .filter((p) => p.kind === 'town' || p.kind === 'locality') - .map((p) => ({ place: p.slug })); + 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) { @@ -1517,11 +1521,20 @@ export const revalidate = 604800; export const dynamicParams = true; export async function generateStaticParams(): Promise> { - // Only ~154 authorities, so these are cheap enough to always prebuild — - // unlike towns, which are gated behind PRERENDER_PLACES. - return (await fetchPlaces()) - .filter((p) => p.kind === 'authority') - .map((p) => ({ la: p.slug })); + // Gated like every other prerender in this app. There are only ~154 + // authorities, but "few enough to always build" still means the API must be + // reachable at build time, and in CI it is not — the build fails with + // ECONNREFUSED rather than degrading. The catch is the same fallback the + // school route uses. + if (process.env.PRERENDER_PLACES !== '1') return []; + try { + return (await fetchPlaces()) + .filter((p) => p.kind === 'authority') + .map((p) => ({ la: p.slug })); + } catch (error) { + console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error); + return []; + } } export async function generateMetadata({ params }: Props): Promise { @@ -1581,9 +1594,14 @@ export const dynamicParams = true; export async function generateStaticParams(): Promise> { // 1,760 of these; same CI budget argument as the town routes. if (process.env.PRERENDER_PLACES !== '1') return []; - return (await fetchPlaces()) - .filter((p) => p.kind === 'outcode') - .map((p) => ({ outcode: p.slug })); + try { + return (await fetchPlaces()) + .filter((p) => p.kind === 'outcode') + .map((p) => ({ outcode: p.slug })); + } catch (error) { + console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error); + return []; + } } export async function generateMetadata({ params }: Props): Promise { diff --git a/nextjs-app/__tests__/app/placeMetadata.test.ts b/nextjs-app/__tests__/app/placeMetadata.test.ts new file mode 100644 index 0000000..67286db --- /dev/null +++ b/nextjs-app/__tests__/app/placeMetadata.test.ts @@ -0,0 +1,30 @@ +import { generateMetadata as placeMeta } from '@/app/schools/[place]/page'; + +jest.mock('@/lib/places', () => ({ + ...jest.requireActual('@/lib/places'), + fetchPlace: jest.fn(async (kind: string, slug: string) => + slug === 'atlantis' ? null : ({ + place: { kind, slug, name: 'Brentwood', count: 29, + parent_authority: 'Essex' }, + schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null }, + })), + fetchPlaces: jest.fn(async () => []), +})); + +describe('place page metadata', () => { + it('titles the page the way the place is searched', async () => { + const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) }); + expect(m.title).toMatch(/schools in brentwood/i); + }); + + it('canonicalises to its own path on the www host', async () => { + const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) }); + expect(m.alternates?.canonical) + .toBe('https://www.schoolcompare.co.uk/schools/brentwood'); + }); + + it('an unknown place gets a not-found title rather than inventing one', async () => { + const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) }); + expect(m.title).toMatch(/not found/i); + }); +}); diff --git a/nextjs-app/app/schools/[place]/[phase]/page.tsx b/nextjs-app/app/schools/[place]/[phase]/page.tsx new file mode 100644 index 0000000..be97d0f --- /dev/null +++ b/nextjs-app/app/schools/[place]/[phase]/page.tsx @@ -0,0 +1,61 @@ +/** + * Phase variants of a place page. + * + * Phase is part of the query — "primary schools in beccles", "secondary + * schools in brentwood" — not a filter applied afterwards, so each gets its + * own indexable path. A place with no schools of the phase has no page: the + * per-phase threshold, not an error. + */ +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { fetchPlace } 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; phase: string }> } + +export const revalidate = 604800; +export const dynamicParams = true; + +const PHASES = ['primary', 'secondary'] as const; +type Phase = (typeof PHASES)[number]; + +const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v); + +async function resolve(slug: string, phase: Phase) { + return (await fetchPlace('town', slug, phase)) + ?? (await fetchPlace('locality', slug, phase)); +} + +export async function generateMetadata({ params }: Props): Promise { + const { place: slug, phase } = await params; + if (!isPhase(phase)) return { title: 'Place Not Found' }; + const detail = await resolve(slug, phase); + if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' }; + + const word = phase === 'secondary' ? 'Secondary' : 'Primary'; + const { name } = detail.place; + return { + title: `${word} Schools in ${name} — Ranked | schoolcompare`, + description: + `Every ${phase} school in ${name} ranked by results, with Ofsted grades and ` + + `the local average against England.`, + alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) }, + }; +} + +export default async function PlacePhasePage({ params }: Props) { + const { place: slug, phase } = await params; + if (!isPhase(phase)) notFound(); + const detail = await resolve(slug, phase); + if (!detail || detail.schools.length === 0) notFound(); + + const national = await fetchNationalAverages().catch(() => null); + const englandAverage = phase === 'secondary' + ? national?.secondary?.attainment_8_score ?? null + : national?.primary?.rwm_expected_pct ?? null; + + return ; +} diff --git a/nextjs-app/app/schools/[place]/page.tsx b/nextjs-app/app/schools/[place]/page.tsx new file mode 100644 index 0000000..8fe0111 --- /dev/null +++ b/nextjs-app/app/schools/[place]/page.tsx @@ -0,0 +1,91 @@ +/** + * 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> { + // 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 { + const { place: slug } = await params; + const detail = await resolve(slug); + if (!detail) return { title: 'Place Not Found' }; + + const { name, count } = detail.place; + return { + title: `Schools in ${name} — Compare ${count} Schools | schoolcompare`, + description: + `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.`, + 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) { + if (detail.place.parent_authority) { + redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`); + } + 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 ( + + ); +} diff --git a/nextjs-app/app/schools/authority/[la]/page.tsx b/nextjs-app/app/schools/authority/[la]/page.tsx new file mode 100644 index 0000000..7f1aac7 --- /dev/null +++ b/nextjs-app/app/schools/authority/[la]/page.tsx @@ -0,0 +1,67 @@ +/** + * Local authority pages. + * + * A separate namespace from /schools/[place] because 67 town names collide + * with an authority name and neither set contains the other — Bedford the + * town holds 104 schools, Bedford the authority 86, because postal towns + * cross authority boundaries. The title says "Local Authority" so a reader + * landing on both knows which set each covers. + */ +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { fetchPlace, fetchPlaces } from '@/lib/places'; +import { fetchNationalAverages } from '@/lib/api'; +import { PlaceView } from '@/components/places/PlaceView'; +import { absoluteUrl } from '@/lib/site'; + +interface Props { params: Promise<{ la: string }> } + +export const revalidate = 604800; +export const dynamicParams = true; + +export async function generateStaticParams(): Promise> { + // Gated like every other prerender in this app. There are only ~154 + // authorities, but "few enough to always build" still means the API must be + // reachable at build time, and in CI it is not — the build fails with + // ECONNREFUSED rather than degrading. The catch is the same fallback the + // school route uses. + if (process.env.PRERENDER_PLACES !== '1') return []; + try { + return (await fetchPlaces()) + .filter((p) => p.kind === 'authority') + .map((p) => ({ la: p.slug })); + } catch (error) { + console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error); + return []; + } +} + +export async function generateMetadata({ params }: Props): Promise { + const { la } = await params; + const detail = await fetchPlace('authority', la); + if (!detail) return { title: 'Place Not Found' }; + + const { name, count } = detail.place; + return { + title: `Schools in ${name} — Local Authority | schoolcompare`, + description: + `All ${count} schools in the ${name} local authority, ranked by SATs and GCSE ` + + `results, with Ofsted grades and the authority average against England.`, + alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) }, + }; +} + +export default async function AuthorityPage({ params }: Props) { + const { la } = await params; + const detail = await fetchPlace('authority', la); + if (!detail) notFound(); + + const national = await fetchNationalAverages().catch(() => null); + return ( + + ); +} diff --git a/nextjs-app/app/schools/near/[outcode]/page.tsx b/nextjs-app/app/schools/near/[outcode]/page.tsx new file mode 100644 index 0000000..9a43aa2 --- /dev/null +++ b/nextjs-app/app/schools/near/[outcode]/page.tsx @@ -0,0 +1,62 @@ +/** + * Postcode district pages. + * + * No phase variants: nobody searches "primary schools in SW11", so the + * variants would be pages without demand. These exist to catch + * "schools near " and to give London districts a geographic page + * where the GIAS town field cannot. + */ +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { fetchPlace, fetchPlaces } from '@/lib/places'; +import { fetchNationalAverages } from '@/lib/api'; +import { PlaceView } from '@/components/places/PlaceView'; +import { absoluteUrl } from '@/lib/site'; + +interface Props { params: Promise<{ outcode: string }> } + +export const revalidate = 604800; +export const dynamicParams = true; + +export async function generateStaticParams(): Promise> { + // 1,760 of these; same CI budget argument as the town routes. + if (process.env.PRERENDER_PLACES !== '1') return []; + try { + return (await fetchPlaces()) + .filter((p) => p.kind === 'outcode') + .map((p) => ({ outcode: p.slug })); + } catch (error) { + console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error); + return []; + } +} + +export async function generateMetadata({ params }: Props): Promise { + const { outcode } = await params; + const detail = await fetchPlace('outcode', outcode); + if (!detail) return { title: 'Place Not Found' }; + + const { name, count } = detail.place; + return { + title: `Schools near ${name} | schoolcompare`, + description: + `${count} schools in the ${name} postcode district, ranked by results, with ` + + `Ofsted grades and how close you had to live to get a place.`, + alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) }, + }; +} + +export default async function OutcodePage({ params }: Props) { + const { outcode } = await params; + const detail = await fetchPlace('outcode', outcode); + if (!detail) notFound(); + + const national = await fetchNationalAverages().catch(() => null); + return ( + + ); +}