Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5 97dd69a512 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 <noreply@anthropic.com>
2026-08-01 20:47:21 +01:00
TudorandClaude Opus 5 d1c92ea7d5 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 <noreply@anthropic.com>
2026-07-30 15:35:54 +01:00
tudor cab7b4fe91 Merge pull request 'feat(detail): move religious character into header details + mobile reveal' (#83) from feat/detail-header-details-reveal into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 47s
Reviewed-on: #83
2026-07-24 12:15:14 +00:00
@@ -0,0 +1,275 @@
# 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 `<MetricTooltip>`
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
└── <SchoolDetailShell schoolInfo 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: <PrimarySchoolSections/> | <SecondarySchoolSections/> 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.
### 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`,
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(<SchoolDetailView {...props} />)
after: render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)
```
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 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 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
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 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
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.