From d1c92ea7d5bc10be323a032b163139ccaa3d1a04 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 30 Jul 2026 15:35:54 +0100 Subject: [PATCH 01/13] docs(perf): spec for detail-page server/client split Design for splitting SchoolDetailView (65KB) and SecondarySchoolDetailView (46KB) into server section components behind a small client shell, following the existing components/compare/ decomposition pattern. Key constraint: a server component imported by a client component becomes client, so sections are composed in page.tsx and passed through the shell as children. Moving /api/national-averages server-side is a prerequisite, since the England-comparison deltas feed most sections. Co-Authored-By: Claude Opus 5 --- .../2026-07-30-server-client-split-design.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-server-client-split-design.md diff --git a/docs/superpowers/specs/2026-07-30-server-client-split-design.md b/docs/superpowers/specs/2026-07-30-server-client-split-design.md new file mode 100644 index 0000000..67ba55d --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-server-client-split-design.md @@ -0,0 +1,232 @@ +# Detail pages: server/client split + +**Date:** 2026-07-30 +**Status:** Approved, ready for implementation planning +**Branch:** `perf/server-client-split` + +## Problem + +Every page of the site ships ~172 KB gzipped of JavaScript before any +page-specific code loads. The 404 page costs that much; `/admissions`, a static +content page with no data, costs 177 KB. A well-tuned Next.js app baseline is +90-110 KB. + +The cause is that nearly the whole site is a client component. +`SchoolDetailView.tsx` is 65 KB of source marked `'use client'`; +`SecondarySchoolDetailView.tsx` is 46 KB. Both are overwhelmingly presentational +markup driven by props the server already fetched. Every stat tile, table row and +Ofsted badge is serialised into the RSC payload *and* shipped again as JavaScript +to hydrate. + +Measured on a production build of `cab7b4f` (Next 16.1.6, Turbopack): + +| Metric | Value | +| --- | --- | +| Baseline JS, every page | 172 KB gz across 8 chunks | +| `/admissions` (no data) | 177 KB gz | +| Chart.js chunk | 64 KB gz (correctly lazy) | +| Leaflet chunk | 41 KB gz (correctly lazy) | +| CSS | ~30 KB gz, route-split | + +**Scope of this document.** The table above characterises the whole problem; this +design addresses one part of it — the route-level client JS on school detail +pages, the site's highest-traffic and most SEO-critical route. The shared +baseline and the other routes are separate work, tracked elsewhere. + +## What makes this tractable + +Two properties of the existing code make an aggressive split low-risk. + +**The section bodies are almost entirely static.** Across ~750 lines of sections +in `SchoolDetailView` (lines 596-1350), the only interactivity is five charts +(already `dynamic({ ssr: false })` islands), fourteen `` +instances (already a small client leaf), and two buttons driving the admissions +year/trend toggle. Everything else renders props. + +**The scroll-spy is already decoupled.** `SchoolDetailView.tsx:283` locates +sections with `document.getElementById(id)`, and `navItems` (lines 258-267) is +computed purely from data. The navigation does not care which component renders +the sections, so server-rendered children work unchanged. + +There is also a precedent to follow: `components/compare/` is already decomposed +this way — one file per section plus a `sectionShared.tsx` of primitives. + +## Architecture + +A server component imported by a client component becomes a client component. So +the sections cannot be children of a client `SchoolDetailView`. They are composed +in `page.tsx` and passed *through* the client shell as `children` — the pattern +already used at `app/page.tsx:98-99`. + +The header and navigation sit above the sections, and the sections are plain +siblings, so a single `children` slot suffices. Named slots were considered and +rejected: more ceremony, no benefit when sections render in sequence. + +``` +app/school/[slug]/page.tsx server; fetches details + nationalAverages +│ computes isSecondary / isAllThrough / navItems +└── 'use client' — the only large client file + │ back link · header + details reveal · hero map · compare CTA + │ sticky section nav · scroll spy · jump sheet + └── children: | server + └── components/school/ all server, mirroring components/compare/ + OfstedSection · ResultsSection · AdmissionsSection + InclusionSection · HistorySection · SchoolLifeSection + LocalAreaSection · FinancesSection · WellbeingSection + sectionShared.tsx Section/Card/Row primitives +``` + +Phase branching stays out of the route file: two thin server components +(`PrimarySchoolSections`, `SecondarySchoolSections`) return the section sequence, +and `page.tsx` picks one. + +### Client leaves inside server sections + +Importing a client component into a server component is the legal direction, so +these stay client and are used from server sections unchanged: + +- `MetricTooltip` / `InfoPopover` — exist, unchanged +- `PerformanceChart`, `SatsChart`, `AdmissionsTrendChart` — exist, stay `dynamic({ ssr: false })` +- `AdmissionsViewToggle` — **new**, ~25 lines; receives server-rendered year-view + and trend-view as two props and swaps between them +- `DeltaChip`, `SpecialSchoolNote` — already server-compatible (no `'use client'`) + +### What stays in the client shell, and why + +The shell is genuinely interactive throughout: `router.back()`, the details +reveal, `IntersectionObserver` hero-CTA tracking, the nav overflow fade, the +Escape-to-close sheet, and the comparison context. The goal is not to shrink the +shell but to stop the other ~750 lines being attached to it. + +### Section sharing + +| Shared by both views | Primary only | Secondary only | +| --- | --- | --- | +| Ofsted, Admissions, History, Finances, and the shell | Results (KS2), Inclusion, School Life, Local Area | Wellbeing | + +All-through schools render both the KS2 and KS4 section sets. The shared library +makes this natural rather than duplicated. + +### Prerequisite: national averages move server-side + +`SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`, +and `primaryAvg` / `secondaryAvg` feed the England-comparison deltas across most +sections. Those sections cannot be server components until that data arrives as a +prop. `SecondarySchoolDetailView.tsx:91-93` has the same fetch. + +`page.tsx` will fetch it in parallel with the school details (the endpoint is +backend-cached for an hour) and pass `nationalAvg` down. This is a prerequisite, +not an optional extra. It independently removes a client round-trip per detail +page and puts the deltas in the initial HTML instead of popping in after +hydration. + +### Layout + +`Footer` is already a server component — no change needed. `Navigation` genuinely +requires client (`usePathname` plus the comparison count) and stays as it is. +This item is much smaller than the original review estimated. + +### Unused dependency + +`hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts` and `useSchoolDetails.ts` +are imported by nothing. They are the only consumers of `swr`. All four files and +the `swr` dependency are deleted. + +## Testing + +`SchoolDetailView` will not exist as a single component afterwards, so +characterization tests must not import it directly. A single render helper +absorbs the change: + +``` +__tests__/support/renderSchoolDetail.tsx the only file that changes at the split + before: render() + after: render() +``` + +Every assertion imports that helper and never changes. Assertions passing +untouched across the refactor is the proof that behaviour is preserved. + +**Coverage:** Ofsted panel (rating, date, report link); KS2 results rows and +England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the +year/trend toggle; inclusion and wellbeing figures; the history table; finances; +and the conditional `navItems` set. + +**Fixtures:** four shapes — primary, secondary, all-through, and special school. +The special-school path drops the England comparison via `isSpecialSchool()` and +is the site of a known past regression (PR #70), so it gets explicit coverage. + +**Environment gaps to close in `jest.setup.js`:** jsdom provides neither +`IntersectionObserver` (used twice in each view) nor `window.scrollTo`. Chart.js +needs a canvas, so the chart components are mocked. `next/jest` already handles +CSS modules, and `next/navigation` is already globally mocked. Section components +are synchronous functions, so React Testing Library renders both the server and +client pieces in jsdom without special handling. + +**E2E:** `e2e/journeys.spec.ts` gains detail-page assertions. CLAUDE.md requires +e2e updates in the same PR as user-facing changes. + +## Commit sequence + +One PR, but the diff is large, so it is structured to stay reviewable and +bisectable: + +1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures +2. `test:` characterization tests against the current components — all green before anything moves +3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches +4. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits) +5. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views +6. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json` +7. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions + +Commits 4 and 5 move the bytes; commits 1 and 2 are the safety net that makes +them safe. Commit 6 lands late so it cannot confuse a bisect. + +## Verification + +Before the work is claimed complete: + +- `npm run typecheck`, `npm test` and `npm run build` all green +- characterization tests passing **unmodified** from commit 2 +- before/after gzipped JS per route recorded in the PR description, measured the + same way as the figures in the Problem section + +## Success criteria + +- `/school/[slug]` route-specific JS drops to roughly 40-60 KB gz, from a client + tree currently built out of a 65 KB and a 46 KB source file +- No visual or behavioural change on any of the four school shapes +- One fewer client API round-trip per detail page + +**This change does not reduce the 172 KB baseline.** That baseline is react-dom, +the Next.js runtime, and the layout's client components (`Navigation`, +`ComparisonProvider`, `ComparisonToast`) — none of which this design touches, as +`Navigation` legitimately needs `usePathname` and the comparison count. The +baseline is a separate piece of work; it is cited above only to establish why +route-level client JS matters. Reporting must state the two numbers separately so +the unchanged baseline is not read as a failure of this change. + +Budget enforcement in CI was considered and deliberately deferred: measurement is +recorded in the PR, but no new CI gate is added in this change. + +## Non-goals + +- No visual redesign or copy changes to any section +- No change to `Navigation`, `Footer`, or any other route +- No CI bundle-budget gate +- Bugs found mid-move are recorded, not fixed inline + +## Risks + +**DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS +modules depend on. Commits 4 and 5 are strict moves and the section CSS moves +with them; any styling change is out of scope. + +**All-through schools.** The trickiest case: `isAllThrough` gates KS2 content +back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It +gets a dedicated fixture. + +**Large diff.** Accepted deliberately in favour of designing the shared section +library against both consumers at once. Mitigated by the commit sequence above. + +**`swr` removal** is safe — nothing imports those hooks. From 97dd69a5127658a1f446798bef59d0fc2c0065a1 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 1 Aug 2026 20:47:21 +0100 Subject: [PATCH 02/13] docs(perf): resolve CSS strategy for shared section components Planning surfaced a gap: the two detail views' CSS modules share 79 class names, 22 with differing rules, 16 of those used in section markup by both views. Shared sections cannot use one stylesheet without visual change. Resolution: union the 14 incidental-drift differences (defensive overflow properties never back-ported between the views), and keep the 2 genuine visual differences (.genderBar, .heroStatValue) as distinct classes behind a variant prop. Adds an isolated CSS-merge commit so a visual regression bisects to the merge rather than a JSX move. Co-Authored-By: Claude Opus 5 --- .../2026-07-30-server-client-split-design.md | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-30-server-client-split-design.md b/docs/superpowers/specs/2026-07-30-server-client-split-design.md index 67ba55d..fe57f96 100644 --- a/docs/superpowers/specs/2026-07-30-server-client-split-design.md +++ b/docs/superpowers/specs/2026-07-30-server-client-split-design.md @@ -107,6 +107,41 @@ shell but to stop the other ~750 lines being attached to it. All-through schools render both the KS2 and KS4 section sets. The shared library makes this natural rather than duplicated. +### CSS strategy + +Shared section components need one stylesheet, but the two existing CSS modules +disagree. Measured on `cab7b4f`: `SchoolDetailView.module.css` defines 142 +classes, `SecondarySchoolDetailView.module.css` 112, with 79 names in common — of +which 22 have different rules, and 16 of those are used inside section markup by +both views (`.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, +`.metricsGrid`, `.tableWrapper`, the `heroStat*` set, the `genderSplit*` set, +`.historyToggle`, `.ofstedReportLink`). + +The 16 conflicts split into two kinds: + +**Incidental drift (14 classes).** One view carries defensive overflow properties +the other lacks — `min-width: 0`, `overflow-wrap: break-word`, +`word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. +These are independent bug-fixes that were never back-ported, not design +decisions. **Resolution: take the union.** No visible change, and it fixes latent +long-school-name overflow on whichever page currently lacks the property. + +**Real visual differences (2 classes).** + +| Class | Primary | Secondary | +| --- | --- | --- | +| `.genderBar` | `height: 6px`, `background: var(--border-color, #e5dfd5)`, `width: 100%` | `height: 4px`, `background: rgba(0, 0, 0, 0.08)`, `margin-top: 0.45rem` | +| `.heroStatValue` | `gap: 0.5rem`, `justify-content: flex-start`, Playfair fallback includes Georgia | `gap: 0.4rem`, Playfair fallback omits Georgia | + +**Resolution: keep both looks as distinct classes** and have the owning section +take a `variant: 'primary' | 'secondary'` prop selecting between them. Zero +intended visual change, one shared stylesheet, and the divergence becomes +explicit rather than accidental. + +The merged stylesheet is `components/school/schoolSections.module.css`. Shell-only +rules stay behind in the per-view modules. Classes outside the 79 shared names +move across unchanged. + ### Prerequisite: national averages move server-side `SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`, @@ -174,13 +209,16 @@ bisectable: 1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures 2. `test:` characterization tests against the current components — all green before anything moves 3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches -4. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits) -5. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views -6. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json` -7. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions +4. `refactor:` extract the shared derived-flags/`navItems` module +5. `style:` merge the two CSS modules into `components/school/schoolSections.module.css` per the CSS strategy above +6. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits) +7. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views +8. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json` +9. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions -Commits 4 and 5 move the bytes; commits 1 and 2 are the safety net that makes -them safe. Commit 6 lands late so it cannot confuse a bisect. +Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes +them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge +rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect. ## Verification @@ -220,7 +258,12 @@ recorded in the PR, but no new CI gate is added in this change. **DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS modules depend on. Commits 4 and 5 are strict moves and the section CSS moves -with them; any styling change is out of scope. +with them; any styling change beyond the CSS-merge rules above is out of scope. + +**CSS merge.** Merging two large stylesheets is the highest-risk part of the +work. The union rule is mechanical, but a missed conflict shows up as a visual +regression that no unit test catches. The merge gets its own commit, and the +16 known conflicts are enumerated above so they can be verified individually. **All-through schools.** The trickiest case: `isAllThrough` gates KS2 content back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It From 7199c3a90b9691d9b75ef69c0611780143215af2 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 1 Aug 2026 20:52:56 +0100 Subject: [PATCH 03/13] docs(perf): implementation plan for detail-page server/client split Nine tasks mapping to the spec's commit sequence, each ending in an independently testable deliverable. Also corrects the spec's section-sharing table: measured similarity shows Admissions (14%) and History (40%) are not shareable between the two views, only Ofsted (80%) and Finances (91%). This costs no bytes -- the win comes from sections being server components, not from sharing them -- and cuts the diff and regression risk. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-01-server-client-split.md | 950 ++++++++++++++++++ .../2026-07-30-server-client-split-design.md | 30 +- 2 files changed, 975 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-01-server-client-split.md diff --git a/docs/superpowers/plans/2026-08-01-server-client-split.md b/docs/superpowers/plans/2026-08-01-server-client-split.md new file mode 100644 index 0000000..8b42c64 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-server-client-split.md @@ -0,0 +1,950 @@ +# Detail-page Server/Client Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop ~750 lines of static school-detail markup from shipping to the browser as JavaScript, by turning the page's sections into React Server Components behind a small client shell. + +**Architecture:** A server component imported by a client component becomes a client component. So the sections cannot be children of a client `SchoolDetailView`. Instead `app/school/[slug]/page.tsx` (server) composes the sections and passes them *through* a small client shell as `children` — the pattern already used at `app/page.tsx:98-99`. The shell keeps the genuinely interactive chrome (back link, header reveal, hero map, compare CTA, sticky nav, scroll-spy); everything below it becomes server-rendered. + +**Tech Stack:** Next.js 16.1.6 (App Router, Turbopack), React 19, TypeScript, CSS Modules, Jest + React Testing Library, Playwright (e2e). + +**Spec:** `docs/superpowers/specs/2026-07-30-server-client-split-design.md` + +## Global Constraints + +- Branch is `perf/server-client-split`, already created off `origin/main` at `cab7b4f`. Never push to `main`; this lands as a PR (CLAUDE.md SDLC). +- All paths below are relative to the repo root. The Next.js app lives in `nextjs-app/`; run all `npm` commands from there. +- **Do not start a local server to test** — it does not work in this environment (CLAUDE.md). Verification is `npm run typecheck`, `npm test`, `npm run build`. +- **No visual or behavioural change** on any of the four school shapes, except the two documented CSS merges. No redesign, no copy changes. +- Commits 6 and 7 are **pure moves**: JSX is relocated verbatim, changing only the `styles` import and prop plumbing. If you find a bug mid-move, record it in the PR description — do not fix it inline. +- Characterization tests written in Task 2 must pass **unmodified** through Tasks 3-8. The only file permitted to change is `__tests__/support/renderSchoolDetail.tsx`. +- End commit messages with `Co-Authored-By: Claude Opus 5 `. + +--- + +## What is shared and what is not + +Measured similarity between the two views' sections (`difflib.SequenceMatcher` on stripped lines): + +| Section | Primary lines | Secondary lines | Similarity | Decision | +| --- | --- | --- | --- | --- | +| Ofsted | 88 | 107 | 80% | **Shared** — `OfstedSection` | +| Finances | 29 | 35 | 91% | **Shared** — `FinancesSection` | +| History | 121 | 47 | 40% | Separate components | +| Admissions | 89 | 55 | 14% | Separate components | + +The spec's section-sharing table listed Admissions and History as shared; measurement shows they are not. **This costs no bytes** — the client-JS win comes from a section being a *server* component, not from being shared. Sharing is a maintainability bonus, taken only where it is cheap. + +## File Structure + +**Create — `nextjs-app/lib/`** +- `schoolSections.ts` — pure derived flags (`hasInclusionData`, `hasKS2Results`, `suppressKs2Comparison`, …) and `buildNavItems()`. No JSX, no React. Unit-testable in isolation; consumed by `page.tsx` and the sections. + +**Create — `nextjs-app/components/school/`** (mirrors the existing `components/compare/` layout) +- `schoolSections.module.css` — merged stylesheet (Task 5) +- `sectionShared.tsx` — `Section`, `MetricGrid`, `MetricCard`, `StatRow` primitives, all server +- `OfstedSection.tsx`, `FinancesSection.tsx` — shared, server +- `ResultsSection.tsx`, `AdmissionsSection.tsx`, `InclusionSection.tsx`, `HistorySection.tsx`, `SchoolLifeSection.tsx`, `LocalAreaSection.tsx` — primary, server +- `GcseSection.tsx`, `SecondaryAdmissionsSection.tsx`, `SecondaryHistorySection.tsx`, `WellbeingSection.tsx` — secondary, server +- `AdmissionsViewToggle.tsx` — **the only new client component** +- `PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` — server composers +- `SchoolDetailShell.tsx` + `SchoolDetailShell.module.css` — the client shell + +**Create — `nextjs-app/__tests__/`** +- `support/schoolFixtures.ts` — four fixtures: primary, secondary, all-through, special +- `support/renderSchoolDetail.tsx` — the swappable render helper +- `components/schoolDetail.characterization.test.tsx` +- `lib/schoolSections.test.ts` + +**Modify** +- `nextjs-app/jest.setup.js` — `IntersectionObserver`, `window.scrollTo` +- `nextjs-app/app/school/[slug]/page.tsx` — fetch national averages, compose sections +- `nextjs-app/package.json` — drop `swr` +- `e2e/tests/journeys.spec.ts` — detail-page assertions + +**Deliberately untouched** +- `components/Navigation.tsx` — genuinely needs client (`usePathname` plus the comparison count). No task. +- `components/Footer.tsx` — already a server component; no `'use client'` directive. No task. +- `app/layout.tsx` — unchanged. The shared ~172 KB baseline is out of scope; see Task 9 Step 4. + +**Delete** +- `nextjs-app/components/SchoolDetailView.tsx` + `.module.css` +- `nextjs-app/components/SecondarySchoolDetailView.tsx` + `.module.css` +- `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts` + +--- + +### Task 1: Test environment and fixtures + +**Files:** +- Modify: `nextjs-app/jest.setup.js` +- Create: `nextjs-app/__tests__/support/schoolFixtures.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: `primaryFixture`, `secondaryFixture`, `allThroughFixture`, `specialFixture` — each an object matching the prop shape of `SchoolDetailView` (`{ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance }`), plus `nationalAveragesFixture` typed `NationalAverages`. + +- [ ] **Step 1: Add the missing jsdom globals** + +jsdom implements neither API; both detail views use `IntersectionObserver` twice and `window.scrollTo` once. Append to `nextjs-app/jest.setup.js`: + +```javascript +// jsdom implements neither of these; the detail views use both. +global.IntersectionObserver = class { + constructor(callback) { this.callback = callback; } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { return []; } +}; + +window.scrollTo = jest.fn(); +``` + +- [ ] **Step 2: Build the fixtures** + +Create `nextjs-app/__tests__/support/schoolFixtures.ts`. Derive the exact field names from `nextjs-app/lib/types.ts` (`School`, `SchoolResult`, `OfstedInspection`, `SchoolCensus`, `SchoolAdmissions`, `SchoolDeprivation`, `SchoolFinance`, `NationalAverages`) — do not invent fields; TypeScript will reject them. + +The four fixtures must differ in exactly these ways, because these are the branches the characterization tests exercise: + +| Fixture | `schoolInfo.phase` | Results present | Ofsted | Admissions history | Notes | +| --- | --- | --- | --- | --- | --- | +| `primaryFixture` | `'Primary'` | `rwm_expected_pct: 58` | legacy OEIF, `overall_effectiveness: 2` | 3 years with `first_preference_offer_pct` | drives the trend toggle | +| `secondaryFixture` | `'Secondary'` | `attainment_8_score: 46.2`, `progress_8_score: 0.31` | Report Card (`rc_*` fields set) | 1 year only | no trend toggle | +| `allThroughFixture` | `'All-through'` | **both** `rwm_expected_pct` and `attainment_8_score` | legacy OEIF | 2 years | KS2 content gated back on | +| `specialFixture` | `'Primary'`, `school_type: 'Community special school'` | `rwm_expected_pct: 0`, reading/writing/maths all `0` | none (`null`) | none (`null`) | England comparison suppressed | + +Every fixture needs `urn` in the valid 6-digit range, a `school_name`, and a `local_authority`. Give `primaryFixture` at least 3 entries in `yearlyData` so the history table and chart have data. + +- [ ] **Step 3: Verify the fixtures typecheck** + +Run: `cd nextjs-app && npm run typecheck` +Expected: PASS. Any error here means a fixture field does not exist on the real type — fix the fixture, never the type. + +- [ ] **Step 4: Commit** + +```bash +git add nextjs-app/jest.setup.js nextjs-app/__tests__/support/schoolFixtures.ts +git commit -m "$(cat <<'EOF' +test: add jsdom globals and school detail fixtures + +jsdom provides neither IntersectionObserver nor scrollTo; both detail views +use them. Adds four fixtures (primary, secondary, all-through, special) +covering the branches the characterization tests will pin. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 2: Characterization tests + +This is the safety net. **It must be green before any production code moves.** + +**Files:** +- Create: `nextjs-app/__tests__/support/renderSchoolDetail.tsx` +- Create: `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx` + +**Interfaces:** +- Consumes: fixtures from Task 1. +- Produces: `renderSchoolDetail(fixture, opts?)` and `renderSecondarySchoolDetail(fixture, opts?)`. These are the **only** functions the tests call to render. Task 7 rewrites their bodies; their signatures and every assertion stay frozen. + +- [ ] **Step 1: Write the render helper against the CURRENT components** + +Create `nextjs-app/__tests__/support/renderSchoolDetail.tsx`: + +```tsx +/** + * The single seam between the characterization tests and the component tree. + * + * Task 7 rewrites the bodies of these two functions to render the new + * shell + server-sections composition. Nothing else in the test suite may + * change — assertions passing unmodified across that rewrite is the proof + * that behaviour was preserved. + */ +import { render } from '@testing-library/react'; +import { SchoolDetailView } from '@/components/SchoolDetailView'; +import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView'; + +export function renderSchoolDetail(fixture: any) { + return render(); +} + +export function renderSecondarySchoolDetail(fixture: any) { + return render(); +} +``` + +- [ ] **Step 2: Write the characterization tests** + +Create `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`. Mock the charts and analytics at the top — Chart.js needs a canvas jsdom does not provide, and `track()` fires on mount: + +```tsx +jest.mock('@/lib/analytics', () => ({ + track: jest.fn(), + getNavigationSource: () => 'direct', +})); +jest.mock('@/components/PerformanceChart', () => ({ + PerformanceChart: () =>
, +})); +jest.mock('@/components/SatsChart', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/components/AdmissionsTrendChart', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/components/SchoolHeroMap', () => ({ + SchoolHeroMap: () =>
, +})); +``` + +Then cover, using `renderSchoolDetail` / `renderSecondarySchoolDetail` only: + +1. **Ofsted, legacy OEIF** (`primaryFixture`) — the grade label `Good` renders; the inspected date renders; the `reports.ofsted.gov.uk` link carries the fixture's URN in its `href`. +2. **Ofsted, Report Card** (`secondaryFixture`) — heading reads `Ofsted Report Card`; the November 2025 disclaimer renders; each populated `rc_*` category renders its label and band. +3. **KS2 results + England delta** (`primaryFixture`) — the RWM percentage renders; a delta versus the England average renders. +4. **KS4 results** (`secondaryFixture`) — Attainment 8 and Progress 8 values render; EBacc rows render. +5. **All-through** (`allThroughFixture`) — **both** a KS2 figure and an Attainment 8 figure render in the same document. This is the highest-value assertion in the file; it is the case most likely to break. +6. **Special school** (`specialFixture`) — no England-comparison delta renders, and the special-school note renders. Guards the PR #70 regression. +7. **Admissions toggle** (`primaryFixture`) — both buttons render; the trend view starts `hidden`; after `userEvent.click` on the trend button the trend view is visible and the year view is `hidden`. Assert on the `hidden` attribute, matching how the current code toggles. +8. **Admissions without trend** (`secondaryFixture`, 1 year) — no toggle buttons render. +9. **Nav items** (`primaryFixture`) — the expected section ids are present in the document; and for `specialFixture` (no ofsted, no admissions) those two are absent. + +Use `getByText` / `queryByText` / `getByRole` — never snapshots, and never assert on CSS-module class names, which are hashed and will change. + +- [ ] **Step 3: Run the tests and confirm they PASS** + +Run: `cd nextjs-app && npm test -- schoolDetail.characterization` +Expected: **PASS.** This is the inverse of normal TDD — these tests describe behaviour that already exists, so green means they correctly pin the current behaviour. If one fails, the test is wrong, not the component. Fix the test. + +- [ ] **Step 4: Commit** + +```bash +git add nextjs-app/__tests__/ +git commit -m "$(cat <<'EOF' +test: pin school detail behaviour before refactor + +Characterization tests covering Ofsted (both layouts), KS2/KS4 results, +all-through dual rendering, special-school comparison suppression, and the +admissions toggle. All rendering goes through renderSchoolDetail(), the one +seam that the server/client split is allowed to change. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 3: Move national averages to the server + +The prerequisite for everything after it: `primaryAvg` / `secondaryAvg` feed the England deltas across most sections, and while they arrive via `useEffect` those sections cannot be server components. + +**Files:** +- Modify: `nextjs-app/app/school/[slug]/page.tsx` +- Modify: `nextjs-app/components/SchoolDetailView.tsx:173-179` +- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:91-93` + +**Interfaces:** +- Consumes: `fetchNationalAverages()` from `@/lib/api` (already exists, `revalidate: 3600`). +- Produces: both view components gain a required prop `nationalAvg: NationalAverages | null`. + +- [ ] **Step 1: Fetch it in parallel in the page** + +In `app/school/[slug]/page.tsx`, replace the lone `fetchSchoolDetails` call in the default export with a parallel pair. Import `fetchNationalAverages` alongside the existing imports. National averages must never break the page, so it degrades to `null`: + +```tsx +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(); +} +``` + +Leave `generateMetadata`'s own `fetchSchoolDetails(urn)` call alone — Next.js memoises identical `fetch` calls within a render pass, so it does not cost a second request. + +- [ ] **Step 2: Pass it to both views** + +Add `nationalAvg={nationalAvg}` to both the `` and `` elements at the bottom of the page. + +- [ ] **Step 3: Accept the prop and delete the useEffect** + +In `components/SchoolDetailView.tsx`: add `nationalAvg: NationalAverages | null;` to `SchoolDetailViewProps`, add `nationalAvg` to the destructured parameters, and delete lines 172-179 — the `useState` and the `useEffect` that fetches `/api/national-averages`. The two lines that follow it stay exactly as they are: + +```tsx +const primaryAvg = nationalAvg?.primary ?? {}; +const secondaryAvg = nationalAvg?.secondary ?? {}; +``` + +Apply the identical change to `components/SecondarySchoolDetailView.tsx` at lines 91-93. + +- [ ] **Step 4: Update the render helper to supply the prop** + +In `__tests__/support/renderSchoolDetail.tsx`, spread `nationalAveragesFixture` in: + +```tsx +export function renderSchoolDetail(fixture: any) { + return render(); +} +``` + +This is a permitted change — the helper is the designated seam. The test file itself must not change. + +- [ ] **Step 5: Verify** + +Run: `cd nextjs-app && npm run typecheck && npm test -- schoolDetail.characterization` +Expected: both PASS, with the characterization test file unmodified. + +- [ ] **Step 6: Commit** + +```bash +git add nextjs-app/app/school nextjs-app/components/SchoolDetailView.tsx \ + nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/__tests__/support/ +git commit -m "$(cat <<'EOF' +refactor(detail): fetch national averages on the server + +Both detail views fetched /api/national-averages in a useEffect, so the +England-comparison deltas popped in after hydration and the sections that +use them could not become server components. The page now fetches it in +parallel with the school details (backend-cached 1h) and passes it down. + +Removes one client round-trip per detail page. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 4: Extract the derived-flags module + +Pulls the pure data-shape logic out of the components so `page.tsx` can compute `navItems` on the server without importing a client component. + +**Files:** +- Create: `nextjs-app/lib/schoolSections.ts` +- Create: `nextjs-app/__tests__/lib/schoolSections.test.ts` + +**Interfaces:** +- Consumes: types from `@/lib/types`; `isSpecialSchool` from `@/lib/utils`. +- Produces: + - `type SchoolFlags = { isAllThrough: boolean; isSecondary: boolean; isPrimary: boolean; latestResults: SchoolResult | null; hasGenderSplit: boolean; hasInclusionData: boolean; hasSchoolLife: boolean; hasDeprivation: boolean; hasFinance: boolean; hasLocation: boolean; hasKS2Results: boolean; hasKS4Results: boolean; hasAnyResults: boolean; isSpecial: boolean; ks2Placeholder: boolean; suppressKs2Comparison: boolean; suppressKs4Comparison: boolean }` + - `computeSchoolFlags(input): SchoolFlags` where `input` is `{ schoolInfo, yearlyData, absenceData, census, deprivation, finance }` + - `type NavItem = { id: string; label: string }` + - `buildNavItems(flags: SchoolFlags, opts: { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; yearlyDataLength: number }): NavItem[]` + +- [ ] **Step 1: Write the failing tests** + +Create `nextjs-app/__tests__/lib/schoolSections.test.ts` covering the branches that matter: + +```ts +import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections'; +import { primaryFixture, allThroughFixture, specialFixture } from '../support/schoolFixtures'; + +describe('computeSchoolFlags', () => { + it('treats an all-through school as both primary-capable and secondary', () => { + const f = computeSchoolFlags(allThroughFixture); + expect(f.isAllThrough).toBe(true); + expect(f.isSecondary).toBe(true); + expect(f.hasKS2Results).toBe(true); + expect(f.hasKS4Results).toBe(true); + }); + + it('suppresses the KS2 comparison for a special school', () => { + const f = computeSchoolFlags(specialFixture); + expect(f.isSpecial).toBe(true); + expect(f.suppressKs2Comparison).toBe(true); + }); + + it('treats an all-zero KS2 row as a placeholder', () => { + const f = computeSchoolFlags(specialFixture); + expect(f.ks2Placeholder).toBe(true); + }); + + it('does not suppress comparison for an ordinary primary', () => { + const f = computeSchoolFlags(primaryFixture); + expect(f.suppressKs2Comparison).toBe(false); + }); +}); + +describe('buildNavItems', () => { + it('omits sections with no data', () => { + const flags = computeSchoolFlags(specialFixture); + const ids = buildNavItems(flags, { + ofsted: null, admissions: null, yearlyDataLength: 1, + }).map((n) => n.id); + expect(ids).not.toContain('ofsted'); + expect(ids).not.toContain('admissions'); + }); + + it('labels the results section by phase', () => { + const primary = computeSchoolFlags(primaryFixture); + const items = buildNavItems(primary, { + ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions, + yearlyDataLength: primaryFixture.yearlyData.length, + }); + expect(items.find((n) => n.id === 'results')?.label).toBe('SATs'); + }); +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd nextjs-app && npm test -- schoolSections` +Expected: FAIL — `Cannot find module '@/lib/schoolSections'`. + +- [ ] **Step 3: Implement the module** + +Create `nextjs-app/lib/schoolSections.ts` by moving the logic verbatim from `components/SchoolDetailView.tsx` lines **211-267**. Lines 205-209 are the `deprivationDesc` helper — leave it where it is; Task 6 moves it into `LocalAreaSection`. Also bring `latestResults` (line 160). Preserve the comments — they explain the special-school and all-through reasoning and are load-bearing for future readers. The `navItems` push order must stay exactly as it is at lines 258-267; it is engagement-ordered from analytics. + +- [ ] **Step 4: Run to verify they pass** + +Run: `cd nextjs-app && npm test -- schoolSections` +Expected: PASS. + +- [ ] **Step 5: Consume it from both views** + +Replace the inline flag computations in `SchoolDetailView.tsx` and `SecondarySchoolDetailView.tsx` with a call to `computeSchoolFlags` / `buildNavItems`, destructuring the flags so the JSX below is untouched. + +- [ ] **Step 6: Verify nothing regressed** + +Run: `cd nextjs-app && npm run typecheck && npm test` +Expected: all PASS, characterization tests unmodified. + +- [ ] **Step 7: Commit** + +```bash +git add nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.test.ts \ + nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.tsx +git commit -m "$(cat <<'EOF' +refactor(detail): extract derived flags and nav items to lib + +Pure data-shape logic moves out of the client components so page.tsx can +compute the section list on the server. Adds direct unit coverage for the +all-through and special-school branches, which were previously only +reachable through a full component render. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 5: Merge the stylesheets + +Isolated so that a visual regression bisects to the CSS merge rather than to a JSX move. Highest-risk task in the plan; no test catches a mistake here. + +**Files:** +- Create: `nextjs-app/components/school/schoolSections.module.css` + +**Interfaces:** +- Consumes: nothing. +- Produces: a stylesheet exporting every class name used by section markup in either view, plus `genderBarSecondary` and `heroStatValueSecondary` variant classes. + +- [ ] **Step 1: Copy the primary stylesheet as the base** + +```bash +mkdir -p nextjs-app/components/school +cp nextjs-app/components/SchoolDetailView.module.css \ + nextjs-app/components/school/schoolSections.module.css +``` + +- [ ] **Step 2: Add the secondary-only classes** + +33 class names exist only in `SecondarySchoolDetailView.module.css`. Append their rules unchanged. Confirm the list: + +```bash +cd nextjs-app && python3 - <<'PY' +import re +c=lambda p: set(re.findall(r'\.([a-zA-Z][\w-]*)', open(p).read())) +a=c('components/SchoolDetailView.module.css'); b=c('components/SecondarySchoolDetailView.module.css') +print('\n'.join(sorted(b-a))) +PY +``` + +- [ ] **Step 3: Union the 14 incidental-drift differences** + +For these classes, add any property present in the secondary rule but missing from the primary rule. All are defensive overflow properties — `min-width: 0`, `overflow-wrap: break-word`, `word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. Never remove a property the primary rule already has. + +Affected: `.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, `.metricsGrid`, `.tableWrapper`, `.heroStatCard`, `.heroStatLabel`, `.historyToggle`, `.ofstedReportLink`, `.genderSplitBoys`, `.genderSplitGirls`, `.genderSplitSep`, and the remaining drift class from the audit in Step 5. + +- [ ] **Step 4: Add variant classes for the two real differences** + +Keep the primary look on the base class and add the secondary look as a separate class: + +```css +/* Secondary detail pages render a slimmer gender bar on a translucent track. + Kept as an explicit variant rather than reconciled, so the divergence is + deliberate instead of accidental drift. */ +.genderBarSecondary { + height: 4px; + background: rgba(0, 0, 0, 0.08); + margin-top: 0.45rem; +} + +/* Secondary hero stats sit tighter and omit the Georgia fallback. */ +.heroStatValueSecondary { + gap: 0.4rem; + font-family: var(--font-playfair), "Playfair Display", serif; +} +``` + +- [ ] **Step 5: Audit the merge** + +Every shared class must now be a superset of both originals, except the two variants: + +```bash +cd nextjs-app && python3 - <<'PY' +import re +def rules(p): + out={} + for m in re.finditer(r'\.([a-zA-Z][\w-]*)\s*\{([^{}]*)\}', open(p).read()): + out.setdefault(m.group(1),[]).append({x.strip() for x in + re.sub(r'\s+',' ',m.group(2)).strip().rstrip(';').split(';') if x.strip()}) + return out +m=rules('components/school/schoolSections.module.css') +for src in ['components/SchoolDetailView.module.css','components/SecondarySchoolDetailView.module.css']: + for name, bodies in rules(src).items(): + if name in ('genderBar','heroStatValue'): continue + if name not in m: print(f"MISSING .{name} (from {src})"); continue + missing = bodies[0] - m[name][0] + if missing: print(f"LOST .{name}: {missing} (from {src})") +print("audit complete") +PY +``` +Expected: no `MISSING` or `LOST` lines before `audit complete`. + +- [ ] **Step 6: Commit** + +```bash +git add nextjs-app/components/school/schoolSections.module.css +git commit -m "$(cat <<'EOF' +style(detail): merge the two detail stylesheets + +Unions the 14 incidental-drift differences between the primary and secondary +modules -- defensive overflow properties (min-width, overflow-wrap, +max-width) that were fixed on one page and never back-ported. Keeps the two +genuine visual differences, .genderBar and .heroStatValue, as explicit +variant classes. + +No intended visual change. Isolated so a regression bisects here rather than +to a JSX move. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 6: Extract the server sections + +The bulk of the work, and a **pure move**. Every section below becomes a server component: no `'use client'`, no hooks, no event handlers. + +**Files:** +- Create: `nextjs-app/components/school/sectionShared.tsx` +- Create: the 12 section files listed in File Structure +- Create: `nextjs-app/components/school/AdmissionsViewToggle.tsx` + +**Interfaces:** +- Consumes: `SchoolFlags` and `NavItem` from `@/lib/schoolSections`; `schoolSections.module.css`. +- Produces: each section is a named export taking exactly the props its JSX reads. `AdmissionsViewToggle` is the only `'use client'` file created here, with props `{ title: ReactNode; subtitle: ReactNode; trendLabel: string; yearView: ReactNode; trendView: ReactNode }`. + +- [ ] **Step 1: Extract the shared primitives** + +Create `sectionShared.tsx` mirroring `components/compare/sectionShared.tsx`. Export server components `Section` (renders `
`), `SectionTitle`, `MetricGrid`, `MetricCard`. Re-export the stylesheet as `sectionStyles`, matching the compare module's convention. + +- [ ] **Step 2: Move the shared sections** + +These are the only two sections where you are merging two implementations rather than moving one, so the reconciliation rule matters. **Start from the primary version and add only what the secondary version has that the primary lacks.** Diff them first so you are working from the actual delta rather than reading both in full: + +```bash +cd nextjs-app && diff <(sed -n '598,687p' components/SchoolDetailView.tsx | sed 's/^[ \t]*//') \ + <(sed -n '395,502p' components/SecondarySchoolDetailView.tsx | sed 's/^[ \t]*//') +``` + +For every line the secondary has and the primary does not, decide one of two things and write the decision as a code comment: either it is **phase-specific** (gate it on a prop — e.g. `showSixthForm`), or it is **an improvement the primary never received** (keep it unconditionally). Never delete a branch the primary version has. If a difference is neither — a genuine behavioural conflict — stop and report it rather than guessing. + +- `OfstedSection.tsx` — primary `SchoolDetailView.tsx:598-687`, secondary `SecondarySchoolDetailView.tsx:395-502` (80% similar). Props: `{ ofsted, urn, isReportCard, ofstedInspectedDate, oeifAllSameGrade, oeifAreas }`. Note `RC_CATEGORIES` (lines 44-53) includes `rc_sixth_form`, which only ever populates for secondary — it is already null-guarded, so it needs no prop. +- `FinancesSection.tsx` — primary `SchoolDetailView.tsx:1314-1344`, secondary `SecondarySchoolDetailView.tsx:937-973` (91% similar). Same rule; run the equivalent diff. + +- [ ] **Step 3: Move the primary-only sections** + +Verbatim moves, changing only the `styles` import to `schoolSections.module.css` and replacing closed-over variables with props: + +| Component | Source | Props | +| --- | --- | --- | +| `ResultsSection.tsx` | `SchoolDetailView.tsx:688-959` | `latestResults, flags, primaryAvg, secondaryAvg, schoolInfo` | +| `AdmissionsSection.tsx` | `SchoolDetailView.tsx:960-1051` | `admissions, admissionsHistory, admissionsSummary, isAllThrough` | +| `InclusionSection.tsx` | `SchoolDetailView.tsx:1052-1135` | `latestResults, census, hasGenderSplit` | +| `HistorySection.tsx` | `SchoolDetailView.tsx:1136-1256` | `yearlyData, flags` | +| `SchoolLifeSection.tsx` | `SchoolDetailView.tsx:1257-1289` | `absenceData` | +| `LocalAreaSection.tsx` | `SchoolDetailView.tsx:1290-1313` | `deprivation` — bring `deprivationDesc` (lines 205-209) with it | + +- [ ] **Step 4: Move the secondary-only sections** + +| Component | Source | Props | +| --- | --- | --- | +| `GcseSection.tsx` | `SecondarySchoolDetailView.tsx:503-732` | `latestResults, flags, secondaryAvg, schoolInfo` | +| `SecondaryAdmissionsSection.tsx` | `SecondarySchoolDetailView.tsx:733-792` | `admissions` | +| `SecondaryHistorySection.tsx` | `SecondarySchoolDetailView.tsx:793-839` | `yearlyData, flags` | +| `WellbeingSection.tsx` | `SecondarySchoolDetailView.tsx:840-936` | `absenceData, census` — pass `variant="secondary"` to select `genderBarSecondary` | + +- [ ] **Step 5: Build the admissions toggle island** + +The current code renders both views always and toggles the `hidden` attribute, so the island only owns `hidden` and `aria-pressed`. The buttons sit inside `.admissionsHeader` beside the `

` while the viewport is a sibling below, so the component must span both to preserve the DOM exactly. + +Create `nextjs-app/components/school/AdmissionsViewToggle.tsx`: + +```tsx +'use client'; + +import { useState, type ReactNode } from 'react'; +import styles from './schoolSections.module.css'; + +/** + * The only interactive part of the admissions section. Both views are always + * in the DOM (matching the previous behaviour) and this toggles `hidden`, so + * the server-rendered markup passed in as yearView/trendView never ships as + * client JavaScript. + */ +export function AdmissionsViewToggle({ + title, subtitle, trendLabel, yearView, trendView, +}: { + title: ReactNode; + subtitle: ReactNode; + trendLabel: string; + yearView: ReactNode; + trendView: ReactNode; +}) { + const [view, setView] = useState<'year' | 'trend'>('year'); + + return ( + <> +
+

{title}

+
+ + +
+
+ {subtitle} +
+ + +
+ + ); +} +``` + +In `AdmissionsSection.tsx`, when `showAdmissionsTrend` is false, render the header and year view directly with **no** client component — those schools then ship zero admissions JavaScript. + +- [ ] **Step 6: Verify it compiles** + +Run: `cd nextjs-app && npm run typecheck` +Expected: PASS. The old views still exist and still render; nothing is wired up yet. + +- [ ] **Step 7: Commit** + +```bash +git add nextjs-app/components/school/ +git commit -m "$(cat <<'EOF' +refactor(detail): extract sections as server components + +Moves the section markup out of the two client views into +components/school/, mirroring the components/compare/ layout. Sections are +server components; the only new client file is AdmissionsViewToggle, which +owns the hidden/aria-pressed state and receives both views as server-rendered +children. + +Ofsted and Finances are shared between phases (80% and 91% similar); +Admissions and History are not (14% and 40%) and stay separate. + +Pure move -- no logic or markup changes. Not yet wired up. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 7: Add the shell and wire it up + +Where the bytes actually move. + +**Files:** +- Create: `nextjs-app/components/school/SchoolDetailShell.tsx` + `.module.css` +- Create: `nextjs-app/components/school/PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` +- Modify: `nextjs-app/app/school/[slug]/page.tsx` +- Modify: `nextjs-app/__tests__/support/renderSchoolDetail.tsx` +- Delete: `SchoolDetailView.tsx` + `.module.css`, `SecondarySchoolDetailView.tsx` + `.module.css` + +**Interfaces:** +- Consumes: everything from Tasks 4-6. +- Produces: `SchoolDetailShell` — `'use client'`, props `{ schoolInfo: School; navItems: NavItem[]; flags: SchoolFlags; children: ReactNode }`. + +- [ ] **Step 1: Build the shell** + +Create `SchoolDetailShell.tsx` from `SchoolDetailView.tsx` lines 1-595 — the back link, header, details reveal, hero map, compare CTA, sticky nav, jump sheet — plus the hooks at lines 79-104 and the effects at 120-203 and 270-300. Keep `'use client'`. It renders `{children}` where the sections used to begin (after the nav closes, at what is currently line 595). + +`navItems` becomes a prop instead of being computed inline. The scroll-spy at line 283 uses `document.getElementById(id)` and needs no change — it finds server-rendered sections exactly as before. + +For the shell's stylesheet, do not eyeball which rules are shell-only — determine it mechanically. Every class the shell's JSX references goes into `SchoolDetailShell.module.css`; classes referenced by both the shell and a section stay duplicated in both files (CSS Modules hash them independently, so duplication is correct, not a smell). List the shell's classes with: + +```bash +cd nextjs-app && grep -o 'styles\.[a-zA-Z][A-Za-z0-9_]*' components/school/SchoolDetailShell.tsx \ + | sort -u | sed 's/styles\.//' +``` + +Copy each of those rules — including every media-query and `:hover`/`:focus` block that mentions them — from `SchoolDetailView.module.css` before deleting it in Step 6. + +- [ ] **Step 2: Build the two composers** + +Server components — no `'use client'`. Each returns the section sequence in the current order, gated by the same flags: + +```tsx +export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) { + const { ofsted, admissions, /* … */ } = data; + return ( + <> + {ofsted && } + {flags.hasAnyResults && flags.latestResults && } + {admissions && } + {flags.hasInclusionData && } + {data.yearlyData.length > 0 && } + {flags.hasSchoolLife && } + {flags.hasDeprivation && } + {flags.hasFinance && } + + ); +} +``` + +The conditions must match `buildNavItems` exactly, or the nav will link to sections that do not exist. + +`SecondarySchoolSections` follows the same shape with `GcseSection`, `SecondaryAdmissionsSection`, `SecondaryHistorySection`, `WellbeingSection`, `FinancesSection`. + +- [ ] **Step 3: Rewire the page** + +In `app/school/[slug]/page.tsx`, replace the `isSecondary ? : ` block. Compute flags and nav items on the server, then wrap: + +```tsx +const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance }); +const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length }); + +return ( + <> +