PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page is a section with a nav entry, and "how close did we need to live, and would we have got in?" is a topic, not a variant reading of the intake figures. The headline number stays on the Admissions tile where the intake story is; the record behind it now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
270 lines
9.6 KiB
TypeScript
270 lines
9.6 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, admission_distance, admission_distance_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,
|
|
admissionDistance: admission_distance ?? null,
|
|
admissionDistanceHistory: admission_distance_history ?? [],
|
|
admissionsHistory: admissions_history ?? [],
|
|
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}
|
|
admissionsHistory={admissions_history ?? []}
|
|
admissionDistance={admission_distance ?? null}
|
|
admissionDistanceHistory={admission_distance_history ?? []}
|
|
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 ?? []}
|
|
admissionDistance={admission_distance ?? null}
|
|
admissionDistanceHistory={admission_distance_history ?? []}
|
|
deprivation={deprivation ?? null}
|
|
finance={finance ?? null}
|
|
nationalAvg={nationalAvg}
|
|
flags={primaryFlags}
|
|
/>
|
|
</SchoolDetailShell>
|
|
)}
|
|
</>
|
|
);
|
|
}
|