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 16s
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) Canceled after 1m21s
Someone on a place page is usually looking for a school they can name, so the order should serve scanning for it rather than ranking. /api/rankings keeps its league-table ordering; this is a place-page decision, not a site-wide one. Sorted case-insensitively, or a capitalised name would sort ahead of every lowercase one. The change made five pieces of copy untrue, so they go with it. The phase variant titled itself "— Ranked", and all four route families described themselves as "ranked by SATs and GCSE results". A page that opens by claiming an order it does not keep is worse than one that claims nothing. The ItemList markup carried `position` with no declared order, which reads as a ranking. It now declares ItemListOrderAscending, so the structured data says what the table does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
64 lines
2.4 KiB
TypeScript
64 lines
2.4 KiB
TypeScript
/**
|
|
* 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<Metadata> {
|
|
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 {
|
|
// Not "Ranked": the table is alphabetical, so the word would be a claim
|
|
// the page does not keep.
|
|
title: { absolute: `${word} Schools in ${name} | schoolcompare` },
|
|
description:
|
|
`Every ${phase} school in ${name}, with results, 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 <PlaceView detail={detail} phase={phase}
|
|
englandAverage={englandAverage} neighbours={[]} />;
|
|
}
|