Reported from staging: a Catholic primary showed six Catholic primaries, none of them close enough to be a real option, and omitted the community school down the road. Three causes, compounding. Ranking put tier before distance, so a faith match at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule — added so a cap of six would not drag in weak distant matches — filled the row from the best tier before it ever widened, which is what made every card Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city for a primary, whose catchments are routinely under a mile. The premise was backwards. For a parent, distance is a constraint and intake is a preference; a school beyond a primary catchment is not a weaker option, it is not an option. So distance now decides the order and nothing else does. The hard filters are untouched — they were always where the defensibility lived. Similarity survives as chips on the card: reported, so a reader applies their own weighting, rather than ranked, so we apply ours for them. Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity bound, not a target: ordering already handles density, so the cap only decides what happens where an area is sparse. A primary with nothing inside two miles now renders no section, which is the honest answer. Deleted: the tier system, the stopping rule, the tier-dependent lede, the `tier` field, the tier-3 fallback chip and its style. select_similar also stops taking is_secondary — it reads the phase from the subject's own row, so no caller can hand it one that disagrees with the data. The heading is now "Other schools nearby". The hard filters still guarantee a comparable set, but nothing ranks on likeness, so the heading no longer says it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
|
* Individual School Page (SSR)
|
|
* Dynamic route for school details with full SEO optimization
|
|
* URL format: /school/138267-school-name-here
|
|
*/
|
|
|
|
import { APIFetchError, fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
|
import { notFound, redirect } from 'next/navigation';
|
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
|
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
|
import { shouldRenderSimilar } from '@/components/school/SimilarSchoolsSection';
|
|
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
|
|
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
|
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
|
import {
|
|
computeSchoolFlags, buildNavItems,
|
|
computeSecondaryFlags, buildSecondaryNavItems,
|
|
} from '@/lib/schoolSections';
|
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
|
import type { NationalAverages } from '@/lib/types';
|
|
import { absoluteUrl } from '@/lib/site';
|
|
import type { Metadata } from 'next';
|
|
|
|
/**
|
|
* Enumerate every school for static generation at build time.
|
|
*
|
|
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
|
|
* (or when the API can't be reached), we return an empty list and the route
|
|
* falls back to ISR on first request — `dynamicParams = true` covers it.
|
|
*/
|
|
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
|
|
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
|
|
|
|
const params: Array<{ slug: string }> = [];
|
|
const PAGE_SIZE = 500;
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
try {
|
|
do {
|
|
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
|
|
for (const s of res.schools) {
|
|
const path = schoolUrl(s.urn, s.school_name);
|
|
const slug = path.replace('/school/', '');
|
|
params.push({ slug });
|
|
}
|
|
totalPages = res.total_pages || 1;
|
|
page += 1;
|
|
} while (page <= totalPages);
|
|
} catch (error) {
|
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
|
return [];
|
|
}
|
|
|
|
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
|
|
return params;
|
|
}
|
|
|
|
interface SchoolPageProps {
|
|
params: Promise<{ slug: string }>;
|
|
}
|
|
|
|
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const urn = parseSchoolSlug(slug);
|
|
|
|
if (!urn || urn < 100000 || urn > 999999) {
|
|
return {
|
|
title: 'School Not Found',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const data = await fetchSchoolDetails(urn);
|
|
const { school_info } = data;
|
|
|
|
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
|
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
|
const isAllThrough = phaseStr === 'all-through';
|
|
const isSecondary = !isAllThrough && (
|
|
phaseStr.includes('secondary')
|
|
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null)
|
|
);
|
|
const la = school_info.local_authority ? ` in ${school_info.local_authority}` : '';
|
|
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
|
const description = isAllThrough
|
|
? `View KS2 SATs and GCSE results for ${school_info.school_name}${la}. All-through school covering primary and secondary education.`
|
|
: isSecondary
|
|
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${la}.`
|
|
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${la}. Compare reading, writing, and maths results.`;
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
keywords: isAllThrough
|
|
? `${school_info.school_name}, KS2 results, GCSE results, all-through school, ${school_info.local_authority}, SATs, Attainment 8`
|
|
: isSecondary
|
|
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
|
|
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: 'website',
|
|
url: absoluteUrl(canonicalPath),
|
|
siteName: 'schoolcompare',
|
|
},
|
|
twitter: {
|
|
card: 'summary',
|
|
title,
|
|
description,
|
|
},
|
|
alternates: {
|
|
canonical: absoluteUrl(canonicalPath),
|
|
},
|
|
};
|
|
} catch {
|
|
return {
|
|
title: 'School Not Found',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ISR: regenerate at most once a week per slug. School data updates annually,
|
|
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
|
|
export const revalidate = 604800;
|
|
export const dynamicParams = true;
|
|
|
|
export default async function SchoolPage({ params }: SchoolPageProps) {
|
|
const { slug } = await params;
|
|
const urn = parseSchoolSlug(slug);
|
|
|
|
// Validate URN format
|
|
if (!urn || urn < 100000 || urn > 999999) {
|
|
notFound();
|
|
}
|
|
|
|
// Fetch school data. National averages feed the England-comparison deltas
|
|
// across most sections; fetching them here rather than in a client effect
|
|
// keeps those sections server-renderable and puts the deltas in the initial
|
|
// HTML. They are supplementary, so they degrade to null rather than 404ing
|
|
// the page.
|
|
let data;
|
|
let nationalAvg: NationalAverages | null = null;
|
|
try {
|
|
[data, nationalAvg] = await Promise.all([
|
|
fetchSchoolDetails(urn),
|
|
fetchNationalAverages().catch(() => null),
|
|
]);
|
|
} catch (error) {
|
|
if (error instanceof APIFetchError && error.status === 404) notFound();
|
|
throw error;
|
|
}
|
|
|
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
|
// Absent on an older API build; the module and the trail both degrade to
|
|
// nothing rather than throwing, which is how this shipped without a
|
|
// lockstep deploy of the two images.
|
|
const places: SchoolPlace[] = data.places ?? [];
|
|
// Absent on an older API build, exactly like `places` above.
|
|
const similarSchools = data.similar_schools ?? [];
|
|
|
|
// Redirect bare URN to canonical slug URL
|
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
|
if (slug !== canonicalSlug) {
|
|
redirect(`/school/${canonicalSlug}`);
|
|
}
|
|
|
|
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
|
const isAllThrough = phaseStr === 'all-through';
|
|
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
|
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
|
const isSecondary = !isAllThrough && (
|
|
phaseStr.includes('secondary')
|
|
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
|
);
|
|
|
|
// Section list is computed on the server so the client shell never needs to
|
|
// derive it -- and so it can never disagree with what the sections render.
|
|
const sectionInput = {
|
|
schoolInfo: school_info, yearlyData: yearly_data,
|
|
absenceData: absence_data, census: census ?? null,
|
|
deprivation: deprivation ?? null, finance: finance ?? null,
|
|
destinations: destinations ?? null,
|
|
};
|
|
const primaryFlags = computeSchoolFlags(sectionInput);
|
|
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
|
const navInput = {
|
|
ofsted: ofsted ?? null,
|
|
admissions: admissions ?? null,
|
|
admissionDistance: admission_distance ?? null,
|
|
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
|
hasNearbySchools: shouldRenderSimilar(similarSchools),
|
|
yearlyDataLength: yearly_data.length,
|
|
};
|
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
|
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
|
|
|
/*
|
|
* `School`, not `EducationalOrganization`.
|
|
*
|
|
* Both are valid, but EducationalOrganization is the parent type covering
|
|
* universities, training providers and nurseries alike. School is the
|
|
* specific one, and a type that says what the page is about is the whole
|
|
* point of declaring it. Google's own guidance treats the narrower type as
|
|
* the correct choice where it applies.
|
|
*/
|
|
const structuredData = {
|
|
'@context': 'https://schema.org',
|
|
'@graph': [{
|
|
'@type': 'School',
|
|
name: school_info.school_name,
|
|
identifier: school_info.urn.toString(),
|
|
...(school_info.address && {
|
|
address: {
|
|
'@type': 'PostalAddress',
|
|
streetAddress: school_info.address,
|
|
addressLocality: school_info.local_authority || undefined,
|
|
postalCode: school_info.postcode || undefined,
|
|
addressCountry: 'GB',
|
|
},
|
|
}),
|
|
...(school_info.latitude && school_info.longitude && {
|
|
geo: {
|
|
'@type': 'GeoCoordinates',
|
|
latitude: school_info.latitude,
|
|
longitude: school_info.longitude,
|
|
},
|
|
}),
|
|
...(school_info.school_type && {
|
|
additionalType: school_info.school_type,
|
|
}),
|
|
},
|
|
// The trail the page sits at the end of. School pages carried no
|
|
// breadcrumb at all, while every place page already emitted one.
|
|
schoolBreadcrumbJsonLd({
|
|
name: school_info.school_name,
|
|
url: `/school/${slug}`,
|
|
places,
|
|
}),
|
|
],
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
|
/>
|
|
{isSecondary ? (
|
|
<SchoolDetailShell
|
|
schoolInfo={school_info}
|
|
yearlyData={yearly_data}
|
|
census={census ?? null}
|
|
navItems={secondaryNavItems}
|
|
>
|
|
<SecondarySchoolSections
|
|
schoolInfo={school_info}
|
|
yearlyData={yearly_data}
|
|
absenceData={absence_data}
|
|
ofsted={ofsted ?? null}
|
|
census={census ?? null}
|
|
admissions={admissions ?? null}
|
|
admissionsHistory={admissions_history ?? []}
|
|
admissionDistance={admission_distance}
|
|
deprivation={deprivation ?? null}
|
|
finance={finance ?? null}
|
|
nationalAvg={nationalAvg}
|
|
destinations={destinations ?? null}
|
|
similarSchools={similarSchools}
|
|
flags={secondaryFlags}
|
|
/>
|
|
</SchoolDetailShell>
|
|
) : (
|
|
<SchoolDetailShell
|
|
schoolInfo={school_info}
|
|
yearlyData={yearly_data}
|
|
census={census ?? null}
|
|
navItems={primaryNavItems}
|
|
>
|
|
<PrimarySchoolSections
|
|
schoolInfo={school_info}
|
|
yearlyData={yearly_data}
|
|
absenceData={absence_data}
|
|
ofsted={ofsted ?? null}
|
|
census={census ?? null}
|
|
admissions={admissions ?? null}
|
|
admissionsHistory={admissions_history ?? []}
|
|
admissionDistance={admission_distance ?? null}
|
|
deprivation={deprivation ?? null}
|
|
finance={finance ?? null}
|
|
nationalAvg={nationalAvg}
|
|
similarSchools={similarSchools}
|
|
flags={primaryFlags}
|
|
/>
|
|
</SchoolDetailShell>
|
|
)}
|
|
<NearbyPlaces places={places} />
|
|
</>
|
|
);
|
|
}
|