Merge pull request 'perf(detail): render school detail sections on the server' (#84) from perf/server-client-split into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m23s

Reviewed-on: #84
This commit was merged in pull request #84.
This commit is contained in:
2026-08-02 21:03:32 +00:00
40 changed files with 5742 additions and 3678 deletions
@@ -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 <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% | **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 <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`:
```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:
```tsx
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**
```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 <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`:
```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 `<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:
```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(<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**
```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 <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:
```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 <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**
```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 <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:
```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 `<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`:
```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**
```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 <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:
```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 && <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:
```tsx
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`:
```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:
```bash
git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
```
- [ ] **Step 6: Delete the old views**
```bash
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:
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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.tsx`**empty** 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
@@ -0,0 +1,295 @@
# 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
Sharing is decided by measured similarity, not assumption. Comparing the two
views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
| Section | Primary | Secondary | Similarity | Decision |
| --- | --- | --- | --- | --- |
| Finances | 29 lines | 35 | 91% | Shared |
| Ofsted | 88 | 107 | 80% | Shared |
| History | 121 | 47 | 40% | Separate |
| Admissions | 89 | 55 | 14% | Separate |
Admissions and History were assumed shareable when this design was first drafted;
measurement during planning showed they are not — primary's admissions carries
the year/trend toggle and chart, secondary's is a much simpler panel.
**This costs nothing in bytes.** The client-JS win comes from a section being a
*server* component, not from being shared between views. Sharing is a
maintainability benefit, taken only where it is cheap. Forcing the two
low-similarity sections together would inflate the diff and the regression risk
for no performance gain.
| Shared | Primary only | Secondary only |
| --- | --- | --- |
| Ofsted, Finances, `sectionShared` primitives, and the shell | Results (KS2), Admissions, History, Inclusion, School Life, Local Area | GCSEs, Admissions, History, Wellbeing |
All-through schools render both the KS2 and KS4 section sets via the primary
composer.
### 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.
+70
View File
@@ -556,3 +556,73 @@ test('compare metric-help popover stays within the mobile viewport', async ({ pa
.evaluate((el) => el.scrollWidth > el.clientWidth + 1); .evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false); expect(bodyOverflowsX).toBe(false);
}); });
/**
* The following two journeys cover the server/client split of the detail page.
* The sections are now React Server Components composed in the route and passed
* through a client shell; these assert that the two halves still meet correctly
* in a real browser, which no unit test can prove.
*/
test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => {
// Find a school with at least two years carrying an offer rate — the toggle
// only appears then. Data-invariant: uses whatever the environment holds.
const res = await page.request.get('/api/schools?search=primary&per_page=50');
expect(res.ok()).toBeTruthy();
const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn);
let target: number | null = null;
for (const urn of candidates.slice(0, 12)) {
const detail = await page.request.get(`/api/schools/${urn}`);
if (!detail.ok()) continue;
const history = (await detail.json()).admissions_history ?? [];
const withRate = history.filter(
(h: { first_preference_offer_pct?: number | null }) => h.first_preference_offer_pct != null,
);
if (withRate.length >= 2) { target = urn; break; }
}
test.skip(target === null, 'no school in this environment has 2+ years of admissions offer data');
await page.goto(`/school/${target}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
const yearBtn = page.getByRole('button', { name: 'This year' });
const trendBtn = page.getByRole('button', { name: /-year trend$/ });
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
// The toggle is the one client island inside an otherwise server-rendered
// section: clicking it must swap the two server-rendered views.
await trendBtn.click();
await expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
await expect(yearBtn).toHaveAttribute('aria-pressed', 'false');
await yearBtn.click();
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
});
test('sticky section nav jumps to server-rendered sections', async ({ page }) => {
const [urn] = await twoPrimaryUrns(page);
await page.goto(`/school/${urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// The nav is client-rendered from a server-computed list, while the sections
// themselves are server-rendered. Every link must resolve to a real section:
// the scroll-spy finds them with document.getElementById, so a mismatch
// between the two halves would dead-end here.
const navLinks = page.locator('nav a[href^="#"]');
const count = await navLinks.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const href = await navLinks.nth(i).getAttribute('href');
expect(href).toBeTruthy();
await expect(page.locator(href!)).toHaveCount(1);
}
// And following one actually moves the page.
const before = await page.evaluate(() => window.scrollY);
await navLinks.last().click();
await page.waitForTimeout(600);
const after = await page.evaluate(() => window.scrollY);
expect(after).toBeGreaterThan(before);
});
@@ -0,0 +1,177 @@
/**
* Characterization tests for the school detail pages.
*
* These describe behaviour that ALREADY EXISTS. They are written before the
* server/client split and must pass UNMODIFIED after it — that is the whole
* point. If one fails during the refactor, the refactor is wrong; fix the
* components, never these assertions.
*
* All rendering goes through renderSchoolDetail / renderSecondarySchoolDetail,
* the one file the refactor is permitted to change.
*/
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
} from '../support/schoolFixtures';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
// Chart.js needs a canvas jsdom does not provide, and Leaflet needs a real
// window. Both are already lazy client islands; their content is out of scope.
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" />,
__esModule: true,
}));
describe('Ofsted section', () => {
it('renders the legacy OEIF grade, date and report link', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText('Ofsted Rating')).toBeInTheDocument();
expect(screen.getByText(/Inspected 17 May 2023/)).toBeInTheDocument();
expect(screen.getAllByText('Good').length).toBeGreaterThan(0);
const link = screen.getByRole('link', { name: /Ofsted reports/ });
expect(link).toHaveAttribute(
'href',
expect.stringContaining(String(primaryFixture.schoolInfo.urn)),
);
});
it('shows the previous grade when it differs from the current one', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText(/Previously:/)).toBeInTheDocument();
});
it('renders the Report Card layout with its category bands', () => {
renderSecondarySchoolDetail(secondaryFixture);
expect(screen.getByText('Ofsted Report Card')).toBeInTheDocument();
expect(screen.getByText(/From November 2025, Ofsted replaced single overall grades/)).toBeInTheDocument();
expect(screen.getByText('Safeguarding')).toBeInTheDocument();
expect(screen.getByText('Met')).toBeInTheDocument();
expect(screen.getByText('Achievement')).toBeInTheDocument();
});
});
describe('KS2 results', () => {
it('renders the combined RWM figure with its England comparison', async () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText('58%')).toBeInTheDocument();
// 58 vs an England average of 61 → a -3 pts chip and the hint line.
expect(await screen.findByText('England avg: 61%')).toBeInTheDocument();
expect(await screen.findByText('-3 pts')).toBeInTheDocument();
});
});
describe('KS4 results', () => {
// These figures appear in both the hero stats and the history table, so
// assert on presence rather than uniqueness.
it('renders Attainment 8, Progress 8 and EBacc figures', () => {
renderSecondarySchoolDetail(secondaryFixture);
expect(screen.getAllByText('46.2').length).toBeGreaterThan(0);
// formatProgress rounds to 1 decimal and signs positives: 0.31 → "+0.3"
expect(screen.getAllByText(/\+0\.3/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/EBacc/i).length).toBeGreaterThan(0);
});
});
describe('all-through schools', () => {
// The highest-value assertion in this file. isAllThrough gates the KS2
// content back on for a school that also has KS4 data, so an all-through
// page must show BOTH key stages. This is the case most likely to break.
it('renders both KS2 and KS4 figures on the same page', () => {
renderSchoolDetail(allThroughFixture);
expect(screen.getAllByText('63%').length).toBeGreaterThan(0); // KS2 RWM
expect(screen.getAllByText('48.7').length).toBeGreaterThan(0); // KS4 Attainment 8
});
it('labels the KS2 block explicitly', () => {
renderSchoolDetail(allThroughFixture);
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
});
});
describe('special schools', () => {
// Guards the PR #70 regression, where a special school displayed
// "0% -62 below England" against a mainstream benchmark that does not fit.
it('suppresses the England comparison', () => {
renderSchoolDetail(specialFixture);
expect(screen.queryByText(/England avg:/)).not.toBeInTheDocument();
});
it('explains why the comparison is missing', () => {
renderSchoolDetail(specialFixture);
expect(screen.getByRole('note')).toHaveTextContent('This is a special school.');
});
});
describe('admissions', () => {
it('toggles between the year view and the multi-year trend', async () => {
const user = userEvent.setup();
const { container } = renderSchoolDetail(primaryFixture);
const yearBtn = screen.getByRole('button', { name: 'This year' });
const trendBtn = screen.getByRole('button', { name: /3-year trend/ });
expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
const yearView = container.querySelector('[class*="admissionsViewYear"]')!;
const trendView = container.querySelector('[class*="admissionsViewTrend"]')!;
expect(yearView).not.toHaveAttribute('hidden');
expect(trendView).toHaveAttribute('hidden');
await user.click(trendBtn);
expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
expect(yearView).toHaveAttribute('hidden');
expect(trendView).not.toHaveAttribute('hidden');
});
it('renders the headline admissions figures', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText('Places offered')).toBeInTheDocument();
expect(screen.getByText('Got their first choice')).toBeInTheDocument();
});
it('omits the toggle when there is only one year of offer data', () => {
renderSecondarySchoolDetail(secondaryFixture);
expect(screen.queryByRole('button', { name: 'This year' })).not.toBeInTheDocument();
});
});
describe('section navigation', () => {
it('lists the sections that have data', () => {
const { container } = renderSchoolDetail(primaryFixture);
for (const id of ['ofsted', 'results', 'admissions', 'history', 'finances']) {
expect(container.querySelector(`#${id}`)).toBeInTheDocument();
}
});
it('omits sections with no data', () => {
const { container } = renderSchoolDetail(specialFixture);
expect(container.querySelector('#ofsted')).not.toBeInTheDocument();
expect(container.querySelector('#admissions')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,144 @@
import {
computeSchoolFlags, buildNavItems,
computeSecondaryFlags, buildSecondaryNavItems,
} from '@/lib/schoolSections';
import {
primaryFixture, secondaryFixture, 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('classifies an ordinary primary', () => {
const f = computeSchoolFlags(primaryFixture);
expect(f.isPrimary).toBe(true);
expect(f.isSecondary).toBe(false);
expect(f.hasKS2Results).toBe(true);
expect(f.hasKS4Results).toBe(false);
});
it('classifies an ordinary secondary', () => {
const f = computeSchoolFlags(secondaryFixture);
expect(f.isSecondary).toBe(true);
expect(f.isAllThrough).toBe(false);
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);
expect(f.suppressKs4Comparison).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.isSpecial).toBe(false);
expect(f.suppressKs2Comparison).toBe(false);
});
it('detects a gender split only when census counts are present', () => {
expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true);
expect(
computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit,
).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');
expect(ids).toContain('history');
});
it('labels the results section by phase', () => {
const label = (fixture: any) => {
const flags = computeSchoolFlags(fixture);
return buildNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
yearlyDataLength: fixture.yearlyData.length,
}).find((n) => n.id === 'results')?.label;
};
expect(label(primaryFixture)).toBe('SATs');
expect(label(secondaryFixture)).toBe('GCSEs');
expect(label(allThroughFixture)).toBe('Results');
});
it('keeps the engagement-led ordering', () => {
const flags = computeSchoolFlags(primaryFixture);
const ids = buildNavItems(flags, {
ofsted: primaryFixture.ofsted,
admissions: primaryFixture.admissions,
yearlyDataLength: primaryFixture.yearlyData.length,
}).map((n) => n.id);
expect(ids).toEqual([
'ofsted', 'results', 'admissions', 'inclusion',
'history', 'school-life', 'local-area', 'finances',
]);
});
});
describe('computeSecondaryFlags', () => {
it('reads results and sixth form from the secondary fixture', () => {
const f = computeSecondaryFlags(secondaryFixture);
expect(f.hasResults).toBe(true);
expect(f.hasSixthForm).toBe(false);
expect(f.hasWellbeing).toBe(true);
});
it('reports sixth form when GIAS flags it', () => {
expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true);
});
it('suppresses the comparison for a special school', () => {
const f = computeSecondaryFlags(specialFixture);
expect(f.isSpecial).toBe(true);
expect(f.suppressComparison).toBe(true);
});
it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => {
expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false);
});
});
describe('buildSecondaryNavItems', () => {
it('uses the secondary section ids', () => {
const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, {
ofsted: secondaryFixture.ofsted,
admissions: secondaryFixture.admissions,
yearlyDataLength: secondaryFixture.yearlyData.length,
}).map((n) => n.id);
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
});
it('gates History on more than one year, unlike the primary page', () => {
const flags = computeSecondaryFlags(secondaryFixture);
const ids = buildSecondaryNavItems(flags, {
ofsted: null, admissions: null, yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).not.toContain('history');
});
});
@@ -0,0 +1,79 @@
/**
* The single seam between the characterization tests and the component tree.
*
* This file is the ONLY thing the server/client split was allowed to change.
* It now renders the shell + server-sections composition that
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
* that is the proof the refactor preserved behaviour.
*/
import { render } from '@testing-library/react';
import type { ReactNode } from 'react';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
import {
computeSchoolFlags, buildNavItems,
computeSecondaryFlags, buildSecondaryNavItems,
} from '@/lib/schoolSections';
import { nationalAveragesFixture } from './schoolFixtures';
// The shell calls useComparison(), which throws outside the provider. In the
// app this wrapper comes from app/layout.tsx.
function withProviders(ui: ReactNode) {
return <ComparisonProvider>{ui}</ComparisonProvider>;
}
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(
withProviders(
<SchoolDetailShell
schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census}
navItems={navItems}
>
<PrimarySchoolSections
{...fixture}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
</SchoolDetailShell>,
),
);
}
export function renderSecondarySchoolDetail(fixture: any) {
const flags = computeSecondaryFlags(fixture);
const navItems = buildSecondaryNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
yearlyDataLength: fixture.yearlyData.length,
});
return render(
withProviders(
<SchoolDetailShell
schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census}
navItems={navItems}
>
<SecondarySchoolSections
{...fixture}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
</SchoolDetailShell>,
),
);
}
@@ -0,0 +1,342 @@
/**
* Fixtures for the school detail characterization tests.
*
* Four shapes, each pinning a branch the detail views take:
* primary — KS2 results, legacy OEIF Ofsted, 3 years of admissions
* (enough for the year/trend toggle)
* secondary — KS4 results, Report Card Ofsted, 1 admissions year
* (no toggle)
* allThrough — BOTH KS2 and KS4, so the primary content is gated back on
* special — a special school with an all-zero KS2 row, where the
* England comparison must be suppressed
*
* Field names come from lib/types.ts. If TypeScript rejects something here,
* the fixture is wrong — never widen the type to accommodate a fixture.
*/
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
// ---------------------------------------------------------------------------
// Builders — SchoolResult has 57 required nullable fields, so every fixture
// starts from an all-null row and overrides only what it is testing.
// ---------------------------------------------------------------------------
const emptyResult: SchoolResult = {
school_id: 1,
year: 2024,
total_pupils: null, eligible_pupils: null,
rwm_expected_pct: null, reading_expected_pct: null, writing_expected_pct: null,
maths_expected_pct: null, gps_expected_pct: null, science_expected_pct: null,
rwm_high_pct: null, reading_high_pct: null, writing_high_pct: null,
maths_high_pct: null, gps_high_pct: null,
reading_progress: null, writing_progress: null, maths_progress: null,
reading_avg_score: null, maths_avg_score: null, gps_avg_score: null,
disadvantaged_pct: null, eal_pct: null, sen_support_pct: null, sen_ehcp_pct: null,
stability_pct: null,
reading_absence_pct: null, gps_absence_pct: null, maths_absence_pct: null,
writing_absence_pct: null, science_absence_pct: null,
rwm_expected_boys_pct: null, rwm_expected_girls_pct: null,
rwm_high_boys_pct: null, rwm_high_girls_pct: null,
rwm_expected_disadvantaged_pct: null, rwm_expected_non_disadvantaged_pct: null,
disadvantaged_gap: null,
rwm_expected_3yr_pct: null, reading_avg_3yr: null, maths_avg_3yr: null,
attainment_8_score: null, progress_8_score: null,
progress_8_lower_ci: null, progress_8_upper_ci: null,
progress_8_english: null, progress_8_maths: null,
progress_8_ebacc: null, progress_8_open: null,
english_maths_strong_pass_pct: null, english_maths_standard_pass_pct: null,
ebacc_entry_pct: null, ebacc_strong_pass_pct: null, ebacc_standard_pass_pct: null,
ebacc_avg_score: null, gcse_grade_91_pct: null, prior_attainment_avg: null,
};
export function makeResult(overrides: Partial<SchoolResult> = {}): SchoolResult {
return { ...emptyResult, ...overrides };
}
function makeSchool(overrides: Partial<School> = {}): School {
return {
urn: 123456,
school_name: 'Test Primary School',
local_authority: 'Westshire',
local_authority_code: 900,
school_type: 'Community school',
school_type_code: 'CY',
religious_denomination: 'None',
age_range: '4-11',
address1: '1 Test Lane',
address2: null,
town: 'Testville',
postcode: 'TE1 1ST',
address: '1 Test Lane, Testville, TE1 1ST',
latitude: 51.5,
longitude: -0.12,
phase: 'Primary',
gender: 'Mixed',
...overrides,
};
}
const emptyOfsted: OfstedInspection = {
framework: null,
inspection_date: null,
inspection_type: null,
overall_effectiveness: null,
quality_of_education: null,
behaviour_attitudes: null,
personal_development: null,
leadership_management: null,
early_years_provision: null,
previous_overall: null,
rc_safeguarding_met: null,
rc_inclusion: null,
rc_curriculum_teaching: null,
rc_achievement: null,
rc_attendance_behaviour: null,
rc_personal_development: null,
rc_leadership_governance: null,
rc_early_years: null,
rc_sixth_form: null,
report_url: null,
};
function makeOfsted(overrides: Partial<OfstedInspection> = {}): OfstedInspection {
return { ...emptyOfsted, ...overrides };
}
function makeAdmissions(overrides: Partial<SchoolAdmissions> = {}): SchoolAdmissions {
return {
year: 2024,
places_offered: 60,
total_applications: 210,
first_preference_applications: 90,
first_preference_offers: 54,
first_preference_offer_pct: 60,
oversubscribed: true,
...overrides,
};
}
const census: SchoolCensus = {
year: 2024,
total_pupils: 420,
female_pupils: 205,
male_pupils: 215,
fsm_pct: 18.4,
eal_pct: 22.1,
};
const absence: AbsenceData = {
overall_absence_rate: 5.2,
persistent_absence_rate: 14.8,
};
const deprivation: SchoolDeprivation = {
lsoa_code: 'E01000001',
idaci_score: 0.21,
idaci_decile: 4,
};
const finance: SchoolFinance = {
year: 2024,
per_pupil_spend: 5400,
staff_cost_pct: 72.5,
teacher_cost_pct: 51.2,
support_staff_cost_pct: 21.3,
premises_cost_pct: 6.1,
};
// ---------------------------------------------------------------------------
// National averages
// ---------------------------------------------------------------------------
export const nationalAveragesFixture: NationalAverages = {
year: 2024,
primary: { rwm_expected_pct: 61, rwm_high_pct: 8, reading_expected_pct: 74 },
secondary: { attainment_8_score: 45.9, progress_8_score: 0, ebacc_entry_pct: 39 },
by_year: [
{ year: 2023, primary: { rwm_expected_pct: 60 }, secondary: { attainment_8_score: 44.4 } },
{ year: 2024, primary: { rwm_expected_pct: 61 }, secondary: { attainment_8_score: 45.9 } },
],
};
// ---------------------------------------------------------------------------
// Fixture 1: ordinary primary — KS2 results, legacy OEIF, 3 admissions years
// ---------------------------------------------------------------------------
export const primaryFixture = {
schoolInfo: makeSchool(),
yearlyData: [
makeResult({ year: 2022, rwm_expected_pct: 54, reading_expected_pct: 68, writing_expected_pct: 62, maths_expected_pct: 65, total_pupils: 58 }),
makeResult({ year: 2023, rwm_expected_pct: 56, reading_expected_pct: 70, writing_expected_pct: 64, maths_expected_pct: 67, total_pupils: 60 }),
makeResult({
year: 2024,
rwm_expected_pct: 58, rwm_high_pct: 9,
reading_expected_pct: 72, writing_expected_pct: 66, maths_expected_pct: 69,
reading_progress: 0.4, writing_progress: -0.2, maths_progress: 1.1,
disadvantaged_pct: 24.5, eal_pct: 22.1, sen_support_pct: 13.2,
total_pupils: 60,
}),
],
absenceData: absence,
ofsted: makeOfsted({
framework: 'OEIF',
inspection_date: '2023-05-17',
inspection_type: 'Section 5',
overall_effectiveness: 2,
quality_of_education: 2,
behaviour_attitudes: 1,
personal_development: 2,
leadership_management: 2,
previous_overall: 3,
grade_source: 'graded',
}),
census,
admissions: makeAdmissions({ year: 2024 }),
admissionsHistory: [
makeAdmissions({ year: 2022, first_preference_offer_pct: 71 }),
makeAdmissions({ year: 2023, first_preference_offer_pct: 65 }),
makeAdmissions({ year: 2024, first_preference_offer_pct: 60 }),
],
deprivation,
finance,
};
// ---------------------------------------------------------------------------
// Fixture 2: secondary — KS4 results, Report Card Ofsted, single admissions year
// ---------------------------------------------------------------------------
export const secondaryFixture = {
schoolInfo: makeSchool({
urn: 234567,
school_name: 'Test Secondary School',
phase: 'Secondary',
age_range: '11-16',
school_type: 'Academy converter',
school_type_code: 'AC',
}),
yearlyData: [
makeResult({ year: 2023, attainment_8_score: 44.8, progress_8_score: 0.18 }),
makeResult({
year: 2024,
attainment_8_score: 46.2, progress_8_score: 0.31,
progress_8_banding: 'Above average',
english_maths_standard_pass_pct: 68.4,
english_maths_strong_pass_pct: 47.1,
ebacc_entry_pct: 41.2, ebacc_standard_pass_pct: 28.6,
ebacc_strong_pass_pct: 19.4, ebacc_avg_score: 4.31,
disadvantaged_pct: 27.8, eal_pct: 19.3, sen_support_pct: 11.7,
}),
],
absenceData: absence,
ofsted: makeOfsted({
framework: 'ReportCard',
inspection_date: '2025-11-20',
rc_inspection_date: '2025-11-20',
// isReportCard keys off a non-empty report_card record, NOT the rc_*
// fields — the rc_* numbers supply the values once the layout is chosen.
report_card: {
inclusion: { code: 2, label: 'Strong' },
curriculum_teaching: { code: 2, label: 'Strong' },
achievement: { code: 3, label: 'Expected standard' },
attendance_behaviour: { code: 2, label: 'Strong' },
personal_development: { code: 1, label: 'Exceptional' },
leadership_governance: { code: 2, label: 'Strong' },
},
rc_safeguarding_met: true,
rc_inclusion: 2,
rc_curriculum_teaching: 2,
rc_achievement: 3,
rc_attendance_behaviour: 2,
rc_personal_development: 1,
rc_leadership_governance: 2,
}),
census,
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
admissionsHistory: [makeAdmissions({ year: 2024, school_phase: 'Secondary' })],
deprivation,
finance,
};
// ---------------------------------------------------------------------------
// Fixture 3: all-through — BOTH key stages on the same row.
// The highest-value fixture: isAllThrough gates the KS2 content back on for a
// school that also has KS4 data, and this is the case most likely to break.
// ---------------------------------------------------------------------------
export const allThroughFixture = {
schoolInfo: makeSchool({
urn: 345678,
school_name: 'Test All-Through School',
phase: 'All-through',
age_range: '4-18',
has_sixth_form: true,
}),
yearlyData: [
makeResult({ year: 2023, rwm_expected_pct: 55, attainment_8_score: 45.0 }),
makeResult({
year: 2024,
rwm_expected_pct: 63, reading_expected_pct: 76,
writing_expected_pct: 70, maths_expected_pct: 71,
attainment_8_score: 48.7, progress_8_score: 0.42,
english_maths_standard_pass_pct: 71.2,
disadvantaged_pct: 20.1, eal_pct: 17.5, sen_support_pct: 9.8,
}),
],
absenceData: absence,
ofsted: makeOfsted({
framework: 'OEIF',
inspection_date: '2022-10-04',
inspection_type: 'Section 5',
overall_effectiveness: 1,
quality_of_education: 1,
behaviour_attitudes: 1,
personal_development: 1,
leadership_management: 1,
grade_source: 'graded',
}),
census,
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
admissionsHistory: [
makeAdmissions({ year: 2023, school_phase: 'Secondary', first_preference_offer_pct: 58 }),
makeAdmissions({ year: 2024, school_phase: 'Secondary', first_preference_offer_pct: 52 }),
],
deprivation,
finance,
};
// ---------------------------------------------------------------------------
// Fixture 4: special school — all-zero KS2 row.
// Guards the PR #70 regression: special/PRU/AP schools must not be shown an
// England comparison, because their pupils sit the same tests but very few
// reach the mainstream "expected standard".
// ---------------------------------------------------------------------------
export const specialFixture = {
schoolInfo: makeSchool({
urn: 456789,
school_name: 'Test Special School',
school_type: 'Community special school',
school_type_code: 'CYS',
age_range: '2-19',
}),
yearlyData: [
makeResult({
year: 2024,
rwm_expected_pct: 0,
reading_expected_pct: 0,
writing_expected_pct: 0,
maths_expected_pct: 0,
total_pupils: 12,
}),
],
absenceData: absence,
ofsted: null,
census,
admissions: null,
admissionsHistory: [],
deprivation,
finance,
};
+58 -9
View File
@@ -4,11 +4,17 @@
* URL format: /school/138267-school-name-here * URL format: /school/138267-school-name-here
*/ */
import { fetchSchoolDetails, fetchSchools } from '@/lib/api'; import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { SchoolDetailView } from '@/components/SchoolDetailView'; import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView'; import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
import {
computeSchoolFlags, buildNavItems,
computeSecondaryFlags, buildSecondaryNavItems,
} from '@/lib/schoolSections';
import { parseSchoolSlug, schoolUrl } from '@/lib/utils'; import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
import type { NationalAverages } from '@/lib/types';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
/** /**
@@ -124,10 +130,18 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound(); notFound();
} }
// Fetch school data // Fetch school data. National averages feed the England-comparison deltas
// across most sections; fetching them here rather than in a client effect
// keeps those sections server-renderable and puts the deltas in the initial
// HTML. They are supplementary, so they degrade to null rather than 404ing
// the page.
let data; let data;
let nationalAvg: NationalAverages | null = null;
try { try {
data = await fetchSchoolDetails(urn); [data, nationalAvg] = await Promise.all([
fetchSchoolDetails(urn),
fetchNationalAverages().catch(() => null),
]);
} catch (error) { } catch (error) {
console.error(`Failed to fetch school ${urn}:`, error); console.error(`Failed to fetch school ${urn}:`, error);
notFound(); notFound();
@@ -143,13 +157,30 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
const phaseStr = (school_info.phase ?? '').toLowerCase(); const phaseStr = (school_info.phase ?? '').toLowerCase();
const isAllThrough = phaseStr === 'all-through'; const isAllThrough = phaseStr === 'all-through';
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections). // All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data. // SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
const isSecondary = !isAllThrough && ( const isSecondary = !isAllThrough && (
phaseStr.includes('secondary') phaseStr.includes('secondary')
|| yearly_data.some((d: any) => d.attainment_8_score != null) || yearly_data.some((d: any) => d.attainment_8_score != null)
); );
// Section list is computed on the server so the client shell never needs to
// derive it -- and so it can never disagree with what the sections render.
const sectionInput = {
schoolInfo: school_info, yearlyData: yearly_data,
absenceData: absence_data, census: census ?? null,
deprivation: deprivation ?? null, finance: finance ?? null,
};
const primaryFlags = computeSchoolFlags(sectionInput);
const secondaryFlags = computeSecondaryFlags(sectionInput);
const navInput = {
ofsted: ofsted ?? null,
admissions: admissions ?? null,
yearlyDataLength: yearly_data.length,
};
const primaryNavItems = buildNavItems(primaryFlags, navInput);
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
// Generate JSON-LD structured data for SEO // Generate JSON-LD structured data for SEO
const structuredData = { const structuredData = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -184,7 +215,13 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
{isSecondary ? ( {isSecondary ? (
<SecondarySchoolDetailView <SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={secondaryNavItems}
>
<SecondarySchoolSections
schoolInfo={school_info} schoolInfo={school_info}
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
@@ -193,9 +230,18 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
admissions={admissions ?? null} admissions={admissions ?? null}
deprivation={deprivation ?? null} deprivation={deprivation ?? null}
finance={finance ?? null} finance={finance ?? null}
nationalAvg={nationalAvg}
flags={secondaryFlags}
/> />
</SchoolDetailShell>
) : ( ) : (
<SchoolDetailView <SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={primaryNavItems}
>
<PrimarySchoolSections
schoolInfo={school_info} schoolInfo={school_info}
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
@@ -205,7 +251,10 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
admissionsHistory={admissions_history ?? []} admissionsHistory={admissions_history ?? []}
deprivation={deprivation ?? null} deprivation={deprivation ?? null}
finance={finance ?? null} finance={finance ?? null}
nationalAvg={nationalAvg}
flags={primaryFlags}
/> />
</SchoolDetailShell>
)} )}
</> </>
); );
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,972 +0,0 @@
/**
* SecondarySchoolDetailView Component
* Dedicated detail view for secondary schools with scroll-to-section navigation.
* All sections render at once; the sticky nav scrolls to each.
*/
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison';
import { MetricTooltip } from './MetricTooltip';
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
const PerformanceChart = dynamic(
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
const RC_LABELS: Record<number, string> = {
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
};
const RC_CATEGORIES = [
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
{ key: 'rc_achievement' as const, label: 'Achievement' },
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
{ key: 'rc_early_years' as const, label: 'Early Years' },
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
];
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
if (val == null) return '';
if (val > 0) return modStyles.progressPositive;
if (val < 0) return modStyles.progressNegative;
return '';
}
function deprivationDesc(decile: number): string {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
interface SecondarySchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, census, admissions, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
// Header details collapse behind a "Show all details" link on mobile/tablet.
const [detailsOpen, setDetailsOpen] = useState(false);
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
useEffect(() => {
fetch('/api/national-averages')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setNationalAvg(data); })
.catch(() => {});
}, []);
const secondaryAvg = nationalAvg?.secondary ?? {};
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null;
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
// so their headline attainment is far below the mainstream average by design.
// Drop the England comparison + "below" framing so the page doesn't portray
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
// single 080 score with no subject breakdown to test for a placeholder, so
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
// mainstream school still shows its real value and comparison.
const isSpecial = isSpecialSchool(schoolInfo);
const suppressComparison = isSpecial;
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
})();
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Back returns wherever the user came from; deep-links fall back to search
// so the button never dead-ends or leaves the site.
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push('/search');
}
};
const scrollToTop = () => {
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: schoolInfo.phase || 'secondary',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
// Build nav items dynamically based on available data.
// Engagement-led order (matches the primary page): recognised Ofsted badge,
// then the most-sought sections — results, admissions, history — with the
// experience and context sections following.
const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<string, number> = {};
const pickActive = () => {
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
setActiveSection(top?.[1] > 0 ? top[0] : '');
};
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
ratioMap[id] = 0;
const obs = new IntersectionObserver(
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach(o => o.disconnect());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
return (
<div className={styles.container}>
{/* Standalone back link, above the header — returns wherever the user
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
<button type="button" onClick={handleBack} className={styles.topBack}>
<span aria-hidden="true"></span> Back
</button>
{/* ── Header — the location map band blends into the school title ── */}
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
{hasLocation && (
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
)}
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.badges}>
{schoolInfo.school_type && (
<span className={styles.badge}>{schoolInfo.school_type}</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.badge}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.badge}>Nursery</span>
)}
{hasSixthForm && (
<span className={styles.badge}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
{admissionsTag}
</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{hasLocation && (
<>
{' · '}
<button
type="button"
className={styles.mapLink}
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
>
View on map
</button>
</>
)}
</p>
)}
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
)}
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
)}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
>
{/* On phones the map hero shows only the glyph (nav-bar style). */}
<span className={styles.btnCompareLabel}>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</span>
<span className={styles.btnCompareGlyph} aria-hidden="true">
{isInComparison ? '✓' : '+'}
</span>
</button>
</div>
</div>
</header>
{/* ── Sticky section navigation ─────────────────────── */}
<nav className={styles.tabNav} aria-label="Page sections">
<div className={styles.tabNavInner}>
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top"> Top</button>
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
))}
</div>
</nav>
{/* ── Ofsted ─────────────────────────────────────── */}
{ofsted && (
<section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}>
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && (
<span className={styles.ofstedDate}>
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${schoolInfo.urn}`}
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Ofsted reports
</a>
</h2>
{isReportCard ? (
<>
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</div>
</div>
)}
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
const value = ofsted[key] as number | null;
return value != null ? (
<div key={key} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
{RC_LABELS[value]}
</div>
</div>
) : null;
})}
</div>
</>
) : ofsted.overall_effectiveness ? (
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{OFSTED_LABELS[ofsted.overall_effectiveness]}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
</p>
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
)}
</>
) : (
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
</div>
))}
</div>
</>
)}
</section>
)}
{/* ── GCSE Results ───────────────────────────────── */}
{hasResults && latestResults && (
<section id="gcse" className={styles.card}>
<h2 className={styles.sectionTitle}>
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
(COVID), so DfE has no starting point to measure their progress from.
</div>
)}
{/* Hero stat cards — top GCSE metrics */}
<div className={styles.heroStatGrid}>
{latestResults.attainment_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Attainment 8 score
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)}
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<DeltaChip
value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Progress 8 score
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
<div className={styles.heroStatHint}>
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
</div>
) : (
<div className={styles.heroStatHint}>National baseline: 0.0</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Attainment 8 visual bar (080 scale). This viz is explicitly
"school vs national", so it's dropped for special schools where
that comparison isn't meaningful. */}
{!suppressComparison && latestResults.attainment_8_score != null && (
<div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}>
<div
className={styles.att8VizFill}
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
/>
{secondaryAvg.attainment_8_score != null && (
<div
className={styles.att8VizNatLine}
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
>
<div className={styles.att8VizNatPill}>
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
</div>
</div>
)}
</div>
<div className={styles.att8VizTicks}>
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
</div>
</div>
)}
{/* Progress 8 number line with CI */}
{latestResults.progress_8_score != null && !p8Suspended && (
<div className={styles.p8Viz}>
<div className={styles.p8VizLabel}>Progress 8 relative to national baseline (0)</div>
{(() => {
const p8 = latestResults.progress_8_score!;
const lo = latestResults.progress_8_lower_ci ?? p8;
const hi = latestResults.progress_8_upper_ci ?? p8;
const range = 6; // 3 to +3
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
return (
<div className={styles.p8VizTrack}>
{/* CI band */}
<div
className={styles.p8VizCi}
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
/>
{/* Zero line */}
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
{/* Score dot */}
<div
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
style={{ left: toX(p8) }}
/>
</div>
);
})()}
<div className={styles.p8VizTicks}>
<span>3</span><span>2</span><span>1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
</div>
</div>
)}
{/* Progress 8 component breakdown */}
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
<>
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
<div className={styles.metricTable}>
{[
{ label: 'English', val: latestResults.progress_8_english },
{ label: 'Maths', val: latestResults.progress_8_maths },
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
].filter(r => r.val != null).map(({ label, val }) => (
<div key={label} className={styles.metricRow}>
<span className={styles.metricName}>{label}</span>
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
{formatProgress(val!)}
</span>
</div>
))}
</div>
</>
)}
{/* EBacc */}
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 4+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 5+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_avg_score != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc average point score</span>
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
</div>
)}
</div>
</>
)}
</section>
)}
{/* ── Admissions ─────────────────────────────────── */}
{admissions && (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>Admissions</h2>
{admissionsTag && (
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
<strong>{admissionsTag}</strong>{' '}
{admissionsTag === 'Selective'
? '— Entry to this school is by selective examination (e.g. 11+).'
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
: '✓ Places were available last year'}
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
</div>
)}
</section>
)}
{/* ── History table ──────────────────────────────── */}
{yearlyData.length > 1 && (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Historical Results</h2>
{yearlyData.length > 0 && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
<details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>Eng &amp; Maths 4+</th>
<th>EBacc entry %</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && (
<section id="wellbeing" className={styles.card}>
<h2 className={styles.sectionTitle}>Wellbeing &amp; Context</h2>
{/* SEN */}
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
<>
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
<div className={styles.heroStatGrid}>
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.heroStatHint}>Without an EHCP</div>
</div>
)}
{latestResults?.sen_ehcp_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils with EHCP
<MetricTooltip metricKey="sen_ehcp_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
</div>
)}
{(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null;
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
const hasSplit = isMixed && female != null && male != null && female + male > 0;
const sum = hasSplit ? female! + male! : 0;
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
const boysPct = hasSplit ? 100 - girlsPct : 0;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Total pupils</div>
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
{hasSplit && (
<>
<div
className={styles.genderBar}
role="img"
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
>
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
</div>
<div className={styles.genderSplitHint}>
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
<span className={styles.genderSplitSep}> · </span>
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
</div>
</>
)}
{schoolInfo.capacity != null && !hasSplit && (
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
)}
</div>
);
})()}
</div>
</>
)}
{/* Deprivation */}
{hasDeprivation && deprivation && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h3>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</>
)}
</section>
)}
{/* ── Finances ───────────────────────────────────── */}
{hasFinance && finance && (
<section id="finances" className={styles.card}>
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.premises_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on premises</div>
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,136 @@
/**
* AdmissionsSection — primary detail pages.
*
* Not shared with the secondary page: the two versions were only 14% similar
* (this one carries the year/trend toggle and the offer-rate chart; the
* secondary one is a much simpler panel). See SecondaryAdmissionsSection.
*
* Server component. The year/trend toggle is delegated to the small
* AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript.
*/
import type { ReactNode } from 'react';
import type { SchoolAdmissions } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
export function AdmissionsSection({
admissions,
admissionsHistory,
isAllThrough,
}: {
admissions: SchoolAdmissions;
admissionsHistory: SchoolAdmissions[];
isAllThrough: boolean;
}) {
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions);
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
) : null;
const yearView = (
<>
<dl className={styles.admissionsTiles}>
{admissions.places_offered != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
<>
{admissions.first_preference_offers}
<span className={styles.admissionsTileSub}>
of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)}
</span>
</>
) : (
formatPercentage(admissions.first_preference_offer_pct)
)}
</dd>
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div>
)}
</dl>
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
</>
);
const trendView = (
<>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} />
<p className={styles.admissionsTrendSummary}>
This year ({formatAcademicYear(admissions.year)}),{' '}
{admissions.first_preference_applications != null && (
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
)}
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
{admissions.total_applications != null && `${admissions.total_applications.toLocaleString()} applications in total`}.
</p>
</>
);
return (
<Section id="admissions">
{showAdmissionsTrend ? (
<AdmissionsViewToggle
title={title}
subtitle={subtitle}
trendLabel={`${admissionsHistory.length}-year trend`}
yearView={yearView}
trendView={trendView}
/>
) : (
/* No trend data — render statically, with no client component at all. */
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
</div>
{subtitle}
<div className={styles.admissionsViewport}>
<div className={styles.admissionsViewYear}>{yearView}</div>
</div>
</>
)}
</Section>
);
}
@@ -0,0 +1,58 @@
'use client';
import { useState, type ReactNode } from 'react';
import styles from './schoolSections.module.css';
/**
* The only interactive part of the primary admissions section, and the only
* client component in components/school/.
*
* Both views are always present in the DOM and visibility is toggled with the
* `hidden` attribute — matching the previous behaviour exactly — so the
* server-rendered markup passed in as yearView/trendView never ships as client
* JavaScript.
*
* It spans the header and the viewport because the segmented control sits
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
* below it; wrapping only one would change the DOM the CSS depends on.
*/
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>
</>
);
}
@@ -0,0 +1,57 @@
/**
* FinancesSection — shared between the primary and secondary detail pages
* (the two versions were 91% identical).
*
* Server component.
*/
import type { SchoolFinance } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
export function FinancesSection({
finance,
showPremises = false,
}: {
finance: SchoolFinance;
/**
* The secondary page shows a premises-cost card the primary page never had.
* Gated rather than enabled everywhere so this refactor makes no visible
* change; enabling it for primary is a one-line follow-up.
*/
showPremises?: boolean;
}) {
return (
<Section id="finances">
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
{showPremises && finance.premises_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on premises</div>
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</Section>
);
}
@@ -0,0 +1,249 @@
/**
* GcseSection — KS4 headline results. Secondary pages. Server component.
*/
import type { School, SchoolResult } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { DeltaChip } from '../DeltaChip';
import { SpecialSchoolNote } from '../SpecialSchoolNote';
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
export function GcseSection({
latestResults, schoolInfo, secondaryAvg, p8Suspended, suppressComparison,
}: {
latestResults: SchoolResult;
schoolInfo: School;
secondaryAvg: Record<string, number>;
p8Suspended: boolean;
suppressComparison: boolean;
}) {
return (
<section id="gcse" className={styles.card}>
<h2 className={styles.sectionTitle}>
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
(COVID), so DfE has no starting point to measure their progress from.
</div>
)}
{/* Hero stat cards — top GCSE metrics */}
<div className={styles.heroStatGrid}>
{latestResults.attainment_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Attainment 8 score
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)}
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<DeltaChip
value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Progress 8 score
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
<div className={styles.heroStatHint}>
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
</div>
) : (
<div className={styles.heroStatHint}>National baseline: 0.0</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Attainment 8 visual bar (080 scale). This viz is explicitly
"school vs national", so it's dropped for special schools where
that comparison isn't meaningful. */}
{!suppressComparison && latestResults.attainment_8_score != null && (
<div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}>
<div
className={styles.att8VizFill}
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
/>
{secondaryAvg.attainment_8_score != null && (
<div
className={styles.att8VizNatLine}
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
>
<div className={styles.att8VizNatPill}>
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
</div>
</div>
)}
</div>
<div className={styles.att8VizTicks}>
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
</div>
</div>
)}
{/* Progress 8 number line with CI */}
{latestResults.progress_8_score != null && !p8Suspended && (
<div className={styles.p8Viz}>
<div className={styles.p8VizLabel}>Progress 8 relative to national baseline (0)</div>
{(() => {
const p8 = latestResults.progress_8_score!;
const lo = latestResults.progress_8_lower_ci ?? p8;
const hi = latestResults.progress_8_upper_ci ?? p8;
const range = 6; // 3 to +3
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
return (
<div className={styles.p8VizTrack}>
{/* CI band */}
<div
className={styles.p8VizCi}
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
/>
{/* Zero line */}
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
{/* Score dot */}
<div
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
style={{ left: toX(p8) }}
/>
</div>
);
})()}
<div className={styles.p8VizTicks}>
<span>3</span><span>2</span><span>1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
</div>
</div>
)}
{/* Progress 8 component breakdown */}
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
<>
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
<div className={styles.metricTable}>
{[
{ label: 'English', val: latestResults.progress_8_english },
{ label: 'Maths', val: latestResults.progress_8_maths },
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
].filter(r => r.val != null).map(({ label, val }) => (
<div key={label} className={styles.metricRow}>
<span className={styles.metricName}>{label}</span>
<span className={`${styles.metricValue} ${progressClass(val)}`}>
{formatProgress(val!)}
</span>
</div>
))}
</div>
</>
)}
{/* EBacc */}
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 4+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc Grade 5+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_avg_score != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc average point score</span>
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
</div>
)}
</div>
</>
)}
</section>
);
}
@@ -0,0 +1,150 @@
/**
* HistorySection — results over time (chart plus historical table).
* Primary pages; the secondary equivalent is SecondaryHistorySection (the two
* were only 40% similar). Server component.
*/
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import { PerformanceChart, SatsChart } from './charts';
export function HistorySection({
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
isAllThrough, isPrimary, isSecondary, hasKS2Results, hasKS4Results,
suppressKs2Comparison, suppressKs4Comparison,
}: {
yearlyData: SchoolResult[];
schoolInfo: School;
nationalAvg: NationalAverages | null;
primaryAvg: Record<string, number>;
secondaryAvg: Record<string, number>;
isAllThrough: boolean;
isPrimary: boolean;
isSecondary: boolean;
hasKS2Results: boolean;
hasKS4Results: boolean;
suppressKs2Comparison: boolean;
suppressKs4Comparison: boolean;
}) {
return (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2>
{isAllThrough ? (
// All-through: KS2 and KS4 trends are on different scales and have
// different gap stories, so render them as two stacked charts
// rather than crowding 8+ series onto one axis.
<>
{hasKS2Results && (
<>
<h3 className={styles.subSectionTitle}>Primary KS2 SATs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
{hasKS4Results && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
</>
) : (
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
/>
</div>
)}
{yearlyData.length > 1 && (
<details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
{isAllThrough ? (
<>
<th>RWM (expected %)</th>
<th>Exceeding (%)</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
</>
) : isSecondary ? (
<>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
<th>English &amp; Maths Grade 5+</th>
</>
) : (
<>
<th>Reading, Writing &amp; Maths (expected %)</th>
<th>Exceeding expected (%)</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
</>
)}
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isAllThrough ? (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
</>
) : isSecondary ? (
<>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
</>
) : (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</section>
);
}
@@ -0,0 +1,102 @@
/**
* InclusionSection — pupil characteristics and gender split. Primary pages.
* Server component.
*/
import type { SchoolCensus, SchoolResult } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { DeltaChip } from '../DeltaChip';
import { Section, sectionStyles as styles } from './sectionShared';
export function InclusionSection({
latestResults, census, hasGenderSplit, primaryAvg,
}: {
latestResults: SchoolResult | null;
census: SchoolCensus | null;
hasGenderSplit: boolean;
primaryAvg: Record<string, number>;
}) {
return (
<section id="inclusion" className={styles.card}>
<h2 className={styles.sectionTitle}>Pupils &amp; Inclusion</h2>
<div className={styles.heroStatGrid}>
{latestResults?.disadvantaged_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.disadvantaged_pct)}
{primaryAvg.disadvantaged_pct != null && (
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
)}
</div>
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
</div>
)}
{latestResults?.eal_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English as an additional language
<MetricTooltip metricKey="eal_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.eal_pct)}
{primaryAvg.eal_pct != null && (
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
)}
</div>
{primaryAvg.eal_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.sen_support_pct)}
{primaryAvg.sen_support_pct != null && (
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
)}
</div>
{primaryAvg.sen_support_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)}
</div>
)}
{hasGenderSplit && (() => {
const female = census!.female_pupils!;
const male = census!.male_pupils!;
const girlsPct = Math.round((female / (female + male)) * 100);
const boysPct = 100 - girlsPct;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Boys and girls</div>
<div className={styles.genderSplitValue}>
<span className={styles.genderSplitGirls}>{girlsPct}%</span>
<span className={styles.genderSplitLabel}>girls</span>
<span className={styles.genderSplitSep}>·</span>
<span className={styles.genderSplitBoys}>{boysPct}%</span>
<span className={styles.genderSplitLabel}>boys</span>
</div>
<div
className={styles.genderBar}
role="img"
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
>
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
</div>
<div className={styles.heroStatHint}>
{female.toLocaleString()} girls, {male.toLocaleString()} boys
</div>
</div>
);
})()}
</div>
</section>
);
}
@@ -0,0 +1,41 @@
/**
* LocalAreaSection — IDACI deprivation decile. Primary pages only.
*
* Server component.
*/
import type { SchoolDeprivation } from '@/lib/types';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
// Moved with this section from SchoolDetailView, its only consumer.
function deprivationDesc(decile: number) {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
export function LocalAreaSection({ deprivation }: { deprivation: SchoolDeprivation }) {
return (
<Section id="local-area">
<h2 className={styles.sectionTitle}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h2>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</Section>
);
}
@@ -0,0 +1,164 @@
/**
* OfstedSection — shared between the primary and secondary detail pages.
*
* The two versions were ~80% identical, but that figure masked a real fork in
* the no-overall-grade case: the primary page shows a "Not rated" badge, while
* the secondary page shows a four-area OEIF panel. The disclaimer copy also
* differs slightly. Both are preserved exactly via the `variant` prop rather
* than reconciled, because this refactor must not change either page. Merging
* them is a follow-up decision for a human, not a side effect of a move.
*
* Server component.
*/
import type { OfstedInspection } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
const RC_LABELS: Record<number, string> = {
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
};
const RC_CATEGORIES = [
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
{ key: 'rc_achievement' as const, label: 'Achievement' },
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
{ key: 'rc_early_years' as const, label: 'Early Years' },
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
];
export interface OfstedSectionProps {
ofsted: OfstedInspection;
urn: number;
isReportCard: boolean;
ofstedInspectedDate: string | null;
oeifAllSameGrade: boolean;
oeifAreas: { label: string; value: number }[];
variant?: 'primary' | 'secondary';
}
export function OfstedSection({
ofsted, urn, isReportCard, ofstedInspectedDate,
oeifAllSameGrade, oeifAreas, variant = 'primary',
}: OfstedSectionProps) {
const isSecondary = variant === 'secondary';
return (
<Section id="ofsted">
<h2 className={styles.sectionTitle}>
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && (
<span className={styles.ofstedDate}>
{isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${urn}`}
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Ofsted reports
</a>
</h2>
{isReportCard ? (
/* ── New Report Card layout ── */
<>
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</div>
</div>
)}
{RC_CATEGORIES.map(({ key, label }) => {
const value = ofsted[key] as number | null;
return value != null ? (
<div key={key} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
{RC_LABELS[value]}
</div>
</div>
) : null;
})}
</div>
</>
) : (!isSecondary || ofsted.overall_effectiveness) ? (
/* ── Old OEIF layout ── */
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: isSecondary
? 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
</p>
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
)}
</>
) : (
/* ── Secondary only: inspected since Sept 2024, no overall grade ── */
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
</div>
))}
</div>
</>
)}
</Section>
);
}
@@ -0,0 +1,139 @@
/**
* PrimarySchoolSections — the section sequence for primary and all-through
* detail pages. Server component.
*
* All-through schools route here rather than to SecondarySchoolSections,
* because this list renders BOTH the KS2 and KS4 blocks (ResultsSection and
* HistorySection branch on isAllThrough); the secondary list is KS4-only and
* would silently drop their SATs data.
*
* The render conditions here MUST match buildNavItems in lib/schoolSections,
* or the sticky nav will link to sections that do not exist.
*/
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection';
import { InclusionSection } from './InclusionSection';
import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection';
import { LocalAreaSection } from './LocalAreaSection';
import { FinancesSection } from './FinancesSection';
export interface PrimarySchoolSectionsProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
flags: SchoolFlags;
}
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
return (
<>
{ofsted && (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
isReportCard={isReportCard}
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="primary"
/>
)}
{flags.hasAnyResults && flags.latestResults && (
<ResultsSection
latestResults={flags.latestResults}
schoolInfo={schoolInfo}
primaryAvg={primaryAvg}
secondaryAvg={secondaryAvg}
isAllThrough={flags.isAllThrough}
isSecondary={flags.isSecondary}
isSpecial={flags.isSpecial}
hasKS2Results={flags.hasKS2Results}
hasKS4Results={flags.hasKS4Results}
ks2Placeholder={flags.ks2Placeholder}
suppressKs2Comparison={flags.suppressKs2Comparison}
suppressKs4Comparison={flags.suppressKs4Comparison}
/>
)}
{admissions && (
<AdmissionsSection
admissions={admissions}
admissionsHistory={admissionsHistory}
isAllThrough={flags.isAllThrough}
/>
)}
{flags.hasInclusionData && (
<InclusionSection
latestResults={flags.latestResults}
census={census}
hasGenderSplit={flags.hasGenderSplit}
primaryAvg={primaryAvg}
/>
)}
{yearlyData.length > 0 && (
<HistorySection
yearlyData={yearlyData}
schoolInfo={schoolInfo}
nationalAvg={nationalAvg}
primaryAvg={primaryAvg}
secondaryAvg={secondaryAvg}
isAllThrough={flags.isAllThrough}
isPrimary={flags.isPrimary}
isSecondary={flags.isSecondary}
hasKS2Results={flags.hasKS2Results}
hasKS4Results={flags.hasKS4Results}
suppressKs2Comparison={flags.suppressKs2Comparison}
suppressKs4Comparison={flags.suppressKs4Comparison}
/>
)}
{flags.hasSchoolLife && (
<SchoolLifeSection absenceData={absenceData} primaryAvg={primaryAvg} />
)}
{flags.hasDeprivation && deprivation && (
<LocalAreaSection deprivation={deprivation} />
)}
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
</>
);
}
@@ -0,0 +1,303 @@
/**
* ResultsSection — KS2 SATs (and, for all-through schools, the KS4 block).
* Primary and all-through pages. Server component.
*/
import type { School, SchoolResult } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { DeltaChip } from '../DeltaChip';
import { SpecialSchoolNote } from '../SpecialSchoolNote';
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
import { SatsChart } from './charts';
export function ResultsSection({
latestResults, schoolInfo, primaryAvg, secondaryAvg,
isAllThrough, isSecondary, isSpecial, hasKS2Results, hasKS4Results,
ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
}: {
latestResults: SchoolResult;
schoolInfo: School;
primaryAvg: Record<string, number>;
secondaryAvg: Record<string, number>;
isAllThrough: boolean;
isSecondary: boolean;
isSpecial: boolean;
hasKS2Results: boolean;
hasKS4Results: boolean;
ks2Placeholder: boolean;
suppressKs2Comparison: boolean;
suppressKs4Comparison: boolean;
}) {
return (
<section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}>
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
{isSpecial
? (isSecondary
? 'GCSE results for Year 11 pupils.'
: 'End-of-primary-school tests taken by Year 6 pupils.')
: isAllThrough
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
: isSecondary
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
</p>
{/* Explains up front why the England comparison is dropped below, so
a 0% headline never reads as a failing grade against a benchmark
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
<SpecialSchoolNote school={schoolInfo} />
{/* ── Primary / KS2 content ── */}
{hasKS2Results && (
<>
{isAllThrough && (
<h3 className={styles.subSectionTitle}>Primary KS2 SATs (Year 6)</h3>
)}
<div className={styles.heroStatGrid}>
{latestResults.rwm_expected_pct !== null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Reading, Writing &amp; Maths combined
<MetricTooltip metricKey="rwm_expected_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<DeltaChip
value={latestResults.rwm_expected_pct}
baseline={primaryAvg.rwm_expected_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.rwm_high_pct !== null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Exceeding expected level (Reading, Writing &amp; Maths)
<MetricTooltip metricKey="rwm_high_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<DeltaChip
value={latestResults.rwm_high_pct}
baseline={primaryAvg.rwm_high_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{!suppressKs2Comparison &&
latestResults.rwm_expected_pct != null &&
latestResults.reading_expected_pct != null &&
latestResults.writing_expected_pct != null &&
latestResults.maths_expected_pct != null && (
<div className={styles.rwmBridge}>
<span className={styles.rwmBridgeIcon} aria-hidden="true">?</span>
<div className={styles.rwmBridgeBody}>
<div className={styles.rwmBridgeText}>
Why is combined lower? A pupil is only counted if they met the bar in{' '}
<strong>all three</strong> subjects. Some passed reading but not writing; some passed writing but not maths.
</div>
<div className={styles.rwmBridgeMath}>
<span>Reading <strong>{latestResults.reading_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}>·</span>
<span>Writing <strong>{latestResults.writing_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}>·</span>
<span>Maths <strong>{latestResults.maths_expected_pct.toFixed(0)}%</strong></span>
<span className={styles.rwmBridgeMathSep}></span>
<span>All three <strong>{latestResults.rwm_expected_pct.toFixed(0)}%</strong></span>
</div>
</div>
</div>
)}
{/* All-zero placeholder rows (special / suppressed) would render as
three empty bars against the national markers — misleading, so
skip the chart. For a special school with some non-zero
subjects, keep the bars but drop the national markers. */}
{!ks2Placeholder && (
<SatsChart
subjects={[
{
name: 'Reading',
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
// Writing's higher level is teacher-assessed "greater depth".
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
},
{
name: 'Maths',
expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
},
]}
/>
)}
{/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
<div className={styles.progressScoresRow}>
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
<div className={styles.progressScoresGrid}>
{latestResults.reading_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Reading</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
{formatProgress(latestResults.reading_progress)}
</span>
</div>
)}
{latestResults.writing_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Writing</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
{formatProgress(latestResults.writing_progress)}
</span>
</div>
)}
{latestResults.maths_progress != null && (
<div className={styles.progressScoreItem}>
<span className={styles.progressScoreLabel}>Maths</span>
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
{formatProgress(latestResults.maths_progress)}
</span>
</div>
)}
</div>
</div>
)}
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
<p className={styles.progressNote}>
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
</p>
)}
</>
)}
{/* ── Secondary / KS4 content ── */}
{hasKS4Results && (
<>
{isAllThrough && (
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs (Year 11)</h3>
)}
<div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Attainment 8
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Progress 8
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
<div className={styles.metricHint}>0 = national average</div>
</div>
)}
{latestResults.english_maths_standard_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* EBacc */}
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 4+
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 5+
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
</div>
</>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,669 @@
/* Styles for SchoolDetailShell — the interactive chrome of a detail page.
Derived from the classes the shell's JSX references; the section styles
live in components/school/schoolSections.module.css. Classes used by both
appear in both files, which is correct: CSS Modules hash them per-file. */
.container {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* Standalone back link, sits above the header card on the page background. */
.topBack {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.75rem;
padding: 0.25rem 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: none;
cursor: pointer;
line-height: 1.2;
transition: color 0.15s ease;
}
.topBack:hover {
color: var(--accent-coral-dark, #c85a3e);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Header Section */
.header {
position: relative;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
/* Padding lives on .headerContent so the map band can bleed to the edges. */
padding: 0;
margin-bottom: 0;
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
padding: 1.25rem 1.5rem;
}
/* With a map band above, slide the title up under the fade so map and title
read as one object; the Compare button floats glassy over the map. */
.headerHasMap .headerContent {
padding-top: 0;
margin-top: -0.5rem;
}
/* The title (not the whole content row) rises above the map fade. Keeping
.headerContent unpositioned matters: .actions must anchor to .header so it
floats over the map band, not over the title. */
.headerHasMap .titleSection {
position: relative;
z-index: 3;
}
.headerHasMap .actions {
position: absolute;
top: 14px;
right: 14px;
z-index: 6;
margin: 0;
/* Beat the mobile `.actions { width: 100% }` rule — a floating button
must never stretch across the title. */
width: auto;
}
.headerHasMap .actions .btnAdd {
background: rgba(255, 255, 255, 0.9);
color: var(--accent-coral-dark, #b04a2e);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.headerHasMap .actions .btnAdd:hover {
background: #fff;
}
/* Full label by default; phones over the map get an icon-only button
(same compact treatment as the section-nav compare icon). */
.btnCompareGlyph {
display: none;
}
/* Inline "View on map ↗" trigger next to the address. */
.mapLink {
border: none;
background: none;
padding: 0;
font: inherit;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
cursor: pointer;
white-space: nowrap;
}
.mapLink:hover {
color: var(--accent-coral-dark, #c45a3f);
text-decoration: underline;
text-underline-offset: 2px;
}
.titleSection {
flex: 1;
}
.schoolName {
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem;
line-height: 1.1;
letter-spacing: -0.01em;
font-family: var(--font-playfair), "Playfair Display", serif;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.metaItem {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 3px;
}
.address {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
margin: 0 0 0.75rem;
}
/* Expanded header details (headteacher, website, trust, pupils) */
.headerDetails {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.5rem;
}
.headerDetail {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
}
.headerDetail strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
.headerDetail a {
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
}
.headerDetail a:hover {
text-decoration: underline;
}
/* "Show all details" reveal — only rendered on mobile/tablet, where the
header details block is collapsed below the fold. Hidden on desktop. */
.detailsToggle {
display: none;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
padding: 0;
background: none;
border: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
cursor: pointer;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
align-self: center;
}
.btnAdd,
.btnRemove {
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
}
.btnAdd {
background: var(--accent-coral-dark, #b04a2e);
color: white;
}
.btnAdd:hover {
background: var(--accent-coral-darker, #9c3f26);
transform: translateY(-1px);
}
.btnRemove {
background: var(--accent-teal, #2d7d7d);
color: white;
}
.btnRemove:hover {
opacity: 0.9;
}
/* ── Sticky Section Navigation ──────────────────────── */
/* Docks directly under the global header; Back and "All" stay pinned while
only the section links scroll. */
.sectionNav {
position: sticky;
top: 64px; /* global header height on desktop */
z-index: 10;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-top: none;
border-radius: 0 0 10px 10px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 0.5rem;
}
.sectionNavBack {
flex: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sectionNavBack:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
}
/* The scrolling middle: section links only. */
.sectionNavLinks {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.375rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scroll-snap-type: x proximity;
scroll-padding-inline: 0.5rem;
}
.sectionNavLinks::-webkit-scrollbar {
display: none;
}
.sectionNavLink {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
white-space: nowrap;
scroll-snap-align: start;
}
.sectionNavLink:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.sectionNavLinkActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.sectionNavLinkActive:hover {
background: var(--accent-coral-dark, #c45a3f);
color: white;
}
/* ── Mobile: the scrolling links collapse into one "section" menu button ──
(hidden on desktop, where the links fit). */
.sectionNavMenu {
display: none; /* shown only ≤640px */
flex: 1;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 38px;
padding: 0.34rem 0.7rem;
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
cursor: pointer;
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
color: var(--text-primary, #1a1612);
}
.sectionNavMenuCur {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.sectionNavMenuEyebrow {
flex: none;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.sectionNavMenuNow {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sectionNavMenuChev {
flex: none;
color: var(--text-muted, #6d685f);
font-size: 0.7rem;
}
/* Compact icon version of the Compare CTA, used on mobile. */
.sectionNavCompareIcon {
display: none; /* shown only ≤640px */
position: relative;
flex: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 9px;
border: 1px solid var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-dark, #b04a2e);
color: white;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.sectionNavCompareIcon svg {
width: 19px;
height: 19px;
}
.sectionNavCompareBadge {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-card, white);
color: var(--accent-coral-dark, #c45a3f);
border: 1.5px solid var(--accent-coral, #e07256);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 800;
line-height: 1;
}
.sectionNavCompareIconIn {
background: var(--bg-card, white);
border-color: var(--accent-teal, #2d7d7d);
color: var(--accent-teal, #2d7d7d);
}
/* Compare CTA carried into the bar once the hero's button scrolls away. */
.sectionNavCompare {
flex: none;
display: inline-flex;
align-items: center;
padding: 0.34rem 0.7rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral-dark, #b04a2e);
border: 1px solid var(--accent-coral-dark, #b04a2e);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sectionNavCompare:hover {
background: var(--accent-coral-darker, #9c3f26);
border-color: var(--accent-coral-darker, #9c3f26);
}
.sectionNavCompareIn {
background: var(--bg-card, white);
color: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
}
.sectionNavCompareIn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-teal, #2d7d7d);
}
/* "All ▾" jump menu (desktop). */
.sectionNavAll {
flex: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.34rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease;
}
.sectionNavAll:hover {
background: var(--border-color, #e5dfd5);
}
.sectionsBackdrop {
position: fixed;
inset: 0;
z-index: 1500;
background: rgba(26, 22, 18, 0.28);
}
.sectionsPanel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1600;
width: 230px;
max-height: min(70vh, 460px);
overflow-y: auto;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
padding: 0.35rem;
}
.sectionsPanelHead {
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
padding: 0.4rem 0.6rem 0.5rem;
}
.sectionsItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
text-decoration: none;
transition: background 0.12s ease;
}
.sectionsItem:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.sectionsItemActive {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral-dark, #c45a3f);
font-weight: 600;
}
.sectionsTick {
color: var(--accent-coral-dark, #b04a2e);
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
@media (max-width: 640px) {
.headerHasMap .actions .btnCompareLabel {
display: none;
}
.headerHasMap .actions .btnCompareGlyph {
display: inline;
}
.headerHasMap .actions .btnAdd,
.headerHasMap .actions .btnRemove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 40px;
height: 40px;
padding: 0;
border-radius: 999px;
font-size: 1.375rem;
line-height: 1;
}
}
@media (max-width: 640px) {
.sectionNav {
top: 56px; /* global header is shorter on mobile */
padding: 0.4rem 0.6rem;
gap: 0.375rem;
}
}
@media (max-width: 640px) {
.sectionNavLink,
.sectionNavBack {
min-height: 36px;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.sectionNavCompare {
min-height: 36px;
}
}
@media (max-width: 640px) {
.sectionNavAll {
min-height: 36px;
}
}
@@ -0,0 +1,462 @@
/**
* SchoolDetailShell — the interactive chrome of a school detail page.
*
* The ONLY large client component on the route. Everything below the sticky
* nav is server-rendered and arrives as `children`, composed in
* app/school/[slug]/page.tsx. That indirection is required: a server component
* imported by a client component becomes a client component, so the sections
* cannot be imported here.
*
* What stays client-side is genuinely interactive: router.back(), the header
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
* sections with document.getElementById, so it works unchanged against
* server-rendered children.
*/
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
/**
* Only what the chrome itself renders. Everything the sections need — Ofsted,
* admissions, deprivation, finance, national averages — goes straight to the
* section composers in page.tsx and never reaches the client.
*/
export interface SchoolDetailShellProps {
schoolInfo: School;
/** Only for the header's pupil-count fallback. */
yearlyData: SchoolResult[];
census: SchoolCensus | null;
/** Section list for the sticky nav, computed on the server. */
navItems: NavItem[];
/** The server-rendered sections. */
children: ReactNode;
}
export function SchoolDetailShell({
schoolInfo, yearlyData, census, navItems, children,
}: SchoolDetailShellProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
// Admissions view state moved to AdmissionsViewToggle, the client island
// inside the (server-rendered) admissions section.
// Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
const heroActionsRef = useRef<HTMLDivElement | null>(null);
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
// Back returns to wherever the user came from; deep-links (no in-app history)
// fall back to search so the button never dead-ends or leaves the site.
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push('/search');
}
};
const scrollToTop = () => {
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
const el = sectionLinksRef.current;
if (!el) return;
const update = () => {
const overflow = el.scrollWidth - el.clientWidth;
// No overflow → treat as "at end" so the fade is hidden.
if (overflow <= 1) {
setSectionNavAtEnd(true);
return;
}
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, []);
// Track whether the hero's "Add to Compare" button is still on screen.
useEffect(() => {
const el = heroActionsRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => setHeroCtaVisible(entry.isIntersecting),
{ rootMargin: '-64px 0px 0px 0px' },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
// Close the "All ▾" menu on Escape.
useEffect(() => {
if (!sectionsOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
// The chrome needs only these four. The section-shape flags are computed
// once on the server (lib/schoolSections) and consumed by the section
// composers; recomputing them here would duplicate that work for values
// this component never renders.
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Page-view event with funnel attribution. Fires once per mount.
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: phase || 'unknown',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<string, number> = {};
const pickActive = () => {
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
setActiveSection(top?.[1] > 0 ? top[0] : '');
};
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
ratioMap[id] = 0;
const obs = new IntersectionObserver(
([entry]) => {
ratioMap[id] = entry.intersectionRatio;
pickActive();
},
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach(o => o.disconnect());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]);
// Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
return (
<div className={styles.container}>
{/* Standalone back link, above the header — returns to wherever the
user came from. Scrolls away with the page (the sticky bar keeps a
"back to top" control in its place). */}
<button type="button" onClick={handleBack} className={styles.topBack}>
<span aria-hidden="true"></span> Back
</button>
{/* Header — the location map band blends down into the school title. */}
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
{hasLocation && (
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
)}
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{hasLocation && (
<>
{' · '}
<button
type="button"
className={styles.mapLink}
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
>
View on map
</button>
</>
)}
</p>
)}
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
)}
{(() => {
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions} ref={heroActionsRef}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
>
{/* On phones the map hero shows only the glyph (nav-bar style). */}
<span className={styles.btnCompareLabel}>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</span>
<span className={styles.btnCompareGlyph} aria-hidden="true">
{isInComparison ? '✓' : '+'}
</span>
</button>
</div>
</div>
</header>
{/* Sticky Section Navigation — docks under the global header */}
<nav className={styles.sectionNav} aria-label="Page sections">
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
<span aria-hidden="true"></span>
<span className={styles.sectionNavBackLabel}>Top</span>
</button>
{/* Desktop: scrolling section links */}
<div
ref={sectionLinksRef}
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
))}
</div>
{/* Mobile: a single "section" menu button that opens the jump sheet */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavMenu}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
<span className={styles.sectionNavMenuCur}>
<span className={styles.sectionNavMenuEyebrow}>Section</span>
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
</span>
<span className={styles.sectionNavMenuChev} aria-hidden="true"></span>
</button>
)}
{/* The hero's Compare CTA, carried in once it scrolls out of view.
Desktop shows a labelled pill; mobile a compact icon. */}
{!heroCtaVisible && (
<>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
>
{isInComparison ? '✓ Comparing' : '+ Compare'}
</button>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
title={isInComparison ? 'In comparison' : 'Add to compare'}
>
{isInComparison ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12 5 5 9-11" />
</svg>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M4 7h13l-3-3" />
<path d="M20 17H7l3 3" />
</svg>
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
</>
)}
</button>
</>
)}
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavAll}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
All <span aria-hidden="true"></span>
</button>
)}
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
{sectionsOpen && (
<>
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
<div className={styles.sectionsPanelHead}>Jump to section</div>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
role="menuitem"
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
onClick={() => {
setSectionsOpen(false);
track('section_nav_used', { section: id, via: 'all_menu' });
}}
>
<span>{label}</span>
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true"></span>}
</a>
))}
</div>
</>
)}
</nav>
{children}
</div>
);
}
@@ -0,0 +1,51 @@
/**
* SchoolLifeSection — absence figures. Primary pages only; the secondary page
* carries the equivalent content inside WellbeingSection.
*
* Server component.
*/
import type { AbsenceData } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
export function SchoolLifeSection({
absenceData,
primaryAvg,
}: {
absenceData: AbsenceData | null;
primaryAvg: Record<string, number>;
}) {
return (
<Section id="school-life">
<h2 className={styles.sectionTitle}>School Life</h2>
<div className={styles.metricsGrid}>
{absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Days missed (overall absence)
<MetricTooltip metricKey="overall_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
{primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)}
</div>
)}
{absenceData?.persistent_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Regularly missing school
<MetricTooltip metricKey="persistent_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
{primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
</Section>
);
}
@@ -0,0 +1,87 @@
/**
* SecondaryAdmissionsSection — secondary pages.
*
* Not shared with the primary AdmissionsSection: the two were only 14%
* similar. This one has no year/trend toggle, so it ships no client
* JavaScript at all. Server component.
*/
import type { School, SchoolAdmissions } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
export function SecondaryAdmissionsSection({
admissions, schoolInfo, hasSixthForm,
}: {
admissions: SchoolAdmissions;
schoolInfo: School;
hasSixthForm: boolean;
}) {
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
})();
return (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>Admissions</h2>
{admissionsTag && (
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
<strong>{admissionsTag}</strong>{' '}
{admissionsTag === 'Selective'
? '— Entry to this school is by selective examination (e.g. 11+).'
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
: '✓ Places were available last year'}
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
</div>
)}
</section>
);
}
@@ -0,0 +1,70 @@
/**
* SecondaryHistorySection — results over time. Secondary pages.
* Server component.
*/
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import { PerformanceChart } from './charts';
export function SecondaryHistorySection({
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
}: {
yearlyData: SchoolResult[];
schoolInfo: School;
nationalAvg: NationalAverages | null;
secondaryAvg: Record<string, number>;
suppressComparison: boolean;
}) {
// National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
return (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Historical Results</h2>
{yearlyData.length > 0 && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
<details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>Eng &amp; Maths 4+</th>
<th>EBacc entry %</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
);
}
@@ -0,0 +1,115 @@
/**
* SecondarySchoolSections — the section sequence for secondary detail pages.
* Server component.
*
* Wrapped in `.secondaryScope`, which activates the secondary-only style
* overrides in schoolSections.module.css. Those rules target class names the
* primary page also uses (.card, .sectionTitle, .metricCard …), so scoping is
* what keeps them from restyling primary pages.
*
* The render conditions here MUST match buildSecondaryNavItems in
* lib/schoolSections, or the sticky nav will link to sections that do not exist.
*/
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { GcseSection } from './GcseSection';
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection';
import styles from './schoolSections.module.css';
export interface SecondarySchoolSectionsProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
flags: SecondaryFlags;
}
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
return (
<div className={styles.secondaryScope}>
{ofsted && (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
isReportCard={isReportCard}
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="secondary"
/>
)}
{flags.hasResults && flags.latestResults && (
<GcseSection
latestResults={flags.latestResults}
schoolInfo={schoolInfo}
secondaryAvg={secondaryAvg}
p8Suspended={flags.p8Suspended}
suppressComparison={flags.suppressComparison}
/>
)}
{admissions && (
<SecondaryAdmissionsSection
admissions={admissions}
schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm}
/>
)}
{yearlyData.length > 1 && (
<SecondaryHistorySection
yearlyData={yearlyData}
schoolInfo={schoolInfo}
nationalAvg={nationalAvg}
secondaryAvg={secondaryAvg}
suppressComparison={flags.suppressComparison}
/>
)}
{flags.hasWellbeing && (
<WellbeingSection
latestResults={flags.latestResults}
census={census}
schoolInfo={schoolInfo}
deprivation={deprivation}
hasDeprivation={flags.hasDeprivation}
/>
)}
{flags.hasFinance && finance && (
<FinancesSection finance={finance} showPremises />
)}
</div>
);
}
@@ -0,0 +1,122 @@
/**
* WellbeingSection — SEN, gender split and local-area deprivation.
* Secondary pages. Server component.
*/
import type { School, SchoolCensus, SchoolResult, SchoolDeprivation } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { MetricTooltip } from '../MetricTooltip';
import { Section, sectionStyles as styles } from './sectionShared';
// Moved with this section from SecondarySchoolDetailView, its only consumer.
function deprivationDesc(decile: number) {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
export function WellbeingSection({
latestResults, census, schoolInfo, deprivation, hasDeprivation,
}: {
latestResults: SchoolResult | null;
census: SchoolCensus | null;
schoolInfo: School;
deprivation: SchoolDeprivation | null;
hasDeprivation: boolean;
}) {
return (
<section id="wellbeing" className={styles.card}>
<h2 className={styles.sectionTitle}>Wellbeing &amp; Context</h2>
{/* SEN */}
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
<>
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
<div className={styles.heroStatGrid}>
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.heroStatHint}>Without an EHCP</div>
</div>
)}
{latestResults?.sen_ehcp_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils with EHCP
<MetricTooltip metricKey="sen_ehcp_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
</div>
)}
{(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null;
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
const hasSplit = isMixed && female != null && male != null && female + male > 0;
const sum = hasSplit ? female! + male! : 0;
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
const boysPct = hasSplit ? 100 - girlsPct : 0;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Total pupils</div>
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
{hasSplit && (
<>
<div
className={styles.genderBar}
role="img"
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
>
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
</div>
<div className={styles.genderSplitHint}>
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
<span className={styles.genderSplitSep}> · </span>
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
</div>
</>
)}
{schoolInfo.capacity != null && !hasSplit && (
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
)}
</div>
);
})()}
</div>
</>
)}
{/* Deprivation */}
{hasDeprivation && deprivation && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h3>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
</>
)}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
'use client';
/**
* Client wrappers for the lazily-loaded charts.
*
* `next/dynamic` with `ssr: false` is only legal inside a Client Component,
* and the sections that render charts are Server Components. These one-line
* wrappers are the client boundary, so the charts stay browser-only and
* code-split while the section markup around them stays on the server.
*
* Chart.js is ~64 KB gzipped, so keeping it lazy matters.
*/
import dynamic from 'next/dynamic';
export const PerformanceChart = dynamic(
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
export const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
export const AdmissionsTrendChart = dynamic(
() => import('../AdmissionsTrendChart'),
{ ssr: false },
);
@@ -147,6 +147,7 @@
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
font-family: var(--font-playfair), "Playfair Display", serif; font-family: var(--font-playfair), "Playfair Display", serif;
overflow-wrap: break-word;
} }
.meta { .meta {
@@ -168,6 +169,7 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
overflow-wrap: break-word;
} }
/* Expanded header details (headteacher, website, trust, pupils) */ /* Expanded header details (headteacher, website, trust, pupils) */
@@ -711,6 +713,8 @@
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
flex-wrap: wrap; flex-wrap: wrap;
overflow-wrap: break-word;
min-width: 0;
} }
.sectionTitle::before { .sectionTitle::before {
@@ -768,6 +772,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
overflow-wrap: break-word;
word-break: break-word;
} }
.metricHint { .metricHint {
@@ -1210,6 +1216,7 @@
.schoolName { .schoolName {
font-size: 1.25rem; font-size: 1.25rem;
word-break: break-word;
} }
/* Pills wrap horizontally instead of stacking short tokens like /* Pills wrap horizontally instead of stacking short tokens like
@@ -1591,6 +1598,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
user-select: none;
} }
.historyToggle::-webkit-details-marker { .historyToggle::-webkit-details-marker {
@@ -1622,3 +1630,316 @@
.closingStrip strong { .closingStrip strong {
color: #8a6200; color: #8a6200;
} }
/* ── Secondary-only rules, merged from SecondarySchoolDetailView.module.css ── */
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.badge {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 3px;
}
.badgeSelective {
background: rgba(180, 120, 0, 0.1);
color: #8a6200;
}
.badgeFaith {
background: rgba(45, 125, 125, 0.1);
color: var(--accent-teal, #2d7d7d);
}
/* ── Tab Navigation (sticky) ─────────────────────────── */
.tabNav {
position: sticky;
top: 4rem;
z-index: 10;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-top: none;
border-radius: 0 0 10px 10px;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.tabNav::-webkit-scrollbar {
display: none;
}
.tabNavInner {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.backBtn {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
margin-right: 0.25rem;
}
.backBtn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
}
.tabNavDivider {
width: 1px;
height: 1rem;
background: var(--border-color, #e5dfd5);
margin: 0 0.25rem;
flex-shrink: 0;
}
.tabBtn {
display: inline-block;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none;
}
.tabBtn:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.tabBtnActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
font-weight: 600;
}
.tabBtnActive:hover {
background: var(--accent-coral-darker, #9c3f26);
color: white;
}
/* ── Progress 8 suspension banner ───────────────────── */
.p8Banner {
background: rgba(180, 120, 0, 0.1);
border: 1px solid rgba(180, 120, 0, 0.3);
color: #8a6200;
border-radius: 6px;
padding: 0.625rem 0.875rem;
font-size: 0.825rem;
margin-bottom: 1rem;
line-height: 1.5;
}
/* ── Admissions ──────────────────────────────────────── */
.admissionsTypeBadge {
border-radius: 6px;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.admissionsSelective {
background: rgba(180, 120, 0, 0.1);
color: #8a6200;
border: 1px solid rgba(180, 120, 0, 0.25);
}
.admissionsFaith {
background: rgba(45, 125, 125, 0.08);
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.2);
}
.sixthFormNote {
margin-top: 1rem;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 6px;
font-size: 0.825rem;
color: var(--text-secondary, #5c564d);
border-left: 3px solid var(--accent-teal, #2d7d7d);
}
.genderSplitHint {
font-size: 0.7rem;
color: var(--text-muted, #6d685f);
margin-top: 0.35rem;
font-weight: 500;
}
/* ── Attainment 8 visual bar ─────────────────────────── */
.att8Viz {
margin: 1.25rem 0 0.5rem;
}
.att8VizLabel {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted, #6d685f);
margin-bottom: 0.5rem;
}
.att8VizTrack {
position: relative;
height: 14px;
background: rgba(45, 125, 125, 0.08);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
overflow: visible;
}
.att8VizFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px 0 0 4px;
transition: width 0.6s ease;
}
.att8VizNatLine {
position: absolute;
top: -4px;
bottom: -4px;
width: 2px;
background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 2;
}
.att8VizNatPill {
position: absolute;
top: -20px;
transform: translateX(-50%);
background: var(--accent-coral-dark, #b04a2e);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.3rem;
border-radius: 3px;
white-space: nowrap;
}
.att8VizTicks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 0.6rem;
color: var(--text-muted, #6d685f);
}
/* ── Progress 8 number line ──────────────────────────── */
.p8Viz {
margin: 1.25rem 0 0.5rem;
}
.p8VizLabel {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted, #6d685f);
margin-bottom: 0.5rem;
}
.p8VizTrack {
position: relative;
height: 14px;
background: rgba(45, 125, 125, 0.06);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 4px;
overflow: visible;
}
.p8VizCi {
position: absolute;
top: 0;
bottom: 0;
background: rgba(45, 125, 125, 0.18);
border-radius: 3px;
}
.p8VizZero {
position: absolute;
top: -4px;
bottom: -4px;
width: 2px;
background: var(--border-color, #e5dfd5);
z-index: 1;
}
.p8VizDot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-teal, #2d7d7d);
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 3;
}
.p8VizDotNeg {
background: var(--accent-coral, #e07256);
}
.p8VizTicks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 0.6rem;
color: var(--text-muted, #6d685f);
}
/* Secondary variants
Three rules differ between the primary and secondary detail pages in ways
that are genuinely visual rather than incidental drift. Rather than pick a
winner (which would change one of the two pages) they are kept as explicit
variant classes, so the divergence is deliberate and reviewable instead of
accidental. Sections select them via a `variant` prop. */
/* Secondary renders a slimmer gender bar on a translucent track. */
.genderBarSecondary {
height: 4px;
background: rgba(0, 0, 0, 0.08);
margin-top: 0.45rem;
}
/* Secondary emphasises the split figures. */
.genderSplitBoysSecondary {
font-weight: 600;
}
.genderSplitGirlsSecondary {
font-weight: 600;
}
/* Secondary hero stats sit tighter and omit the Georgia fallback. */
.heroStatValueSecondary {
gap: 0.4rem;
font-family: var(--font-playfair), "Playfair Display", serif;
}
/* Secondary-scoped overrides
These rules exist only in the secondary stylesheet but target class names
the primary page also uses (.card, .sectionTitle, .metricCard ). Applied
flat they would restyle the primary page, so they are scoped to a wrapper
class that only SecondarySchoolSections carries. Same principle as the
variant classes above, applied at rule scope. */
.secondaryScope .heroStatCard .heroStatLabel { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted, #6d685f); }
.secondaryScope .heroStatCard .heroStatValue { font-family: var(--font-playfair), "Playfair Display", serif; font-size: 2.1rem; font-weight: 700; line-height: 1; color: var(--accent-teal, #2d7d7d); display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; }
.secondaryScope .heroStatCard .heroStatHint { font-size: 0.7rem; color: var(--text-muted, #6d685f); font-style: normal; margin-top: 0; }
.secondaryScope .historyDisclosure[open] .historyToggle::before { transform: rotate(90deg); }
@media (max-width: 480px) {
.secondaryScope .metricsGrid { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.secondaryScope .metricCard { padding: 0.5rem; }
.secondaryScope .metricLabel { font-size: 0.625rem; }
}
@media (max-width: 768px) {
.secondaryScope .header { padding: 1rem; }
.secondaryScope .badges { gap: 0.25rem; }
.secondaryScope .badge { font-size: 0.75rem; padding: 0.1rem 0.375rem; }
.secondaryScope .metricValue { font-size: 1rem; }
.secondaryScope .heroStatGrid { grid-template-columns: 1fr; }
.secondaryScope .heroStatCard .heroStatValue { font-size: 1.85rem; }
.secondaryScope .card { padding: 1rem; }
.secondaryScope .sectionTitle { font-size: 1rem; }
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
}
@@ -0,0 +1,43 @@
/**
* Shared primitives for the school detail sections, mirroring
* components/compare/sectionShared.tsx.
*
* Deliberately minimal: the sections were extracted as verbatim moves from the
* two detail views, so wrapping their markup in heavyweight primitives would
* risk changing the DOM the CSS modules depend on. This provides only the
* outer <section> shell every section shares, plus the stylesheet.
*
* All section components are SERVER components — no 'use client' anywhere in
* this directory except AdmissionsViewToggle.
*/
import type { ReactNode } from 'react';
import styles from './schoolSections.module.css';
export const sectionStyles = styles;
/** Tone class for a progress score: positive, negative, or neutral. */
export function progressClass(val: number | null | undefined): string {
if (val == null) return '';
if (val > 0) return styles.progressPositive;
if (val < 0) return styles.progressNegative;
return '';
}
/**
* The section shell. `id` must match the id buildNavItems emits, because the
* sticky nav's scroll-spy locates sections with document.getElementById.
*/
export function Section({
id,
children,
}: {
id: string;
children: ReactNode;
}) {
return (
<section id={id} className={styles.card}>
{children}
</section>
);
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Custom hook for fetching filter options with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { FiltersResponse } from '@/lib/types';
export function useFilters() {
const { data, error, isLoading } = useSWR<FiltersResponse>(
'/filters',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000, // 1 minute
}
);
return {
filters: data,
localAuthorities: data?.local_authorities || [],
schoolTypes: data?.school_types || [],
years: data?.years || [],
isLoading,
error,
};
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Custom hook for fetching metric definitions with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { MetricsResponse } from '@/lib/types';
export function useMetrics() {
const { data, error, isLoading } = useSWR<MetricsResponse>(
'/metrics',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000, // 1 minute
}
);
return {
metrics: data?.metrics || [],
metricsList: data?.metrics || [],
getMetric: (key: string) => data?.metrics?.find(m => m.key === key),
isLoading,
error,
};
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Custom hook for fetching school details with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { SchoolDetailsResponse } from '@/lib/types';
export function useSchoolDetails(urn: number | null) {
const { data, error, isLoading, mutate } = useSWR<SchoolDetailsResponse>(
urn ? `/schools/${urn}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000, // 30 seconds
}
);
return {
schoolInfo: data?.school_info,
yearlyData: data?.yearly_data || [],
isLoading,
error,
mutate,
};
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Custom hook for fetching schools with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { SchoolsResponse, SchoolSearchParams } from '@/lib/types';
export function useSchools(params: SchoolSearchParams = {}, shouldFetch: boolean = true) {
const queryParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.set(key, String(value));
}
});
const queryString = queryParams.toString();
const url = `/schools${queryString ? `?${queryString}` : ''}`;
const { data, error, isLoading, mutate } = useSWR<SchoolsResponse>(
shouldFetch ? url : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000, // 5 seconds
}
);
return {
schools: data?.schools || [],
pagination: data ? {
page: data.page,
page_size: data.page_size,
total: data.total,
total_pages: data.total_pages,
} : null,
searchMode: data?.search_mode,
locationInfo: data?.location_info,
isLoading,
error,
mutate,
};
}
+3
View File
@@ -16,6 +16,9 @@ const customJestConfig = {
'**/__tests__/**/*.[jt]s?(x)', '**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)',
], ],
// __tests__/support holds fixtures and render helpers, not test suites; the
// testMatch glob above would otherwise treat them as empty suites and fail.
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/__tests__/support/'],
collectCoverageFrom: [ collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}', 'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}',
+17
View File
@@ -27,6 +27,23 @@ Object.defineProperty(window, 'matchMedia', {
})), })),
}); });
// jsdom implements neither of these; the school detail views use both
// (IntersectionObserver for the scroll-spy and the hero-CTA tracker,
// scrollTo for the "back to top" control).
global.IntersectionObserver = class {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
};
window.scrollTo = jest.fn();
// Mock localStorage // Mock localStorage
const localStorageMock = { const localStorageMock = {
getItem: jest.fn(), getItem: jest.fn(),
+226
View File
@@ -0,0 +1,226 @@
/**
* Derived data-shape logic for the school detail pages.
*
* Pure functions with no React dependency, so the server route can decide
* which sections exist without pulling in a client component. Extracted from
* SchoolDetailView, which owned this logic inline while it was a client
* component.
*/
import type {
School, SchoolResult, AbsenceData, SchoolCensus,
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
} from './types';
import { isSpecialSchool } from './utils';
export interface SchoolFlagsInput {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
census: SchoolCensus | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export interface SchoolFlags {
latestResults: SchoolResult | null;
isAllThrough: boolean;
isSecondary: boolean;
isPrimary: boolean;
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;
}
export interface NavItem {
id: string;
label: string;
}
export function computeSchoolFlags({
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
}: SchoolFlagsInput): SchoolFlags {
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection. All-through schools cover BOTH key stages, so they are
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
const isPrimary = !isSecondary;
// Gender split availability (only meaningful for Mixed schools with census data)
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
const hasGenderSplit = !!(isMixedSchool
&& census?.female_pupils != null
&& census?.male_pupils != null
&& (census.female_pupils + census.male_pupils) > 0);
// Guard for Pupils & Inclusion — only show if at least one metric is available
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|| (latestResults?.eal_pct != null)
|| (latestResults?.sen_support_pct != null)
|| hasGenderSplit;
const hasSchoolLife = absenceData != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
// Determine whether this school has KS2 or KS4 results to show
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results;
// Special schools / PRUs / AP: their pupils sit the same tests but very few
// reach the mainstream "expected standard", so a 0% headline and an England
// comparison portray them as failing against a benchmark that doesn't fit.
const isSpecial = isSpecialSchool(schoolInfo);
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
// special/suppressed signature) is a placeholder, not a real result. This
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
// combined (some pupils met individual subjects but not all three) stays
// comparable. Attainment 8 is a single 080 score with no subject breakdown
// to form such a signature, so KS4 keys off establishment type only — a
// genuine (if extreme) 0.0 still shows its real figure and comparison.
const ks2Placeholder = latestResults != null
&& latestResults.rwm_expected_pct === 0
&& (latestResults.reading_expected_pct ?? 0) === 0
&& (latestResults.writing_expected_pct ?? 0) === 0
&& (latestResults.maths_expected_pct ?? 0) === 0;
// Whether to drop the England-average deltas / national markers / "below"
// framing on the attainment measures.
const suppressKs2Comparison = isSpecial || ks2Placeholder;
const suppressKs4Comparison = isSpecial;
return {
latestResults,
isAllThrough, isSecondary, isPrimary,
hasGenderSplit, hasInclusionData, hasSchoolLife,
hasDeprivation, hasFinance, hasLocation,
hasKS2Results, hasKS4Results, hasAnyResults,
isSpecial, ks2Placeholder,
suppressKs2Comparison, suppressKs4Comparison,
};
}
export interface NavItemsInput {
ofsted: OfstedInspection | null;
admissions: SchoolAdmissions | null;
yearlyDataLength: number;
}
/**
* Build section nav items dynamically — only sections with data.
* Order is engagement-led (from section_nav_used analytics): the most-sought
* sections — results, admissions, inclusion, history — sit near the top,
* after the recognised Ofsted badge; low-demand context sections stay last.
*
* These conditions MUST match the conditions the section composers use to
* render, or the nav will link to sections that do not exist.
*/
export function buildNavItems(
flags: SchoolFlags,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasAnyResults) {
navItems.push({
id: 'results',
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
});
}
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
return navItems;
}
// ---------------------------------------------------------------------------
// Secondary pages
//
// The secondary view is not a variant of the primary one: it has its own
// section ids ('gcse', 'wellbeing'), its own flags, and gates History on
// MORE THAN ONE year rather than at least one. Kept as separate functions so
// neither phase's behaviour bends to accommodate the other.
// ---------------------------------------------------------------------------
export interface SecondaryFlags {
latestResults: SchoolResult | null;
hasSixthForm: boolean;
hasFinance: boolean;
hasDeprivation: boolean;
hasLocation: boolean;
hasWellbeing: boolean;
hasResults: boolean;
/** Progress 8 was suspended from the 2024/25 cohort onwards. */
p8Suspended: boolean;
isSpecial: boolean;
suppressComparison: boolean;
}
export function computeSecondaryFlags({
schoolInfo, yearlyData, deprivation, finance,
}: Omit<SchoolFlagsInput, 'absenceData' | 'census'>): SecondaryFlags {
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null;
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
// so their headline attainment is far below the mainstream average by design.
// Drop the England comparison + "below" framing so the page doesn't portray
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
// single 080 score with no subject breakdown to test for a placeholder, so
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
// mainstream school still shows its real value and comparison.
const isSpecial = isSpecialSchool(schoolInfo);
const suppressComparison = isSpecial;
return {
latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation,
hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison,
};
}
/**
* Build nav items for a secondary page.
* Engagement-led order (matches the primary page): recognised Ofsted badge,
* then the most-sought sections — results, admissions, history — with the
* experience and context sections following.
*/
export function buildSecondaryNavItems(
flags: SecondaryFlags,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
return navItems;
}
+2 -23
View File
@@ -21,7 +21,6 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"swr": "^2.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -4150,7 +4149,9 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -9154,19 +9155,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swr": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz",
"integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -9627,15 +9615,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
-1
View File
@@ -26,7 +26,6 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"swr": "^2.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },