refactor(detail): render sections on the server behind a client shell

page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.

The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.

Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.

Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
  "Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
  still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed

The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-08-02 21:39:21 +01:00
co-authored by Claude Opus 5
parent a7f6ff4035
commit 752eb07310
15 changed files with 1586 additions and 5089 deletions
@@ -11,15 +11,14 @@
* toggle renders at all, so such pages ship zero admissions JavaScript.
*/
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
const AdmissionsTrendChart = dynamic(() => import('../AdmissionsTrendChart'), { ssr: false });
export function AdmissionsSection({
admissions,
@@ -4,16 +4,10 @@
* were only 40% similar). Server component.
*/
import dynamic from 'next/dynamic';
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
import { PerformanceChart, SatsChart } from './charts';
export function HistorySection({
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
@@ -0,0 +1,139 @@
/**
* PrimarySchoolSections — the section sequence for primary and all-through
* detail pages. Server component.
*
* All-through schools route here rather than to SecondarySchoolSections,
* because this list renders BOTH the KS2 and KS4 blocks (ResultsSection and
* HistorySection branch on isAllThrough); the secondary list is KS4-only and
* would silently drop their SATs data.
*
* The render conditions here MUST match buildNavItems in lib/schoolSections,
* or the sticky nav will link to sections that do not exist.
*/
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection';
import { InclusionSection } from './InclusionSection';
import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection';
import { LocalAreaSection } from './LocalAreaSection';
import { FinancesSection } from './FinancesSection';
export interface PrimarySchoolSectionsProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
flags: SchoolFlags;
}
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
return (
<>
{ofsted && (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
isReportCard={isReportCard}
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="primary"
/>
)}
{flags.hasAnyResults && flags.latestResults && (
<ResultsSection
latestResults={flags.latestResults}
schoolInfo={schoolInfo}
primaryAvg={primaryAvg}
secondaryAvg={secondaryAvg}
isAllThrough={flags.isAllThrough}
isSecondary={flags.isSecondary}
isSpecial={flags.isSpecial}
hasKS2Results={flags.hasKS2Results}
hasKS4Results={flags.hasKS4Results}
ks2Placeholder={flags.ks2Placeholder}
suppressKs2Comparison={flags.suppressKs2Comparison}
suppressKs4Comparison={flags.suppressKs4Comparison}
/>
)}
{admissions && (
<AdmissionsSection
admissions={admissions}
admissionsHistory={admissionsHistory}
isAllThrough={flags.isAllThrough}
/>
)}
{flags.hasInclusionData && (
<InclusionSection
latestResults={flags.latestResults}
census={census}
hasGenderSplit={flags.hasGenderSplit}
primaryAvg={primaryAvg}
/>
)}
{yearlyData.length > 0 && (
<HistorySection
yearlyData={yearlyData}
schoolInfo={schoolInfo}
nationalAvg={nationalAvg}
primaryAvg={primaryAvg}
secondaryAvg={secondaryAvg}
isAllThrough={flags.isAllThrough}
isPrimary={flags.isPrimary}
isSecondary={flags.isSecondary}
hasKS2Results={flags.hasKS2Results}
hasKS4Results={flags.hasKS4Results}
suppressKs2Comparison={flags.suppressKs2Comparison}
suppressKs4Comparison={flags.suppressKs4Comparison}
/>
)}
{flags.hasSchoolLife && (
<SchoolLifeSection absenceData={absenceData} primaryAvg={primaryAvg} />
)}
{flags.hasDeprivation && deprivation && (
<LocalAreaSection deprivation={deprivation} />
)}
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
</>
);
}
@@ -3,15 +3,14 @@
* Primary and all-through pages. Server component.
*/
import dynamic from 'next/dynamic';
import type { School, SchoolResult } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { DeltaChip } from '../DeltaChip';
import { SpecialSchoolNote } from '../SpecialSchoolNote';
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
import { SatsChart } from './charts';
const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
export function ResultsSection({
latestResults, schoolInfo, primaryAvg, secondaryAvg,
@@ -0,0 +1,669 @@
/* Styles for SchoolDetailShell — the interactive chrome of a detail page.
Derived from the classes the shell's JSX references; the section styles
live in components/school/schoolSections.module.css. Classes used by both
appear in both files, which is correct: CSS Modules hash them per-file. */
.container {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* Standalone back link, sits above the header card on the page background. */
.topBack {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.75rem;
padding: 0.25rem 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: none;
cursor: pointer;
line-height: 1.2;
transition: color 0.15s ease;
}
.topBack:hover {
color: var(--accent-coral-dark, #c85a3e);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Header Section */
.header {
position: relative;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
/* Padding lives on .headerContent so the map band can bleed to the edges. */
padding: 0;
margin-bottom: 0;
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
padding: 1.25rem 1.5rem;
}
/* With a map band above, slide the title up under the fade so map and title
read as one object; the Compare button floats glassy over the map. */
.headerHasMap .headerContent {
padding-top: 0;
margin-top: -0.5rem;
}
/* The title (not the whole content row) rises above the map fade. Keeping
.headerContent unpositioned matters: .actions must anchor to .header so it
floats over the map band, not over the title. */
.headerHasMap .titleSection {
position: relative;
z-index: 3;
}
.headerHasMap .actions {
position: absolute;
top: 14px;
right: 14px;
z-index: 6;
margin: 0;
/* Beat the mobile `.actions { width: 100% }` rule — a floating button
must never stretch across the title. */
width: auto;
}
.headerHasMap .actions .btnAdd {
background: rgba(255, 255, 255, 0.9);
color: var(--accent-coral-dark, #b04a2e);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.headerHasMap .actions .btnAdd:hover {
background: #fff;
}
/* Full label by default; phones over the map get an icon-only button
(same compact treatment as the section-nav compare icon). */
.btnCompareGlyph {
display: none;
}
/* Inline "View on map ↗" trigger next to the address. */
.mapLink {
border: none;
background: none;
padding: 0;
font: inherit;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
cursor: pointer;
white-space: nowrap;
}
.mapLink:hover {
color: var(--accent-coral-dark, #c45a3f);
text-decoration: underline;
text-underline-offset: 2px;
}
.titleSection {
flex: 1;
}
.schoolName {
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem;
line-height: 1.1;
letter-spacing: -0.01em;
font-family: var(--font-playfair), "Playfair Display", serif;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.metaItem {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 3px;
}
.address {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
margin: 0 0 0.75rem;
}
/* Expanded header details (headteacher, website, trust, pupils) */
.headerDetails {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.5rem;
}
.headerDetail {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
}
.headerDetail strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
.headerDetail a {
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
}
.headerDetail a:hover {
text-decoration: underline;
}
/* "Show all details" reveal — only rendered on mobile/tablet, where the
header details block is collapsed below the fold. Hidden on desktop. */
.detailsToggle {
display: none;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
padding: 0;
background: none;
border: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
cursor: pointer;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
align-self: center;
}
.btnAdd,
.btnRemove {
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
}
.btnAdd {
background: var(--accent-coral-dark, #b04a2e);
color: white;
}
.btnAdd:hover {
background: var(--accent-coral-darker, #9c3f26);
transform: translateY(-1px);
}
.btnRemove {
background: var(--accent-teal, #2d7d7d);
color: white;
}
.btnRemove:hover {
opacity: 0.9;
}
/* ── Sticky Section Navigation ──────────────────────── */
/* Docks directly under the global header; Back and "All" stay pinned while
only the section links scroll. */
.sectionNav {
position: sticky;
top: 64px; /* global header height on desktop */
z-index: 10;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-top: none;
border-radius: 0 0 10px 10px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 0.5rem;
}
.sectionNavBack {
flex: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sectionNavBack:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
}
/* The scrolling middle: section links only. */
.sectionNavLinks {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.375rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scroll-snap-type: x proximity;
scroll-padding-inline: 0.5rem;
}
.sectionNavLinks::-webkit-scrollbar {
display: none;
}
.sectionNavLink {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
white-space: nowrap;
scroll-snap-align: start;
}
.sectionNavLink:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.sectionNavLinkActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.sectionNavLinkActive:hover {
background: var(--accent-coral-dark, #c45a3f);
color: white;
}
/* ── Mobile: the scrolling links collapse into one "section" menu button ──
(hidden on desktop, where the links fit). */
.sectionNavMenu {
display: none; /* shown only ≤640px */
flex: 1;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 38px;
padding: 0.34rem 0.7rem;
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
cursor: pointer;
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
color: var(--text-primary, #1a1612);
}
.sectionNavMenuCur {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.sectionNavMenuEyebrow {
flex: none;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.sectionNavMenuNow {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sectionNavMenuChev {
flex: none;
color: var(--text-muted, #6d685f);
font-size: 0.7rem;
}
/* Compact icon version of the Compare CTA, used on mobile. */
.sectionNavCompareIcon {
display: none; /* shown only ≤640px */
position: relative;
flex: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 9px;
border: 1px solid var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-dark, #b04a2e);
color: white;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.sectionNavCompareIcon svg {
width: 19px;
height: 19px;
}
.sectionNavCompareBadge {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-card, white);
color: var(--accent-coral-dark, #c45a3f);
border: 1.5px solid var(--accent-coral, #e07256);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 800;
line-height: 1;
}
.sectionNavCompareIconIn {
background: var(--bg-card, white);
border-color: var(--accent-teal, #2d7d7d);
color: var(--accent-teal, #2d7d7d);
}
/* Compare CTA carried into the bar once the hero's button scrolls away. */
.sectionNavCompare {
flex: none;
display: inline-flex;
align-items: center;
padding: 0.34rem 0.7rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral-dark, #b04a2e);
border: 1px solid var(--accent-coral-dark, #b04a2e);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sectionNavCompare:hover {
background: var(--accent-coral-darker, #9c3f26);
border-color: var(--accent-coral-darker, #9c3f26);
}
.sectionNavCompareIn {
background: var(--bg-card, white);
color: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
}
.sectionNavCompareIn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-teal, #2d7d7d);
}
/* "All ▾" jump menu (desktop). */
.sectionNavAll {
flex: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.34rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease;
}
.sectionNavAll:hover {
background: var(--border-color, #e5dfd5);
}
.sectionsBackdrop {
position: fixed;
inset: 0;
z-index: 1500;
background: rgba(26, 22, 18, 0.28);
}
.sectionsPanel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1600;
width: 230px;
max-height: min(70vh, 460px);
overflow-y: auto;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
padding: 0.35rem;
}
.sectionsPanelHead {
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
padding: 0.4rem 0.6rem 0.5rem;
}
.sectionsItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
text-decoration: none;
transition: background 0.12s ease;
}
.sectionsItem:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.sectionsItemActive {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral-dark, #c45a3f);
font-weight: 600;
}
.sectionsTick {
color: var(--accent-coral-dark, #b04a2e);
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
@media (max-width: 640px) {
.headerHasMap .actions .btnCompareLabel {
display: none;
}
.headerHasMap .actions .btnCompareGlyph {
display: inline;
}
.headerHasMap .actions .btnAdd,
.headerHasMap .actions .btnRemove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 40px;
height: 40px;
padding: 0;
border-radius: 999px;
font-size: 1.375rem;
line-height: 1;
}
}
@media (max-width: 640px) {
.sectionNav {
top: 56px; /* global header is shorter on mobile */
padding: 0.4rem 0.6rem;
gap: 0.375rem;
}
}
@media (max-width: 640px) {
.sectionNavLink,
.sectionNavBack {
min-height: 36px;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.sectionNavCompare {
min-height: 36px;
}
}
@media (max-width: 640px) {
.sectionNavAll {
min-height: 36px;
}
}
@@ -0,0 +1,512 @@
/**
* SchoolDetailShell — the interactive chrome of a school detail page.
*
* The ONLY large client component on the route. Everything below the sticky
* nav is server-rendered and arrives as `children`, composed in
* app/school/[slug]/page.tsx. That indirection is required: a server component
* imported by a client component becomes a client component, so the sections
* cannot be imported here.
*
* What stays client-side is genuinely interactive: router.back(), the header
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
* sections with document.getElementById, so it works unchanged against
* server-rendered children.
*/
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas,
} from '@/lib/utils';
import { computeSchoolFlags, type NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
export interface SchoolDetailShellProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
/** Fetched on the server so the England-comparison deltas are in the
* initial HTML; null when the endpoint is unavailable. */
nationalAvg: NationalAverages | null;
/** Section list for the sticky nav, computed on the server. */
navItems: NavItem[];
/** The server-rendered sections. */
children: ReactNode;
}
export function SchoolDetailShell({
schoolInfo, yearlyData, absenceData,
ofsted, census, admissions, admissionsHistory, deprivation, finance,
nationalAvg, navItems, children,
}: SchoolDetailShellProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
// Admissions view state moved to AdmissionsViewToggle, the client island
// inside the (server-rendered) admissions section.
// Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
const heroActionsRef = useRef<HTMLDivElement | null>(null);
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
// Back returns to wherever the user came from; deep-links (no in-app history)
// fall back to search so the button never dead-ends or leaves the site.
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push('/search');
}
};
const scrollToTop = () => {
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
const el = sectionLinksRef.current;
if (!el) return;
const update = () => {
const overflow = el.scrollWidth - el.clientWidth;
// No overflow → treat as "at end" so the fade is hidden.
if (overflow <= 1) {
setSectionNavAtEnd(true);
return;
}
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, []);
// Track whether the hero's "Add to Compare" button is still on screen.
useEffect(() => {
const el = heroActionsRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => setHeroCtaVisible(entry.isIntersecting),
{ rootMargin: '-64px 0px 0px 0px' },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
// Close the "All ▾" menu on Escape.
useEffect(() => {
if (!sectionsOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
// Derived data-shape logic lives in lib/schoolSections so the server route
// can compute the section list without importing this client component.
const flags = computeSchoolFlags({
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
});
const {
latestResults, isAllThrough, isSecondary, isPrimary,
hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation,
hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults,
isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
} = flags;
const phase = schoolInfo.phase ?? '';
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Page-view event with funnel attribution. Fires once per mount.
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: phase || 'unknown',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
const deprivationDesc = (decile: number) => {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
};
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<string, number> = {};
const pickActive = () => {
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
setActiveSection(top?.[1] > 0 ? top[0] : '');
};
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
ratioMap[id] = 0;
const obs = new IntersectionObserver(
([entry]) => {
ratioMap[id] = entry.intersectionRatio;
pickActive();
},
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach(o => o.disconnect());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// A report card is dated by its own inspection (rc_inspection_date); the
// legacy inspection_date belongs to an older inspection and must never
// date a report card (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
return (
<div className={styles.container}>
{/* Standalone back link, above the header — returns to wherever the
user came from. Scrolls away with the page (the sticky bar keeps a
"back to top" control in its place). */}
<button type="button" onClick={handleBack} className={styles.topBack}>
<span aria-hidden="true"></span> Back
</button>
{/* Header — the location map band blends down into the school title. */}
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
{hasLocation && (
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
)}
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{hasLocation && (
<>
{' · '}
<button
type="button"
className={styles.mapLink}
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
>
View on map
</button>
</>
)}
</p>
)}
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
)}
{(() => {
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions} ref={heroActionsRef}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
>
{/* On phones the map hero shows only the glyph (nav-bar style). */}
<span className={styles.btnCompareLabel}>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</span>
<span className={styles.btnCompareGlyph} aria-hidden="true">
{isInComparison ? '✓' : '+'}
</span>
</button>
</div>
</div>
</header>
{/* Sticky Section Navigation — docks under the global header */}
<nav className={styles.sectionNav} aria-label="Page sections">
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
<span aria-hidden="true"></span>
<span className={styles.sectionNavBackLabel}>Top</span>
</button>
{/* Desktop: scrolling section links */}
<div
ref={sectionLinksRef}
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
))}
</div>
{/* Mobile: a single "section" menu button that opens the jump sheet */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavMenu}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
<span className={styles.sectionNavMenuCur}>
<span className={styles.sectionNavMenuEyebrow}>Section</span>
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
</span>
<span className={styles.sectionNavMenuChev} aria-hidden="true"></span>
</button>
)}
{/* The hero's Compare CTA, carried in once it scrolls out of view.
Desktop shows a labelled pill; mobile a compact icon. */}
{!heroCtaVisible && (
<>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
>
{isInComparison ? '✓ Comparing' : '+ Compare'}
</button>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
title={isInComparison ? 'In comparison' : 'Add to compare'}
>
{isInComparison ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12 5 5 9-11" />
</svg>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M4 7h13l-3-3" />
<path d="M20 17H7l3 3" />
</svg>
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
</>
)}
</button>
</>
)}
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavAll}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
All <span aria-hidden="true"></span>
</button>
)}
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
{sectionsOpen && (
<>
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
<div className={styles.sectionsPanelHead}>Jump to section</div>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
role="menuitem"
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
onClick={() => {
setSectionsOpen(false);
track('section_nav_used', { section: id, via: 'all_menu' });
}}
>
<span>{label}</span>
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true"></span>}
</a>
))}
</div>
</>
)}
</nav>
{children}
</div>
);
}
@@ -3,15 +3,11 @@
* Server component.
*/
import dynamic from 'next/dynamic';
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import { PerformanceChart } from './charts';
const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
export function SecondaryHistorySection({
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
@@ -0,0 +1,115 @@
/**
* SecondarySchoolSections — the section sequence for secondary detail pages.
* Server component.
*
* Wrapped in `.secondaryScope`, which activates the secondary-only style
* overrides in schoolSections.module.css. Those rules target class names the
* primary page also uses (.card, .sectionTitle, .metricCard …), so scoping is
* what keeps them from restyling primary pages.
*
* The render conditions here MUST match buildSecondaryNavItems in
* lib/schoolSections, or the sticky nav will link to sections that do not exist.
*/
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { GcseSection } from './GcseSection';
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection';
import styles from './schoolSections.module.css';
export interface SecondarySchoolSectionsProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
flags: SecondaryFlags;
}
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
return (
<div className={styles.secondaryScope}>
{ofsted && (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
isReportCard={isReportCard}
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="secondary"
/>
)}
{flags.hasResults && flags.latestResults && (
<GcseSection
latestResults={flags.latestResults}
schoolInfo={schoolInfo}
secondaryAvg={secondaryAvg}
p8Suspended={flags.p8Suspended}
suppressComparison={flags.suppressComparison}
/>
)}
{admissions && (
<SecondaryAdmissionsSection
admissions={admissions}
schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm}
/>
)}
{yearlyData.length > 1 && (
<SecondaryHistorySection
yearlyData={yearlyData}
schoolInfo={schoolInfo}
nationalAvg={nationalAvg}
secondaryAvg={secondaryAvg}
suppressComparison={flags.suppressComparison}
/>
)}
{flags.hasWellbeing && (
<WellbeingSection
latestResults={flags.latestResults}
census={census}
schoolInfo={schoolInfo}
deprivation={deprivation}
hasDeprivation={flags.hasDeprivation}
/>
)}
{flags.hasFinance && finance && (
<FinancesSection finance={finance} showPremises />
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
'use client';
/**
* Client wrappers for the lazily-loaded charts.
*
* `next/dynamic` with `ssr: false` is only legal inside a Client Component,
* and the sections that render charts are Server Components. These one-line
* wrappers are the client boundary, so the charts stay browser-only and
* code-split while the section markup around them stays on the server.
*
* Chart.js is ~64 KB gzipped, so keeping it lazy matters.
*/
import dynamic from 'next/dynamic';
export const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
export const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
export const AdmissionsTrendChart = dynamic(
() => import('../AdmissionsTrendChart'),
{ ssr: false },
);