Files
school_compare/nextjs-app/app/school/[slug]/page.tsx
T
TudorandClaude Opus 5 c9a1892bfb
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 16s
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) Failing after 3m17s
feat(admissions): publish the latest cut-off only, holding history back
Earlier years are to become a paid feature, so they stop being published.

The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.

Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.

What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.

Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.

One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.

The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
2026-08-20 18:44:57 +01:00

267 lines
9.4 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, 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,
hasLocation: school_info.latitude != null && school_info.longitude != 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}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? 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 ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
flags={primaryFlags}
/>
</SchoolDetailShell>
)}
</>
);
}