Compare commits

..
10 Commits
Author SHA1 Message Date
TudorandClaude Opus 5 ec2d12478e test(e2e): cover the detail page server/client boundary
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 14s
PR Checks / Build Frontend (no push) (pull_request) Successful in 53s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m23s
Two journeys for what the split actually changed: the admissions toggle (the
one client island inside an otherwise server-rendered section) and the sticky
nav resolving to server-rendered sections. The nav is client-rendered from a
server-computed list while the sections render on the server, so a mismatch
between the two halves would only show up in a real browser.

Existing journeys already cover all-through and special-school pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:41:18 +01:00
TudorandClaude Opus 5 4c229aec6e 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>
2026-08-02 21:39:59 +01:00
TudorandClaude Opus 5 752eb07310 refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:39:21 +01:00
TudorandClaude Opus 5 a7f6ff4035 refactor(detail): extract sections as server components
Moves ~1,300 lines of section markup out of the two client views into
components/school/, mirroring the components/compare/ layout. Twelve section
components plus shared primitives, all server components. The only client
file is AdmissionsViewToggle, which owns the hidden/aria-pressed state and
receives both views as server-rendered children.

JSX was extracted mechanically rather than retyped, so the markup the CSS
modules depend on is verbatim.

Sharing follows measured similarity, not assumption:
- Finances (91%) shared. The secondary premises-cost card is gated behind a
  prop so primary pages are unchanged; enabling it is a one-line follow-up.
- Ofsted (80%) shared, but behind a variant prop. The headline similarity hid
  a real fork: on a school with no overall grade the primary page shows a
  "Not rated" badge while the secondary shows a four-area OEIF panel, and the
  disclaimer copy differs. Both preserved exactly; reconciling them is a
  human decision, not a side effect of a move.
- Admissions (14%) and History (40%) kept separate.

Not yet wired up -- the old views still render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:33:28 +01:00
TudorandClaude Opus 5 2f786787b9 style(detail): merge the two detail stylesheets
Builds components/school/schoolSections.module.css from the primary module,
plus 35 secondary-only rules, so shared section components have one stylesheet.

Three categories of difference, handled separately so nothing changes visually:

- 7 incidental-drift properties unioned in (overflow-wrap, word-break,
  min-width, user-select) -- fixes that landed on one page and were never
  back-ported. Also fixes latent long-school-name overflow on the page that
  lacked them.
- 4 genuinely visual class differences kept as explicit variants:
  genderBar, heroStatValue, genderSplitBoys, genderSplitGirls. font-weight on
  the genderSplit pair was reclassified from drift to visual -- unioning it
  would have bolded the primary page.
- 17 rules that exist only in the secondary stylesheet but target shared class
  names (.card, .sectionTitle, .metricCard). Applied flat these would restyle
  the primary page, so they are scoped under .secondaryScope, which only
  SecondarySchoolSections will carry.

Audited: every rule in both original stylesheets is a subset of the merged one
(0 MISSING, 0 LOST), excluding the documented variants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:18:15 +01:00
TudorandClaude Opus 5 5044c24895 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 without importing them.

The secondary page is not a variant of the primary one -- different section
ids (gcse, wellbeing), different flags, and History gated on more than one
year rather than at least one -- so it gets its own computeSecondaryFlags and
buildSecondaryNavItems rather than bending a shared function.

Adds 16 unit tests covering the all-through and special-school branches,
previously reachable only through a full component render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:14:35 +01:00
TudorandClaude Opus 5 d74cc95034 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 every section that
uses them was pinned to the client. The page now fetches it in parallel with
the school details (backend-cached 1h, degrades to null) and passes it down.

Removes one client round-trip per detail page and unblocks the section
extraction.

Characterization tests pass unmodified; only the render helper changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:11:08 +01:00
TudorandClaude Opus 5 7c43a1baaf test: pin school detail behaviour before refactor
14 characterization tests covering Ofsted (both OEIF and Report Card layouts),
KS2 results with the England delta, KS4 Attainment 8 / Progress 8 / EBacc,
all-through dual rendering, special-school comparison suppression (PR #70),
the admissions year/trend toggle, and conditional section rendering.

All rendering goes through renderSchoolDetail(), the single seam the
server/client split is allowed to change. The assertions must survive the
refactor unmodified.

Excludes __tests__/support from testMatch: it holds fixtures and helpers, not
suites, and the glob was failing them as empty test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:09:18 +01:00
TudorandClaude Opus 5 bcf3498086 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.

SchoolResult carries 57 required nullable fields, so fixtures are built from
an all-null base row and override only what they exercise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:06:14 +01:00
TudorandClaude Opus 5 7199c3a90b docs(perf): implementation plan for detail-page server/client split
Nine tasks mapping to the spec's commit sequence, each ending in an
independently testable deliverable.

Also corrects the spec's section-sharing table: measured similarity shows
Admissions (14%) and History (40%) are not shareable between the two views,
only Ofsted (80%) and Finances (91%). This costs no bytes -- the win comes
from sections being server components, not from sharing them -- and cuts the
diff and regression risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:52:56 +01:00
40 changed files with 5532 additions and 3681 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
@@ -100,12 +100,32 @@ shell but to stop the other ~750 lines being attached to it.
### Section sharing ### Section sharing
| Shared by both views | Primary only | Secondary only | Sharing is decided by measured similarity, not assumption. Comparing the two
| --- | --- | --- | views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
| Ofsted, Admissions, History, Finances, and the shell | Results (KS2), Inclusion, School Life, Local Area | Wellbeing |
All-through schools render both the KS2 and KS4 section sets. The shared library | Section | Primary | Secondary | Similarity | Decision |
makes this natural rather than duplicated. | --- | --- | --- | --- | --- |
| 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 ### CSS strategy
+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,77 @@
/**
* 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
{...fixture}
nationalAvg={nationalAveragesFixture}
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
{...fixture}
nationalAvg={nationalAveragesFixture}
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,
};
+83 -20
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,18 +215,7 @@ 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}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
) : (
<SchoolDetailView
schoolInfo={school_info} schoolInfo={school_info}
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
@@ -205,7 +225,50 @@ 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}
navItems={secondaryNavItems}
>
<SecondarySchoolSections
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
flags={secondaryFlags}
/>
</SchoolDetailShell>
) : (
<SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
navItems={primaryNavItems}
>
<PrimarySchoolSections
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
deprivation={deprivation ?? 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,512 @@
/**
* SchoolDetailShell — the interactive chrome of a school detail page.
*
* The ONLY large client component on the route. Everything below the sticky
* nav is server-rendered and arrives as `children`, composed in
* app/school/[slug]/page.tsx. That indirection is required: a server component
* imported by a client component becomes a client component, so the sections
* cannot be imported here.
*
* What stays client-side is genuinely interactive: router.back(), the header
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
* sections with document.getElementById, so it works unchanged against
* server-rendered children.
*/
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas,
} from '@/lib/utils';
import { computeSchoolFlags, type NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
export interface SchoolDetailShellProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
/** Fetched on the server so the England-comparison deltas are in the
* initial HTML; null when the endpoint is unavailable. */
nationalAvg: NationalAverages | null;
/** Section list for the sticky nav, computed on the server. */
navItems: NavItem[];
/** The server-rendered sections. */
children: ReactNode;
}
export function SchoolDetailShell({
schoolInfo, yearlyData, absenceData,
ofsted, census, admissions, admissionsHistory, deprivation, finance,
nationalAvg, navItems, children,
}: SchoolDetailShellProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
// Admissions view state moved to AdmissionsViewToggle, the client island
// inside the (server-rendered) admissions section.
// Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
const heroActionsRef = useRef<HTMLDivElement | null>(null);
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
// Back returns to wherever the user came from; deep-links (no in-app history)
// fall back to search so the button never dead-ends or leaves the site.
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push('/search');
}
};
const scrollToTop = () => {
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
const el = sectionLinksRef.current;
if (!el) return;
const update = () => {
const overflow = el.scrollWidth - el.clientWidth;
// No overflow → treat as "at end" so the fade is hidden.
if (overflow <= 1) {
setSectionNavAtEnd(true);
return;
}
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, []);
// Track whether the hero's "Add to Compare" button is still on screen.
useEffect(() => {
const el = heroActionsRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => setHeroCtaVisible(entry.isIntersecting),
{ rootMargin: '-64px 0px 0px 0px' },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
// Close the "All ▾" menu on Escape.
useEffect(() => {
if (!sectionsOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
// Derived data-shape logic lives in lib/schoolSections so the server route
// can compute the section list without importing this client component.
const flags = computeSchoolFlags({
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
});
const {
latestResults, isAllThrough, isSecondary, isPrimary,
hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation,
hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults,
isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
} = flags;
const phase = schoolInfo.phase ?? '';
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Page-view event with funnel attribution. Fires once per mount.
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: phase || 'unknown',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
const deprivationDesc = (decile: number) => {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
};
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<string, number> = {};
const pickActive = () => {
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
setActiveSection(top?.[1] > 0 ? top[0] : '');
};
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
ratioMap[id] = 0;
const obs = new IntersectionObserver(
([entry]) => {
ratioMap[id] = entry.intersectionRatio;
pickActive();
},
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach(o => o.disconnect());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// A report card is dated by its own inspection (rc_inspection_date); the
// legacy inspection_date belongs to an older inspection and must never
// date a report card (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
return (
<div className={styles.container}>
{/* Standalone back link, above the header — returns to wherever the
user came from. Scrolls away with the page (the sticky bar keeps a
"back to top" control in its place). */}
<button type="button" onClick={handleBack} className={styles.topBack}>
<span aria-hidden="true"></span> Back
</button>
{/* Header — the location map band blends down into the school title. */}
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
{hasLocation && (
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
)}
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{hasLocation && (
<>
{' · '}
<button
type="button"
className={styles.mapLink}
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
>
View on map
</button>
</>
)}
</p>
)}
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
)}
{(() => {
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions} ref={heroActionsRef}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
>
{/* On phones the map hero shows only the glyph (nav-bar style). */}
<span className={styles.btnCompareLabel}>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</span>
<span className={styles.btnCompareGlyph} aria-hidden="true">
{isInComparison ? '✓' : '+'}
</span>
</button>
</div>
</div>
</header>
{/* Sticky Section Navigation — docks under the global header */}
<nav className={styles.sectionNav} aria-label="Page sections">
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
<span aria-hidden="true"></span>
<span className={styles.sectionNavBackLabel}>Top</span>
</button>
{/* Desktop: scrolling section links */}
<div
ref={sectionLinksRef}
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
))}
</div>
{/* Mobile: a single "section" menu button that opens the jump sheet */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavMenu}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
<span className={styles.sectionNavMenuCur}>
<span className={styles.sectionNavMenuEyebrow}>Section</span>
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
</span>
<span className={styles.sectionNavMenuChev} aria-hidden="true"></span>
</button>
)}
{/* The hero's Compare CTA, carried in once it scrolls out of view.
Desktop shows a labelled pill; mobile a compact icon. */}
{!heroCtaVisible && (
<>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
>
{isInComparison ? '✓ Comparing' : '+ Compare'}
</button>
<button
onClick={handleComparisonToggle}
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
title={isInComparison ? 'In comparison' : 'Add to compare'}
>
{isInComparison ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12 5 5 9-11" />
</svg>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M4 7h13l-3-3" />
<path d="M20 17H7l3 3" />
</svg>
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
</>
)}
</button>
</>
)}
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
{navItems.length > 0 && (
<button
type="button"
className={styles.sectionNavAll}
aria-haspopup="menu"
aria-expanded={sectionsOpen}
onClick={() => setSectionsOpen((o) => !o)}
>
All <span aria-hidden="true"></span>
</button>
)}
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
{sectionsOpen && (
<>
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
<div className={styles.sectionsPanelHead}>Jump to section</div>
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
role="menuitem"
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
onClick={() => {
setSectionsOpen(false);
track('section_nav_used', { section: id, via: 'all_menu' });
}}
>
<span>{label}</span>
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true"></span>}
</a>
))}
</div>
</>
)}
</nav>
{children}
</div>
);
}
@@ -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,7 +1216,8 @@
.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
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full "Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
@@ -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"
}, },