Files
school_compare/docs/superpowers/plans/2026-08-01-server-client-split.md
T
TudorandClaude Opus 5 7199c3a90b 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 <noreply@anthropic.com>
2026-08-01 20:52:56 +01:00

43 KiB

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 <noreply@anthropic.com>.

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% SharedOfstedSection
Finances 29 35 91% SharedFinancesSection
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.tsxSection, 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.tsxthe 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.jsIntersectionObserver, 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:

// 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
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 <noreply@anthropic.com>
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:

/**
 * 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(<SchoolDetailView {...fixture} />);
}

export function renderSecondarySchoolDetail(fixture: any) {
  return render(<SecondarySchoolDetailView {...fixture} />);
}
  • 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:

jest.mock('@/lib/analytics', () => ({
  track: jest.fn(),
  getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
  PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/SatsChart', () => ({
  __esModule: true,
  default: () => <div data-testid="sats-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
  __esModule: true,
  default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
  SchoolHeroMap: () => <div data-testid="hero-map" />,
}));

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
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 <noreply@anthropic.com>
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:

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 <SecondarySchoolDetailView> and <SchoolDetailView> 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:

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:

export function renderSchoolDetail(fixture: any) {
  return render(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />);
}

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
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 <noreply@anthropic.com>
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:

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
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 <noreply@anthropic.com>
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

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:

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:

/* 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:

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
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 <noreply@anthropic.com>
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 <section id className={styles.card}>), 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:

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 <h2> 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:

'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 (
    <>
      <div className={styles.admissionsHeader}>
        <h2 className={styles.sectionTitle}>{title}</h2>
        <div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
          <button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
            This year
          </button>
          <button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
            {trendLabel}
          </button>
        </div>
      </div>
      {subtitle}
      <div className={styles.admissionsViewport}>
        <div className={styles.admissionsViewYear} hidden={view !== 'year'}>{yearView}</div>
        <div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>{trendView}</div>
      </div>
    </>
  );
}

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
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 <noreply@anthropic.com>
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:

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:

export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) {
  const { ofsted, admissions, /* … */ } = data;
  return (
    <>
      {ofsted && <OfstedSection ofsted={ofsted} urn={data.schoolInfo.urn} /* … */ />}
      {flags.hasAnyResults && flags.latestResults && <ResultsSection /* … */ />}
      {admissions && <AdmissionsSection /* … */ />}
      {flags.hasInclusionData && <InclusionSection /* … */ />}
      {data.yearlyData.length > 0 && <HistorySection /* … */ />}
      {flags.hasSchoolLife && <SchoolLifeSection /* … */ />}
      {flags.hasDeprivation && <LocalAreaSection /* … */ />}
      {flags.hasFinance && <FinancesSection /* … */ />}
    </>
  );
}

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 ? <SecondarySchoolDetailView/> : <SchoolDetailView/> block. Compute flags and nav items on the server, then wrap:

const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance });
const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length });

return (
  <>
    <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
    <SchoolDetailShell schoolInfo={school_info} navItems={navItems} flags={flags}>
      {isSecondary
        ? <SecondarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />
        : <PrimarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />}
    </SchoolDetailShell>
  </>
);

Keep the existing isSecondary / isAllThrough derivation exactly as it is — all-through schools must still route to the primary composer, which renders both KS2 and KS4 content.

  • Step 4: Swap the render helper — the moment of truth

Rewrite only __tests__/support/renderSchoolDetail.tsx:

export function renderSchoolDetail(fixture: any) {
  const flags = computeSchoolFlags(fixture);
  const navItems = buildNavItems(flags, {
    ofsted: fixture.ofsted, admissions: fixture.admissions,
    yearlyDataLength: fixture.yearlyData.length,
  });
  return render(
    <SchoolDetailShell schoolInfo={fixture.schoolInfo} navItems={navItems} flags={flags}>
      <PrimarySchoolSections data={fixture} flags={flags} nationalAvg={nationalAveragesFixture} />
    </SchoolDetailShell>,
  );
}
  • Step 5: Run the characterization tests

Run: cd nextjs-app && npm test -- schoolDetail.characterization Expected: PASS, with schoolDetail.characterization.test.tsx byte-identical to Task 2.

This is the proof the refactor preserved behaviour. If a test fails, the refactor is wrong — fix the components, never the test. Confirm the file is untouched:

git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
  • Step 6: Delete the old views
git rm nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SchoolDetailView.module.css \
       nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.module.css

Confirm nothing still imports them:

cd nextjs-app && grep -rn "SchoolDetailView\|SecondarySchoolDetailView" app components lib __tests__ | grep -v node_modules

Expected: no output.

  • Step 7: Full verification

Run: cd nextjs-app && npm run typecheck && npm test && npm run build Expected: all three PASS.

  • Step 8: Commit
git add -A nextjs-app/
git commit -m "$(cat <<'EOF'
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 ~750 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.

Characterization tests from the earlier commit pass unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 8: Delete the unused SWR hooks

Files:

  • Delete: nextjs-app/hooks/useSchools.ts, useFilters.ts, useMetrics.ts, useSchoolDetails.ts

  • Modify: nextjs-app/package.json

  • Step 1: Confirm nothing imports them

cd nextjs-app && grep -rn "useSchools\|useFilters\|useMetrics\|useSchoolDetails" app components lib __tests__ | grep -v node_modules

Expected: no output. If anything appears, stop and report it — do not delete.

  • Step 2: Delete and drop the dependency
git rm nextjs-app/hooks/useSchools.ts nextjs-app/hooks/useFilters.ts \
       nextjs-app/hooks/useMetrics.ts nextjs-app/hooks/useSchoolDetails.ts
cd nextjs-app && npm uninstall swr
  • Step 3: Verify

Run: cd nextjs-app && npm run typecheck && npm test && npm run build Expected: all PASS.

  • Step 4: Commit
git add -A nextjs-app/
git commit -m "$(cat <<'EOF'
chore: drop unused SWR hooks and dependency

useSchools, useFilters, useMetrics and useSchoolDetails were imported by
nothing and were the only consumers of swr. All data fetching goes through
lib/api.ts on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 9: E2E coverage and measurement

CLAUDE.md requires e2e updates in the same PR as user-facing changes; these journeys gate whether staging is fit for human testing.

Files:

  • Modify: e2e/tests/journeys.spec.ts

  • Step 1: Read the existing journeys

Run: cat e2e/tests/journeys.spec.ts and match its existing helpers, selectors and naming. Note the staging-data constraint: year filters must use the latest year, not the oldest — staging carries only partial history.

  • Step 2: Add detail-page assertions

Add a journey that visits a school detail page and asserts: the Ofsted section renders; the results section renders a figure; the sticky section nav renders and a jump link scrolls to its section; and — the regression this whole refactor risks — the admissions year/trend toggle switches views. Assert on user-visible text and roles, never CSS-module class names.

  • Step 3: Verify the spec parses

Run: cd e2e && npx playwright test --list Expected: the new tests appear. Do not run them locally — they target staging, which is deployed from main.

  • Step 4: Measure the result
cd nextjs-app && rm -rf .next && npx next build >/dev/null 2>&1 && python3 - <<'PY'
import re,os,gzip,glob
for html in sorted(glob.glob('.next/server/app/**/*.html', recursive=True)):
    src=set(re.findall(r'/_next/(static/[^"\']+\.js)', open(html).read()))
    tot=sum(len(gzip.compress(open(os.path.join('.next',s),'rb').read()))
            for s in src if os.path.exists(os.path.join('.next',s)))
    print(f"{tot/1024:8.1f} KB gz  {len(src):3d} scripts  {html}")
PY

Record the figures for the PR description. Compare against the cab7b4f baseline: 172 KB gz shared baseline, /admissions 177 KB gz. Report the route-specific figure and the shared baseline separately — this change does not target the baseline, and an unchanged baseline is not a failure.

  • Step 5: Commit and open the PR
git add e2e/tests/journeys.spec.ts
git commit -m "$(cat <<'EOF'
test(e2e): cover school detail sections and admissions toggle

The server/client split moves the detail sections out of the client bundle;
these journeys gate that the rendered page still behaves the same on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
git push -u origin perf/server-client-split

Open the PR against main per the Gitea flow in docs/DEPLOY.md. The description must include: the before/after byte table from Step 4, the note that the shared baseline is deliberately unchanged, the two CSS variants introduced in Task 5, and any bugs found-but-not-fixed during the pure-move tasks.

Do not trigger the production promotion workflow — that is the human's call.


Verification Checklist

Before claiming the work complete, confirm each of these with actual command output:

  • cd nextjs-app && npm run typecheck — passes
  • cd nextjs-app && npm test — passes
  • cd nextjs-app && npm run build — passes
  • git diff cab7b4f -- nextjs-app/__tests__/components/schoolDetail.characterization.test.tsxempty after Task 2's commit
  • grep -rn "SchoolDetailView" nextjs-app/app nextjs-app/components — no output
  • grep -rn "swr" nextjs-app/package.json — no output
  • CSS audit script from Task 5 Step 5 — no MISSING or LOST lines
  • Byte measurement recorded in the PR description