PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 9m28s
Leftover from the mechanical extraction: the shell still called computeSchoolFlags() and derived isReportCard / ofstedInspectedDate / oeifAreas / oeifAllSameGrade / deprivationDesc / primaryAvg / secondaryAvg on every render, duplicating work page.tsx already does. None of those values were referenced in its JSX anymore -- that logic moved to the section composers. The chrome needs only four locally-derived values (latestResults, phase, isAllThrough, hasLocation), all one-liners over props it already owns. Removing them made seven props dead, which TypeScript caught at both call sites: absenceData, ofsted, admissions, admissionsHistory, deprivation, finance and nationalAvg now go straight to the section composers and never reach the client component. The shell's surface is down to schoolInfo, yearlyData, census, navItems and children. No behaviour change: 155 tests pass and the characterization tests remain byte-identical to the commit that introduced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
9.1 KiB
TypeScript
262 lines
9.1 KiB
TypeScript
/**
|
|
* Individual School Page (SSR)
|
|
* Dynamic route for school details with full SEO optimization
|
|
* URL format: /school/138267-school-name-here
|
|
*/
|
|
|
|
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
|
import { notFound, redirect } from 'next/navigation';
|
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
|
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 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: `https://schoolcompare.co.uk${canonicalPath}`,
|
|
siteName: 'SchoolCompare',
|
|
},
|
|
twitter: {
|
|
card: 'summary',
|
|
title,
|
|
description,
|
|
},
|
|
alternates: {
|
|
canonical: `https://schoolcompare.co.uk${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) {
|
|
console.error(`Failed to fetch school ${urn}:`, error);
|
|
notFound();
|
|
}
|
|
|
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
|
|
|
// 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,
|
|
};
|
|
const primaryFlags = computeSchoolFlags(sectionInput);
|
|
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
|
const navInput = {
|
|
ofsted: ofsted ?? null,
|
|
admissions: admissions ?? null,
|
|
yearlyDataLength: yearly_data.length,
|
|
};
|
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
|
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
|
|
|
// Generate JSON-LD structured data for SEO
|
|
const structuredData = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'EducationalOrganization',
|
|
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,
|
|
}),
|
|
};
|
|
|
|
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}
|
|
deprivation={deprivation ?? null}
|
|
finance={finance ?? null}
|
|
nationalAvg={nationalAvg}
|
|
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 ?? []}
|
|
deprivation={deprivation ?? null}
|
|
finance={finance ?? null}
|
|
nationalAvg={nationalAvg}
|
|
flags={primaryFlags}
|
|
/>
|
|
</SchoolDetailShell>
|
|
)}
|
|
</>
|
|
);
|
|
}
|