Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
803e68970c | ||
|
|
ec2d12478e | ||
|
|
4c229aec6e | ||
|
|
752eb07310 | ||
|
|
a7f6ff4035 | ||
|
|
2f786787b9 | ||
|
|
5044c24895 | ||
|
|
d74cc95034 | ||
|
|
7c43a1baaf | ||
|
|
bcf3498086 | ||
|
|
7199c3a90b | ||
|
|
97dd69a512 | ||
|
|
d1c92ea7d5 | ||
|
|
cab7b4fe91 |
@@ -0,0 +1,950 @@
|
||||
# Detail-page Server/Client Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop ~750 lines of static school-detail markup from shipping to the browser as JavaScript, by turning the page's sections into React Server Components behind a small client shell.
|
||||
|
||||
**Architecture:** A server component imported by a client component becomes a client component. So the sections cannot be children of a client `SchoolDetailView`. Instead `app/school/[slug]/page.tsx` (server) composes the sections and passes them *through* a small client shell as `children` — the pattern already used at `app/page.tsx:98-99`. The shell keeps the genuinely interactive chrome (back link, header reveal, hero map, compare CTA, sticky nav, scroll-spy); everything below it becomes server-rendered.
|
||||
|
||||
**Tech Stack:** Next.js 16.1.6 (App Router, Turbopack), React 19, TypeScript, CSS Modules, Jest + React Testing Library, Playwright (e2e).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-30-server-client-split-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch is `perf/server-client-split`, already created off `origin/main` at `cab7b4f`. Never push to `main`; this lands as a PR (CLAUDE.md SDLC).
|
||||
- All paths below are relative to the repo root. The Next.js app lives in `nextjs-app/`; run all `npm` commands from there.
|
||||
- **Do not start a local server to test** — it does not work in this environment (CLAUDE.md). Verification is `npm run typecheck`, `npm test`, `npm run build`.
|
||||
- **No visual or behavioural change** on any of the four school shapes, except the two documented CSS merges. No redesign, no copy changes.
|
||||
- Commits 6 and 7 are **pure moves**: JSX is relocated verbatim, changing only the `styles` import and prop plumbing. If you find a bug mid-move, record it in the PR description — do not fix it inline.
|
||||
- Characterization tests written in Task 2 must pass **unmodified** through Tasks 3-8. The only file permitted to change is `__tests__/support/renderSchoolDetail.tsx`.
|
||||
- End commit messages with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## What is shared and what is not
|
||||
|
||||
Measured similarity between the two views' sections (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary lines | Secondary lines | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Ofsted | 88 | 107 | 80% | **Shared** — `OfstedSection` |
|
||||
| Finances | 29 | 35 | 91% | **Shared** — `FinancesSection` |
|
||||
| History | 121 | 47 | 40% | Separate components |
|
||||
| Admissions | 89 | 55 | 14% | Separate components |
|
||||
|
||||
The spec's section-sharing table listed Admissions and History as shared; measurement shows they are not. **This costs no bytes** — the client-JS win comes from a section being a *server* component, not from being shared. Sharing is a maintainability bonus, taken only where it is cheap.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create — `nextjs-app/lib/`**
|
||||
- `schoolSections.ts` — pure derived flags (`hasInclusionData`, `hasKS2Results`, `suppressKs2Comparison`, …) and `buildNavItems()`. No JSX, no React. Unit-testable in isolation; consumed by `page.tsx` and the sections.
|
||||
|
||||
**Create — `nextjs-app/components/school/`** (mirrors the existing `components/compare/` layout)
|
||||
- `schoolSections.module.css` — merged stylesheet (Task 5)
|
||||
- `sectionShared.tsx` — `Section`, `MetricGrid`, `MetricCard`, `StatRow` primitives, all server
|
||||
- `OfstedSection.tsx`, `FinancesSection.tsx` — shared, server
|
||||
- `ResultsSection.tsx`, `AdmissionsSection.tsx`, `InclusionSection.tsx`, `HistorySection.tsx`, `SchoolLifeSection.tsx`, `LocalAreaSection.tsx` — primary, server
|
||||
- `GcseSection.tsx`, `SecondaryAdmissionsSection.tsx`, `SecondaryHistorySection.tsx`, `WellbeingSection.tsx` — secondary, server
|
||||
- `AdmissionsViewToggle.tsx` — **the only new client component**
|
||||
- `PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` — server composers
|
||||
- `SchoolDetailShell.tsx` + `SchoolDetailShell.module.css` — the client shell
|
||||
|
||||
**Create — `nextjs-app/__tests__/`**
|
||||
- `support/schoolFixtures.ts` — four fixtures: primary, secondary, all-through, special
|
||||
- `support/renderSchoolDetail.tsx` — the swappable render helper
|
||||
- `components/schoolDetail.characterization.test.tsx`
|
||||
- `lib/schoolSections.test.ts`
|
||||
|
||||
**Modify**
|
||||
- `nextjs-app/jest.setup.js` — `IntersectionObserver`, `window.scrollTo`
|
||||
- `nextjs-app/app/school/[slug]/page.tsx` — fetch national averages, compose sections
|
||||
- `nextjs-app/package.json` — drop `swr`
|
||||
- `e2e/tests/journeys.spec.ts` — detail-page assertions
|
||||
|
||||
**Deliberately untouched**
|
||||
- `components/Navigation.tsx` — genuinely needs client (`usePathname` plus the comparison count). No task.
|
||||
- `components/Footer.tsx` — already a server component; no `'use client'` directive. No task.
|
||||
- `app/layout.tsx` — unchanged. The shared ~172 KB baseline is out of scope; see Task 9 Step 4.
|
||||
|
||||
**Delete**
|
||||
- `nextjs-app/components/SchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/components/SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Test environment and fixtures
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/jest.setup.js`
|
||||
- Create: `nextjs-app/__tests__/support/schoolFixtures.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `primaryFixture`, `secondaryFixture`, `allThroughFixture`, `specialFixture` — each an object matching the prop shape of `SchoolDetailView` (`{ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance }`), plus `nationalAveragesFixture` typed `NationalAverages`.
|
||||
|
||||
- [ ] **Step 1: Add the missing jsdom globals**
|
||||
|
||||
jsdom implements neither API; both detail views use `IntersectionObserver` twice and `window.scrollTo` once. Append to `nextjs-app/jest.setup.js`:
|
||||
|
||||
```javascript
|
||||
// jsdom implements neither of these; the detail views use both.
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
};
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the fixtures**
|
||||
|
||||
Create `nextjs-app/__tests__/support/schoolFixtures.ts`. Derive the exact field names from `nextjs-app/lib/types.ts` (`School`, `SchoolResult`, `OfstedInspection`, `SchoolCensus`, `SchoolAdmissions`, `SchoolDeprivation`, `SchoolFinance`, `NationalAverages`) — do not invent fields; TypeScript will reject them.
|
||||
|
||||
The four fixtures must differ in exactly these ways, because these are the branches the characterization tests exercise:
|
||||
|
||||
| Fixture | `schoolInfo.phase` | Results present | Ofsted | Admissions history | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `primaryFixture` | `'Primary'` | `rwm_expected_pct: 58` | legacy OEIF, `overall_effectiveness: 2` | 3 years with `first_preference_offer_pct` | drives the trend toggle |
|
||||
| `secondaryFixture` | `'Secondary'` | `attainment_8_score: 46.2`, `progress_8_score: 0.31` | Report Card (`rc_*` fields set) | 1 year only | no trend toggle |
|
||||
| `allThroughFixture` | `'All-through'` | **both** `rwm_expected_pct` and `attainment_8_score` | legacy OEIF | 2 years | KS2 content gated back on |
|
||||
| `specialFixture` | `'Primary'`, `school_type: 'Community special school'` | `rwm_expected_pct: 0`, reading/writing/maths all `0` | none (`null`) | none (`null`) | England comparison suppressed |
|
||||
|
||||
Every fixture needs `urn` in the valid 6-digit range, a `school_name`, and a `local_authority`. Give `primaryFixture` at least 3 entries in `yearlyData` so the history table and chart have data.
|
||||
|
||||
- [ ] **Step 3: Verify the fixtures typecheck**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. Any error here means a fixture field does not exist on the real type — fix the fixture, never the type.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/jest.setup.js nextjs-app/__tests__/support/schoolFixtures.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: add jsdom globals and school detail fixtures
|
||||
|
||||
jsdom provides neither IntersectionObserver nor scrollTo; both detail views
|
||||
use them. Adds four fixtures (primary, secondary, all-through, special)
|
||||
covering the branches the characterization tests will pin.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Characterization tests
|
||||
|
||||
This is the safety net. **It must be green before any production code moves.**
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Create: `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: fixtures from Task 1.
|
||||
- Produces: `renderSchoolDetail(fixture, opts?)` and `renderSecondarySchoolDetail(fixture, opts?)`. These are the **only** functions the tests call to render. Task 7 rewrites their bodies; their signatures and every assertion stay frozen.
|
||||
|
||||
- [ ] **Step 1: Write the render helper against the CURRENT components**
|
||||
|
||||
Create `nextjs-app/__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* Task 7 rewrites the bodies of these two functions to render the new
|
||||
* shell + server-sections composition. Nothing else in the test suite may
|
||||
* change — assertions passing unmodified across that rewrite is the proof
|
||||
* that behaviour was preserved.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} />);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
return render(<SecondarySchoolDetailView {...fixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the characterization tests**
|
||||
|
||||
Create `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`. Mock the charts and analytics at the top — Chart.js needs a canvas jsdom does not provide, and `track()` fires on mount:
|
||||
|
||||
```tsx
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
}));
|
||||
```
|
||||
|
||||
Then cover, using `renderSchoolDetail` / `renderSecondarySchoolDetail` only:
|
||||
|
||||
1. **Ofsted, legacy OEIF** (`primaryFixture`) — the grade label `Good` renders; the inspected date renders; the `reports.ofsted.gov.uk` link carries the fixture's URN in its `href`.
|
||||
2. **Ofsted, Report Card** (`secondaryFixture`) — heading reads `Ofsted Report Card`; the November 2025 disclaimer renders; each populated `rc_*` category renders its label and band.
|
||||
3. **KS2 results + England delta** (`primaryFixture`) — the RWM percentage renders; a delta versus the England average renders.
|
||||
4. **KS4 results** (`secondaryFixture`) — Attainment 8 and Progress 8 values render; EBacc rows render.
|
||||
5. **All-through** (`allThroughFixture`) — **both** a KS2 figure and an Attainment 8 figure render in the same document. This is the highest-value assertion in the file; it is the case most likely to break.
|
||||
6. **Special school** (`specialFixture`) — no England-comparison delta renders, and the special-school note renders. Guards the PR #70 regression.
|
||||
7. **Admissions toggle** (`primaryFixture`) — both buttons render; the trend view starts `hidden`; after `userEvent.click` on the trend button the trend view is visible and the year view is `hidden`. Assert on the `hidden` attribute, matching how the current code toggles.
|
||||
8. **Admissions without trend** (`secondaryFixture`, 1 year) — no toggle buttons render.
|
||||
9. **Nav items** (`primaryFixture`) — the expected section ids are present in the document; and for `specialFixture` (no ofsted, no admissions) those two are absent.
|
||||
|
||||
Use `getByText` / `queryByText` / `getByRole` — never snapshots, and never assert on CSS-module class names, which are hashed and will change.
|
||||
|
||||
- [ ] **Step 3: Run the tests and confirm they PASS**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS.** This is the inverse of normal TDD — these tests describe behaviour that already exists, so green means they correctly pin the current behaviour. If one fails, the test is wrong, not the component. Fix the test.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/__tests__/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: pin school detail behaviour before refactor
|
||||
|
||||
Characterization tests covering Ofsted (both layouts), KS2/KS4 results,
|
||||
all-through dual rendering, special-school comparison suppression, and the
|
||||
admissions toggle. All rendering goes through renderSchoolDetail(), the one
|
||||
seam that the server/client split is allowed to change.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move national averages to the server
|
||||
|
||||
The prerequisite for everything after it: `primaryAvg` / `secondaryAvg` feed the England deltas across most sections, and while they arrive via `useEffect` those sections cannot be server components.
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/components/SchoolDetailView.tsx:173-179`
|
||||
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:91-93`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `fetchNationalAverages()` from `@/lib/api` (already exists, `revalidate: 3600`).
|
||||
- Produces: both view components gain a required prop `nationalAvg: NationalAverages | null`.
|
||||
|
||||
- [ ] **Step 1: Fetch it in parallel in the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the lone `fetchSchoolDetails` call in the default export with a parallel pair. Import `fetchNationalAverages` alongside the existing imports. National averages must never break the page, so it degrades to `null`:
|
||||
|
||||
```tsx
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
```
|
||||
|
||||
Leave `generateMetadata`'s own `fetchSchoolDetails(urn)` call alone — Next.js memoises identical `fetch` calls within a render pass, so it does not cost a second request.
|
||||
|
||||
- [ ] **Step 2: Pass it to both views**
|
||||
|
||||
Add `nationalAvg={nationalAvg}` to both the `<SecondarySchoolDetailView>` and `<SchoolDetailView>` elements at the bottom of the page.
|
||||
|
||||
- [ ] **Step 3: Accept the prop and delete the useEffect**
|
||||
|
||||
In `components/SchoolDetailView.tsx`: add `nationalAvg: NationalAverages | null;` to `SchoolDetailViewProps`, add `nationalAvg` to the destructured parameters, and delete lines 172-179 — the `useState` and the `useEffect` that fetches `/api/national-averages`. The two lines that follow it stay exactly as they are:
|
||||
|
||||
```tsx
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
```
|
||||
|
||||
Apply the identical change to `components/SecondarySchoolDetailView.tsx` at lines 91-93.
|
||||
|
||||
- [ ] **Step 4: Update the render helper to supply the prop**
|
||||
|
||||
In `__tests__/support/renderSchoolDetail.tsx`, spread `nationalAveragesFixture` in:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
This is a permitted change — the helper is the designated seam. The test file itself must not change.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test -- schoolDetail.characterization`
|
||||
Expected: both PASS, with the characterization test file unmodified.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/app/school nextjs-app/components/SchoolDetailView.tsx \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/__tests__/support/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): fetch national averages on the server
|
||||
|
||||
Both detail views fetched /api/national-averages in a useEffect, so the
|
||||
England-comparison deltas popped in after hydration and the sections that
|
||||
use them could not become server components. The page now fetches it in
|
||||
parallel with the school details (backend-cached 1h) and passes it down.
|
||||
|
||||
Removes one client round-trip per detail page.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract the derived-flags module
|
||||
|
||||
Pulls the pure data-shape logic out of the components so `page.tsx` can compute `navItems` on the server without importing a client component.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/lib/schoolSections.ts`
|
||||
- Create: `nextjs-app/__tests__/lib/schoolSections.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: types from `@/lib/types`; `isSpecialSchool` from `@/lib/utils`.
|
||||
- Produces:
|
||||
- `type SchoolFlags = { isAllThrough: boolean; isSecondary: boolean; isPrimary: boolean; latestResults: SchoolResult | null; hasGenderSplit: boolean; hasInclusionData: boolean; hasSchoolLife: boolean; hasDeprivation: boolean; hasFinance: boolean; hasLocation: boolean; hasKS2Results: boolean; hasKS4Results: boolean; hasAnyResults: boolean; isSpecial: boolean; ks2Placeholder: boolean; suppressKs2Comparison: boolean; suppressKs4Comparison: boolean }`
|
||||
- `computeSchoolFlags(input): SchoolFlags` where `input` is `{ schoolInfo, yearlyData, absenceData, census, deprivation, finance }`
|
||||
- `type NavItem = { id: string; label: string }`
|
||||
- `buildNavItems(flags: SchoolFlags, opts: { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; yearlyDataLength: number }): NavItem[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `nextjs-app/__tests__/lib/schoolSections.test.ts` covering the branches that matter:
|
||||
|
||||
```ts
|
||||
import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections';
|
||||
import { primaryFixture, allThroughFixture, specialFixture } from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const primary = computeSchoolFlags(primaryFixture);
|
||||
const items = buildNavItems(primary, {
|
||||
ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
});
|
||||
expect(items.find((n) => n.id === 'results')?.label).toBe('SATs');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: FAIL — `Cannot find module '@/lib/schoolSections'`.
|
||||
|
||||
- [ ] **Step 3: Implement the module**
|
||||
|
||||
Create `nextjs-app/lib/schoolSections.ts` by moving the logic verbatim from `components/SchoolDetailView.tsx` lines **211-267**. Lines 205-209 are the `deprivationDesc` helper — leave it where it is; Task 6 moves it into `LocalAreaSection`. Also bring `latestResults` (line 160). Preserve the comments — they explain the special-school and all-through reasoning and are load-bearing for future readers. The `navItems` push order must stay exactly as it is at lines 258-267; it is engagement-ordered from analytics.
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Consume it from both views**
|
||||
|
||||
Replace the inline flag computations in `SchoolDetailView.tsx` and `SecondarySchoolDetailView.tsx` with a call to `computeSchoolFlags` / `buildNavItems`, destructuring the flags so the JSX below is untouched.
|
||||
|
||||
- [ ] **Step 6: Verify nothing regressed**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test`
|
||||
Expected: all PASS, characterization tests unmodified.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.test.ts \
|
||||
nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract derived flags and nav items to lib
|
||||
|
||||
Pure data-shape logic moves out of the client components so page.tsx can
|
||||
compute the section list on the server. Adds direct unit coverage for the
|
||||
all-through and special-school branches, which were previously only
|
||||
reachable through a full component render.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Merge the stylesheets
|
||||
|
||||
Isolated so that a visual regression bisects to the CSS merge rather than to a JSX move. Highest-risk task in the plan; no test catches a mistake here.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/schoolSections.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: a stylesheet exporting every class name used by section markup in either view, plus `genderBarSecondary` and `heroStatValueSecondary` variant classes.
|
||||
|
||||
- [ ] **Step 1: Copy the primary stylesheet as the base**
|
||||
|
||||
```bash
|
||||
mkdir -p nextjs-app/components/school
|
||||
cp nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/school/schoolSections.module.css
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the secondary-only classes**
|
||||
|
||||
33 class names exist only in `SecondarySchoolDetailView.module.css`. Append their rules unchanged. Confirm the list:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
c=lambda p: set(re.findall(r'\.([a-zA-Z][\w-]*)', open(p).read()))
|
||||
a=c('components/SchoolDetailView.module.css'); b=c('components/SecondarySchoolDetailView.module.css')
|
||||
print('\n'.join(sorted(b-a)))
|
||||
PY
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Union the 14 incidental-drift differences**
|
||||
|
||||
For these classes, add any property present in the secondary rule but missing from the primary rule. All are defensive overflow properties — `min-width: 0`, `overflow-wrap: break-word`, `word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. Never remove a property the primary rule already has.
|
||||
|
||||
Affected: `.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, `.metricsGrid`, `.tableWrapper`, `.heroStatCard`, `.heroStatLabel`, `.historyToggle`, `.ofstedReportLink`, `.genderSplitBoys`, `.genderSplitGirls`, `.genderSplitSep`, and the remaining drift class from the audit in Step 5.
|
||||
|
||||
- [ ] **Step 4: Add variant classes for the two real differences**
|
||||
|
||||
Keep the primary look on the base class and add the secondary look as a separate class:
|
||||
|
||||
```css
|
||||
/* Secondary detail pages render a slimmer gender bar on a translucent track.
|
||||
Kept as an explicit variant rather than reconciled, so the divergence is
|
||||
deliberate instead of accidental drift. */
|
||||
.genderBarSecondary {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* Secondary hero stats sit tighter and omit the Georgia fallback. */
|
||||
.heroStatValueSecondary {
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Audit the merge**
|
||||
|
||||
Every shared class must now be a superset of both originals, except the two variants:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
def rules(p):
|
||||
out={}
|
||||
for m in re.finditer(r'\.([a-zA-Z][\w-]*)\s*\{([^{}]*)\}', open(p).read()):
|
||||
out.setdefault(m.group(1),[]).append({x.strip() for x in
|
||||
re.sub(r'\s+',' ',m.group(2)).strip().rstrip(';').split(';') if x.strip()})
|
||||
return out
|
||||
m=rules('components/school/schoolSections.module.css')
|
||||
for src in ['components/SchoolDetailView.module.css','components/SecondarySchoolDetailView.module.css']:
|
||||
for name, bodies in rules(src).items():
|
||||
if name in ('genderBar','heroStatValue'): continue
|
||||
if name not in m: print(f"MISSING .{name} (from {src})"); continue
|
||||
missing = bodies[0] - m[name][0]
|
||||
if missing: print(f"LOST .{name}: {missing} (from {src})")
|
||||
print("audit complete")
|
||||
PY
|
||||
```
|
||||
Expected: no `MISSING` or `LOST` lines before `audit complete`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/schoolSections.module.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
style(detail): merge the two detail stylesheets
|
||||
|
||||
Unions the 14 incidental-drift differences between the primary and secondary
|
||||
modules -- defensive overflow properties (min-width, overflow-wrap,
|
||||
max-width) that were fixed on one page and never back-ported. Keeps the two
|
||||
genuine visual differences, .genderBar and .heroStatValue, as explicit
|
||||
variant classes.
|
||||
|
||||
No intended visual change. Isolated so a regression bisects here rather than
|
||||
to a JSX move.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Extract the server sections
|
||||
|
||||
The bulk of the work, and a **pure move**. Every section below becomes a server component: no `'use client'`, no hooks, no event handlers.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/sectionShared.tsx`
|
||||
- Create: the 12 section files listed in File Structure
|
||||
- Create: `nextjs-app/components/school/AdmissionsViewToggle.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SchoolFlags` and `NavItem` from `@/lib/schoolSections`; `schoolSections.module.css`.
|
||||
- Produces: each section is a named export taking exactly the props its JSX reads. `AdmissionsViewToggle` is the only `'use client'` file created here, with props `{ title: ReactNode; subtitle: ReactNode; trendLabel: string; yearView: ReactNode; trendView: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Extract the shared primitives**
|
||||
|
||||
Create `sectionShared.tsx` mirroring `components/compare/sectionShared.tsx`. Export server components `Section` (renders `<section id className={styles.card}>`), `SectionTitle`, `MetricGrid`, `MetricCard`. Re-export the stylesheet as `sectionStyles`, matching the compare module's convention.
|
||||
|
||||
- [ ] **Step 2: Move the shared sections**
|
||||
|
||||
These are the only two sections where you are merging two implementations rather than moving one, so the reconciliation rule matters. **Start from the primary version and add only what the secondary version has that the primary lacks.** Diff them first so you are working from the actual delta rather than reading both in full:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && diff <(sed -n '598,687p' components/SchoolDetailView.tsx | sed 's/^[ \t]*//') \
|
||||
<(sed -n '395,502p' components/SecondarySchoolDetailView.tsx | sed 's/^[ \t]*//')
|
||||
```
|
||||
|
||||
For every line the secondary has and the primary does not, decide one of two things and write the decision as a code comment: either it is **phase-specific** (gate it on a prop — e.g. `showSixthForm`), or it is **an improvement the primary never received** (keep it unconditionally). Never delete a branch the primary version has. If a difference is neither — a genuine behavioural conflict — stop and report it rather than guessing.
|
||||
|
||||
- `OfstedSection.tsx` — primary `SchoolDetailView.tsx:598-687`, secondary `SecondarySchoolDetailView.tsx:395-502` (80% similar). Props: `{ ofsted, urn, isReportCard, ofstedInspectedDate, oeifAllSameGrade, oeifAreas }`. Note `RC_CATEGORIES` (lines 44-53) includes `rc_sixth_form`, which only ever populates for secondary — it is already null-guarded, so it needs no prop.
|
||||
- `FinancesSection.tsx` — primary `SchoolDetailView.tsx:1314-1344`, secondary `SecondarySchoolDetailView.tsx:937-973` (91% similar). Same rule; run the equivalent diff.
|
||||
|
||||
- [ ] **Step 3: Move the primary-only sections**
|
||||
|
||||
Verbatim moves, changing only the `styles` import to `schoolSections.module.css` and replacing closed-over variables with props:
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `ResultsSection.tsx` | `SchoolDetailView.tsx:688-959` | `latestResults, flags, primaryAvg, secondaryAvg, schoolInfo` |
|
||||
| `AdmissionsSection.tsx` | `SchoolDetailView.tsx:960-1051` | `admissions, admissionsHistory, admissionsSummary, isAllThrough` |
|
||||
| `InclusionSection.tsx` | `SchoolDetailView.tsx:1052-1135` | `latestResults, census, hasGenderSplit` |
|
||||
| `HistorySection.tsx` | `SchoolDetailView.tsx:1136-1256` | `yearlyData, flags` |
|
||||
| `SchoolLifeSection.tsx` | `SchoolDetailView.tsx:1257-1289` | `absenceData` |
|
||||
| `LocalAreaSection.tsx` | `SchoolDetailView.tsx:1290-1313` | `deprivation` — bring `deprivationDesc` (lines 205-209) with it |
|
||||
|
||||
- [ ] **Step 4: Move the secondary-only sections**
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `GcseSection.tsx` | `SecondarySchoolDetailView.tsx:503-732` | `latestResults, flags, secondaryAvg, schoolInfo` |
|
||||
| `SecondaryAdmissionsSection.tsx` | `SecondarySchoolDetailView.tsx:733-792` | `admissions` |
|
||||
| `SecondaryHistorySection.tsx` | `SecondarySchoolDetailView.tsx:793-839` | `yearlyData, flags` |
|
||||
| `WellbeingSection.tsx` | `SecondarySchoolDetailView.tsx:840-936` | `absenceData, census` — pass `variant="secondary"` to select `genderBarSecondary` |
|
||||
|
||||
- [ ] **Step 5: Build the admissions toggle island**
|
||||
|
||||
The current code renders both views always and toggles the `hidden` attribute, so the island only owns `hidden` and `aria-pressed`. The buttons sit inside `.admissionsHeader` beside the `<h2>` while the viewport is a sibling below, so the component must span both to preserve the DOM exactly.
|
||||
|
||||
Create `nextjs-app/components/school/AdmissionsViewToggle.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
/**
|
||||
* The only interactive part of the admissions section. Both views are always
|
||||
* in the DOM (matching the previous behaviour) and this toggles `hidden`, so
|
||||
* the server-rendered markup passed in as yearView/trendView never ships as
|
||||
* client JavaScript.
|
||||
*/
|
||||
export function AdmissionsViewToggle({
|
||||
title, subtitle, trendLabel, yearView, trendView,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>{yearView}</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>{trendView}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `AdmissionsSection.tsx`, when `showAdmissionsTrend` is false, render the header and year view directly with **no** client component — those schools then ship zero admissions JavaScript.
|
||||
|
||||
- [ ] **Step 6: Verify it compiles**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. The old views still exist and still render; nothing is wired up yet.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract sections as server components
|
||||
|
||||
Moves the section markup out of the two client views into
|
||||
components/school/, mirroring the components/compare/ layout. Sections are
|
||||
server components; the only new client file is AdmissionsViewToggle, which
|
||||
owns the hidden/aria-pressed state and receives both views as server-rendered
|
||||
children.
|
||||
|
||||
Ofsted and Finances are shared between phases (80% and 91% similar);
|
||||
Admissions and History are not (14% and 40%) and stay separate.
|
||||
|
||||
Pure move -- no logic or markup changes. Not yet wired up.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add the shell and wire it up
|
||||
|
||||
Where the bytes actually move.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/SchoolDetailShell.tsx` + `.module.css`
|
||||
- Create: `nextjs-app/components/school/PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx`
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Delete: `SchoolDetailView.tsx` + `.module.css`, `SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 4-6.
|
||||
- Produces: `SchoolDetailShell` — `'use client'`, props `{ schoolInfo: School; navItems: NavItem[]; flags: SchoolFlags; children: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Build the shell**
|
||||
|
||||
Create `SchoolDetailShell.tsx` from `SchoolDetailView.tsx` lines 1-595 — the back link, header, details reveal, hero map, compare CTA, sticky nav, jump sheet — plus the hooks at lines 79-104 and the effects at 120-203 and 270-300. Keep `'use client'`. It renders `{children}` where the sections used to begin (after the nav closes, at what is currently line 595).
|
||||
|
||||
`navItems` becomes a prop instead of being computed inline. The scroll-spy at line 283 uses `document.getElementById(id)` and needs no change — it finds server-rendered sections exactly as before.
|
||||
|
||||
For the shell's stylesheet, do not eyeball which rules are shell-only — determine it mechanically. Every class the shell's JSX references goes into `SchoolDetailShell.module.css`; classes referenced by both the shell and a section stay duplicated in both files (CSS Modules hash them independently, so duplication is correct, not a smell). List the shell's classes with:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -o 'styles\.[a-zA-Z][A-Za-z0-9_]*' components/school/SchoolDetailShell.tsx \
|
||||
| sort -u | sed 's/styles\.//'
|
||||
```
|
||||
|
||||
Copy each of those rules — including every media-query and `:hover`/`:focus` block that mentions them — from `SchoolDetailView.module.css` before deleting it in Step 6.
|
||||
|
||||
- [ ] **Step 2: Build the two composers**
|
||||
|
||||
Server components — no `'use client'`. Each returns the section sequence in the current order, gated by the same flags:
|
||||
|
||||
```tsx
|
||||
export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) {
|
||||
const { ofsted, admissions, /* … */ } = data;
|
||||
return (
|
||||
<>
|
||||
{ofsted && <OfstedSection ofsted={ofsted} urn={data.schoolInfo.urn} /* … */ />}
|
||||
{flags.hasAnyResults && flags.latestResults && <ResultsSection /* … */ />}
|
||||
{admissions && <AdmissionsSection /* … */ />}
|
||||
{flags.hasInclusionData && <InclusionSection /* … */ />}
|
||||
{data.yearlyData.length > 0 && <HistorySection /* … */ />}
|
||||
{flags.hasSchoolLife && <SchoolLifeSection /* … */ />}
|
||||
{flags.hasDeprivation && <LocalAreaSection /* … */ />}
|
||||
{flags.hasFinance && <FinancesSection /* … */ />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The conditions must match `buildNavItems` exactly, or the nav will link to sections that do not exist.
|
||||
|
||||
`SecondarySchoolSections` follows the same shape with `GcseSection`, `SecondaryAdmissionsSection`, `SecondaryHistorySection`, `WellbeingSection`, `FinancesSection`.
|
||||
|
||||
- [ ] **Step 3: Rewire the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the `isSecondary ? <SecondarySchoolDetailView/> : <SchoolDetailView/>` block. Compute flags and nav items on the server, then wrap:
|
||||
|
||||
```tsx
|
||||
const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance });
|
||||
const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length });
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<SchoolDetailShell schoolInfo={school_info} navItems={navItems} flags={flags}>
|
||||
{isSecondary
|
||||
? <SecondarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />
|
||||
: <PrimarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />}
|
||||
</SchoolDetailShell>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
Keep the existing `isSecondary` / `isAllThrough` derivation exactly as it is — all-through schools must still route to the primary composer, which renders both KS2 and KS4 content.
|
||||
|
||||
- [ ] **Step 4: Swap the render helper — the moment of truth**
|
||||
|
||||
Rewrite **only** `__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted, admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
return render(
|
||||
<SchoolDetailShell schoolInfo={fixture.schoolInfo} navItems={navItems} flags={flags}>
|
||||
<PrimarySchoolSections data={fixture} flags={flags} nationalAvg={nationalAveragesFixture} />
|
||||
</SchoolDetailShell>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the characterization tests**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS, with `schoolDetail.characterization.test.tsx` byte-identical to Task 2.**
|
||||
|
||||
This is the proof the refactor preserved behaviour. If a test fails, the refactor is wrong — fix the components, never the test. Confirm the file is untouched:
|
||||
|
||||
```bash
|
||||
git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Delete the old views**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.module.css
|
||||
```
|
||||
|
||||
Confirm nothing still imports them:
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "SchoolDetailView\|SecondarySchoolDetailView" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 7: Full verification**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all three PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): render sections on the server behind a client shell
|
||||
|
||||
page.tsx now composes the sections and passes them through
|
||||
SchoolDetailShell as children, so ~750 lines of static markup stop shipping
|
||||
as client JavaScript. The shell keeps what is genuinely interactive: back
|
||||
link, header reveal, hero map, compare CTA, sticky nav and scroll-spy.
|
||||
|
||||
The scroll-spy already located sections via document.getElementById, so it
|
||||
works unchanged against server-rendered children.
|
||||
|
||||
Characterization tests from the earlier commit pass unmodified.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete the unused SWR hooks
|
||||
|
||||
**Files:**
|
||||
- Delete: `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
- Modify: `nextjs-app/package.json`
|
||||
|
||||
- [ ] **Step 1: Confirm nothing imports them**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "useSchools\|useFilters\|useMetrics\|useSchoolDetails" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output. If anything appears, stop and report it — do not delete.
|
||||
|
||||
- [ ] **Step 2: Delete and drop the dependency**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/hooks/useSchools.ts nextjs-app/hooks/useFilters.ts \
|
||||
nextjs-app/hooks/useMetrics.ts nextjs-app/hooks/useSchoolDetails.ts
|
||||
cd nextjs-app && npm uninstall swr
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: drop unused SWR hooks and dependency
|
||||
|
||||
useSchools, useFilters, useMetrics and useSchoolDetails were imported by
|
||||
nothing and were the only consumers of swr. All data fetching goes through
|
||||
lib/api.ts on the server.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E coverage and measurement
|
||||
|
||||
CLAUDE.md requires e2e updates in the same PR as user-facing changes; these journeys gate whether staging is fit for human testing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/tests/journeys.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Read the existing journeys**
|
||||
|
||||
Run: `cat e2e/tests/journeys.spec.ts` and match its existing helpers, selectors and naming. Note the staging-data constraint: year filters must use the **latest** year, not the oldest — staging carries only partial history.
|
||||
|
||||
- [ ] **Step 2: Add detail-page assertions**
|
||||
|
||||
Add a journey that visits a school detail page and asserts: the Ofsted section renders; the results section renders a figure; the sticky section nav renders and a jump link scrolls to its section; and — the regression this whole refactor risks — the admissions year/trend toggle switches views. Assert on user-visible text and roles, never CSS-module class names.
|
||||
|
||||
- [ ] **Step 3: Verify the spec parses**
|
||||
|
||||
Run: `cd e2e && npx playwright test --list`
|
||||
Expected: the new tests appear. Do not run them locally — they target staging, which is deployed from `main`.
|
||||
|
||||
- [ ] **Step 4: Measure the result**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && rm -rf .next && npx next build >/dev/null 2>&1 && python3 - <<'PY'
|
||||
import re,os,gzip,glob
|
||||
for html in sorted(glob.glob('.next/server/app/**/*.html', recursive=True)):
|
||||
src=set(re.findall(r'/_next/(static/[^"\']+\.js)', open(html).read()))
|
||||
tot=sum(len(gzip.compress(open(os.path.join('.next',s),'rb').read()))
|
||||
for s in src if os.path.exists(os.path.join('.next',s)))
|
||||
print(f"{tot/1024:8.1f} KB gz {len(src):3d} scripts {html}")
|
||||
PY
|
||||
```
|
||||
|
||||
Record the figures for the PR description. Compare against the `cab7b4f` baseline: 172 KB gz shared baseline, `/admissions` 177 KB gz. **Report the route-specific figure and the shared baseline separately** — this change does not target the baseline, and an unchanged baseline is not a failure.
|
||||
|
||||
- [ ] **Step 5: Commit and open the PR**
|
||||
|
||||
```bash
|
||||
git add e2e/tests/journeys.spec.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(e2e): cover school detail sections and admissions toggle
|
||||
|
||||
The server/client split moves the detail sections out of the client bundle;
|
||||
these journeys gate that the rendered page still behaves the same on staging.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push -u origin perf/server-client-split
|
||||
```
|
||||
|
||||
Open the PR against `main` per the Gitea flow in `docs/DEPLOY.md`. The description must include: the before/after byte table from Step 4, the note that the shared baseline is deliberately unchanged, the two CSS variants introduced in Task 5, and any bugs found-but-not-fixed during the pure-move tasks.
|
||||
|
||||
Do **not** trigger the production promotion workflow — that is the human's call.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before claiming the work complete, confirm each of these with actual command output:
|
||||
|
||||
- [ ] `cd nextjs-app && npm run typecheck` — passes
|
||||
- [ ] `cd nextjs-app && npm test` — passes
|
||||
- [ ] `cd nextjs-app && npm run build` — passes
|
||||
- [ ] `git diff cab7b4f -- nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx` — **empty** after Task 2's commit
|
||||
- [ ] `grep -rn "SchoolDetailView" nextjs-app/app nextjs-app/components` — no output
|
||||
- [ ] `grep -rn "swr" nextjs-app/package.json` — no output
|
||||
- [ ] CSS audit script from Task 5 Step 5 — no `MISSING` or `LOST` lines
|
||||
- [ ] Byte measurement recorded in the PR description
|
||||
@@ -0,0 +1,295 @@
|
||||
# Detail pages: server/client split
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Branch:** `perf/server-client-split`
|
||||
|
||||
## Problem
|
||||
|
||||
Every page of the site ships ~172 KB gzipped of JavaScript before any
|
||||
page-specific code loads. The 404 page costs that much; `/admissions`, a static
|
||||
content page with no data, costs 177 KB. A well-tuned Next.js app baseline is
|
||||
90-110 KB.
|
||||
|
||||
The cause is that nearly the whole site is a client component.
|
||||
`SchoolDetailView.tsx` is 65 KB of source marked `'use client'`;
|
||||
`SecondarySchoolDetailView.tsx` is 46 KB. Both are overwhelmingly presentational
|
||||
markup driven by props the server already fetched. Every stat tile, table row and
|
||||
Ofsted badge is serialised into the RSC payload *and* shipped again as JavaScript
|
||||
to hydrate.
|
||||
|
||||
Measured on a production build of `cab7b4f` (Next 16.1.6, Turbopack):
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Baseline JS, every page | 172 KB gz across 8 chunks |
|
||||
| `/admissions` (no data) | 177 KB gz |
|
||||
| Chart.js chunk | 64 KB gz (correctly lazy) |
|
||||
| Leaflet chunk | 41 KB gz (correctly lazy) |
|
||||
| CSS | ~30 KB gz, route-split |
|
||||
|
||||
**Scope of this document.** The table above characterises the whole problem; this
|
||||
design addresses one part of it — the route-level client JS on school detail
|
||||
pages, the site's highest-traffic and most SEO-critical route. The shared
|
||||
baseline and the other routes are separate work, tracked elsewhere.
|
||||
|
||||
## What makes this tractable
|
||||
|
||||
Two properties of the existing code make an aggressive split low-risk.
|
||||
|
||||
**The section bodies are almost entirely static.** Across ~750 lines of sections
|
||||
in `SchoolDetailView` (lines 596-1350), the only interactivity is five charts
|
||||
(already `dynamic({ ssr: false })` islands), fourteen `<MetricTooltip>`
|
||||
instances (already a small client leaf), and two buttons driving the admissions
|
||||
year/trend toggle. Everything else renders props.
|
||||
|
||||
**The scroll-spy is already decoupled.** `SchoolDetailView.tsx:283` locates
|
||||
sections with `document.getElementById(id)`, and `navItems` (lines 258-267) is
|
||||
computed purely from data. The navigation does not care which component renders
|
||||
the sections, so server-rendered children work unchanged.
|
||||
|
||||
There is also a precedent to follow: `components/compare/` is already decomposed
|
||||
this way — one file per section plus a `sectionShared.tsx` of primitives.
|
||||
|
||||
## Architecture
|
||||
|
||||
A server component imported by a client component becomes a client component. So
|
||||
the sections cannot be children of a client `SchoolDetailView`. They are composed
|
||||
in `page.tsx` and passed *through* the client shell as `children` — the pattern
|
||||
already used at `app/page.tsx:98-99`.
|
||||
|
||||
The header and navigation sit above the sections, and the sections are plain
|
||||
siblings, so a single `children` slot suffices. Named slots were considered and
|
||||
rejected: more ceremony, no benefit when sections render in sequence.
|
||||
|
||||
```
|
||||
app/school/[slug]/page.tsx server; fetches details + nationalAverages
|
||||
│ computes isSecondary / isAllThrough / navItems
|
||||
└── <SchoolDetailShell schoolInfo navItems> 'use client' — the only large client file
|
||||
│ back link · header + details reveal · hero map · compare CTA
|
||||
│ sticky section nav · scroll spy · jump sheet
|
||||
└── children: <PrimarySchoolSections/> | <SecondarySchoolSections/> server
|
||||
└── components/school/ all server, mirroring components/compare/
|
||||
OfstedSection · ResultsSection · AdmissionsSection
|
||||
InclusionSection · HistorySection · SchoolLifeSection
|
||||
LocalAreaSection · FinancesSection · WellbeingSection
|
||||
sectionShared.tsx Section/Card/Row primitives
|
||||
```
|
||||
|
||||
Phase branching stays out of the route file: two thin server components
|
||||
(`PrimarySchoolSections`, `SecondarySchoolSections`) return the section sequence,
|
||||
and `page.tsx` picks one.
|
||||
|
||||
### Client leaves inside server sections
|
||||
|
||||
Importing a client component into a server component is the legal direction, so
|
||||
these stay client and are used from server sections unchanged:
|
||||
|
||||
- `MetricTooltip` / `InfoPopover` — exist, unchanged
|
||||
- `PerformanceChart`, `SatsChart`, `AdmissionsTrendChart` — exist, stay `dynamic({ ssr: false })`
|
||||
- `AdmissionsViewToggle` — **new**, ~25 lines; receives server-rendered year-view
|
||||
and trend-view as two props and swaps between them
|
||||
- `DeltaChip`, `SpecialSchoolNote` — already server-compatible (no `'use client'`)
|
||||
|
||||
### What stays in the client shell, and why
|
||||
|
||||
The shell is genuinely interactive throughout: `router.back()`, the details
|
||||
reveal, `IntersectionObserver` hero-CTA tracking, the nav overflow fade, the
|
||||
Escape-to-close sheet, and the comparison context. The goal is not to shrink the
|
||||
shell but to stop the other ~750 lines being attached to it.
|
||||
|
||||
### Section sharing
|
||||
|
||||
Sharing is decided by measured similarity, not assumption. Comparing the two
|
||||
views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary | Secondary | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Finances | 29 lines | 35 | 91% | Shared |
|
||||
| Ofsted | 88 | 107 | 80% | Shared |
|
||||
| History | 121 | 47 | 40% | Separate |
|
||||
| Admissions | 89 | 55 | 14% | Separate |
|
||||
|
||||
Admissions and History were assumed shareable when this design was first drafted;
|
||||
measurement during planning showed they are not — primary's admissions carries
|
||||
the year/trend toggle and chart, secondary's is a much simpler panel.
|
||||
|
||||
**This costs nothing in bytes.** The client-JS win comes from a section being a
|
||||
*server* component, not from being shared between views. Sharing is a
|
||||
maintainability benefit, taken only where it is cheap. Forcing the two
|
||||
low-similarity sections together would inflate the diff and the regression risk
|
||||
for no performance gain.
|
||||
|
||||
| Shared | Primary only | Secondary only |
|
||||
| --- | --- | --- |
|
||||
| Ofsted, Finances, `sectionShared` primitives, and the shell | Results (KS2), Admissions, History, Inclusion, School Life, Local Area | GCSEs, Admissions, History, Wellbeing |
|
||||
|
||||
All-through schools render both the KS2 and KS4 section sets via the primary
|
||||
composer.
|
||||
|
||||
### CSS strategy
|
||||
|
||||
Shared section components need one stylesheet, but the two existing CSS modules
|
||||
disagree. Measured on `cab7b4f`: `SchoolDetailView.module.css` defines 142
|
||||
classes, `SecondarySchoolDetailView.module.css` 112, with 79 names in common — of
|
||||
which 22 have different rules, and 16 of those are used inside section markup by
|
||||
both views (`.card`, `.sectionTitle`, `.metricCard`, `.metricValue`,
|
||||
`.metricsGrid`, `.tableWrapper`, the `heroStat*` set, the `genderSplit*` set,
|
||||
`.historyToggle`, `.ofstedReportLink`).
|
||||
|
||||
The 16 conflicts split into two kinds:
|
||||
|
||||
**Incidental drift (14 classes).** One view carries defensive overflow properties
|
||||
the other lacks — `min-width: 0`, `overflow-wrap: break-word`,
|
||||
`word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`.
|
||||
These are independent bug-fixes that were never back-ported, not design
|
||||
decisions. **Resolution: take the union.** No visible change, and it fixes latent
|
||||
long-school-name overflow on whichever page currently lacks the property.
|
||||
|
||||
**Real visual differences (2 classes).**
|
||||
|
||||
| Class | Primary | Secondary |
|
||||
| --- | --- | --- |
|
||||
| `.genderBar` | `height: 6px`, `background: var(--border-color, #e5dfd5)`, `width: 100%` | `height: 4px`, `background: rgba(0, 0, 0, 0.08)`, `margin-top: 0.45rem` |
|
||||
| `.heroStatValue` | `gap: 0.5rem`, `justify-content: flex-start`, Playfair fallback includes Georgia | `gap: 0.4rem`, Playfair fallback omits Georgia |
|
||||
|
||||
**Resolution: keep both looks as distinct classes** and have the owning section
|
||||
take a `variant: 'primary' | 'secondary'` prop selecting between them. Zero
|
||||
intended visual change, one shared stylesheet, and the divergence becomes
|
||||
explicit rather than accidental.
|
||||
|
||||
The merged stylesheet is `components/school/schoolSections.module.css`. Shell-only
|
||||
rules stay behind in the per-view modules. Classes outside the 79 shared names
|
||||
move across unchanged.
|
||||
|
||||
### Prerequisite: national averages move server-side
|
||||
|
||||
`SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`,
|
||||
and `primaryAvg` / `secondaryAvg` feed the England-comparison deltas across most
|
||||
sections. Those sections cannot be server components until that data arrives as a
|
||||
prop. `SecondarySchoolDetailView.tsx:91-93` has the same fetch.
|
||||
|
||||
`page.tsx` will fetch it in parallel with the school details (the endpoint is
|
||||
backend-cached for an hour) and pass `nationalAvg` down. This is a prerequisite,
|
||||
not an optional extra. It independently removes a client round-trip per detail
|
||||
page and puts the deltas in the initial HTML instead of popping in after
|
||||
hydration.
|
||||
|
||||
### Layout
|
||||
|
||||
`Footer` is already a server component — no change needed. `Navigation` genuinely
|
||||
requires client (`usePathname` plus the comparison count) and stays as it is.
|
||||
This item is much smaller than the original review estimated.
|
||||
|
||||
### Unused dependency
|
||||
|
||||
`hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts` and `useSchoolDetails.ts`
|
||||
are imported by nothing. They are the only consumers of `swr`. All four files and
|
||||
the `swr` dependency are deleted.
|
||||
|
||||
## Testing
|
||||
|
||||
`SchoolDetailView` will not exist as a single component afterwards, so
|
||||
characterization tests must not import it directly. A single render helper
|
||||
absorbs the change:
|
||||
|
||||
```
|
||||
__tests__/support/renderSchoolDetail.tsx the only file that changes at the split
|
||||
before: render(<SchoolDetailView {...props} />)
|
||||
after: render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)
|
||||
```
|
||||
|
||||
Every assertion imports that helper and never changes. Assertions passing
|
||||
untouched across the refactor is the proof that behaviour is preserved.
|
||||
|
||||
**Coverage:** Ofsted panel (rating, date, report link); KS2 results rows and
|
||||
England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the
|
||||
year/trend toggle; inclusion and wellbeing figures; the history table; finances;
|
||||
and the conditional `navItems` set.
|
||||
|
||||
**Fixtures:** four shapes — primary, secondary, all-through, and special school.
|
||||
The special-school path drops the England comparison via `isSpecialSchool()` and
|
||||
is the site of a known past regression (PR #70), so it gets explicit coverage.
|
||||
|
||||
**Environment gaps to close in `jest.setup.js`:** jsdom provides neither
|
||||
`IntersectionObserver` (used twice in each view) nor `window.scrollTo`. Chart.js
|
||||
needs a canvas, so the chart components are mocked. `next/jest` already handles
|
||||
CSS modules, and `next/navigation` is already globally mocked. Section components
|
||||
are synchronous functions, so React Testing Library renders both the server and
|
||||
client pieces in jsdom without special handling.
|
||||
|
||||
**E2E:** `e2e/journeys.spec.ts` gains detail-page assertions. CLAUDE.md requires
|
||||
e2e updates in the same PR as user-facing changes.
|
||||
|
||||
## Commit sequence
|
||||
|
||||
One PR, but the diff is large, so it is structured to stay reviewable and
|
||||
bisectable:
|
||||
|
||||
1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures
|
||||
2. `test:` characterization tests against the current components — all green before anything moves
|
||||
3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches
|
||||
4. `refactor:` extract the shared derived-flags/`navItems` module
|
||||
5. `style:` merge the two CSS modules into `components/school/schoolSections.module.css` per the CSS strategy above
|
||||
6. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits)
|
||||
7. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views
|
||||
8. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json`
|
||||
9. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions
|
||||
|
||||
Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes
|
||||
them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge
|
||||
rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect.
|
||||
|
||||
## Verification
|
||||
|
||||
Before the work is claimed complete:
|
||||
|
||||
- `npm run typecheck`, `npm test` and `npm run build` all green
|
||||
- characterization tests passing **unmodified** from commit 2
|
||||
- before/after gzipped JS per route recorded in the PR description, measured the
|
||||
same way as the figures in the Problem section
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `/school/[slug]` route-specific JS drops to roughly 40-60 KB gz, from a client
|
||||
tree currently built out of a 65 KB and a 46 KB source file
|
||||
- No visual or behavioural change on any of the four school shapes
|
||||
- One fewer client API round-trip per detail page
|
||||
|
||||
**This change does not reduce the 172 KB baseline.** That baseline is react-dom,
|
||||
the Next.js runtime, and the layout's client components (`Navigation`,
|
||||
`ComparisonProvider`, `ComparisonToast`) — none of which this design touches, as
|
||||
`Navigation` legitimately needs `usePathname` and the comparison count. The
|
||||
baseline is a separate piece of work; it is cited above only to establish why
|
||||
route-level client JS matters. Reporting must state the two numbers separately so
|
||||
the unchanged baseline is not read as a failure of this change.
|
||||
|
||||
Budget enforcement in CI was considered and deliberately deferred: measurement is
|
||||
recorded in the PR, but no new CI gate is added in this change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No visual redesign or copy changes to any section
|
||||
- No change to `Navigation`, `Footer`, or any other route
|
||||
- No CI bundle-budget gate
|
||||
- Bugs found mid-move are recorded, not fixed inline
|
||||
|
||||
## Risks
|
||||
|
||||
**DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS
|
||||
modules depend on. Commits 4 and 5 are strict moves and the section CSS moves
|
||||
with them; any styling change beyond the CSS-merge rules above is out of scope.
|
||||
|
||||
**CSS merge.** Merging two large stylesheets is the highest-risk part of the
|
||||
work. The union rule is mechanical, but a missed conflict shows up as a visual
|
||||
regression that no unit test catches. The merge gets its own commit, and the
|
||||
16 known conflicts are enumerated above so they can be verified individually.
|
||||
|
||||
**All-through schools.** The trickiest case: `isAllThrough` gates KS2 content
|
||||
back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It
|
||||
gets a dedicated fixture.
|
||||
|
||||
**Large diff.** Accepted deliberately in favour of designing the shared section
|
||||
library against both consumers at once. Mitigated by the commit sequence above.
|
||||
|
||||
**`swr` removal** is safe — nothing imports those hooks.
|
||||
@@ -556,3 +556,73 @@ test('compare metric-help popover stays within the mobile viewport', async ({ pa
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
|
||||
expect(bodyOverflowsX).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The following two journeys cover the server/client split of the detail page.
|
||||
* The sections are now React Server Components composed in the route and passed
|
||||
* through a client shell; these assert that the two halves still meet correctly
|
||||
* in a real browser, which no unit test can prove.
|
||||
*/
|
||||
|
||||
test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => {
|
||||
// Find a school with at least two years carrying an offer rate — the toggle
|
||||
// only appears then. Data-invariant: uses whatever the environment holds.
|
||||
const res = await page.request.get('/api/schools?search=primary&per_page=50');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn);
|
||||
|
||||
let target: number | null = null;
|
||||
for (const urn of candidates.slice(0, 12)) {
|
||||
const detail = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!detail.ok()) continue;
|
||||
const history = (await detail.json()).admissions_history ?? [];
|
||||
const withRate = history.filter(
|
||||
(h: { first_preference_offer_pct?: number | null }) => h.first_preference_offer_pct != null,
|
||||
);
|
||||
if (withRate.length >= 2) { target = urn; break; }
|
||||
}
|
||||
test.skip(target === null, 'no school in this environment has 2+ years of admissions offer data');
|
||||
|
||||
await page.goto(`/school/${target}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const yearBtn = page.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = page.getByRole('button', { name: /-year trend$/ });
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
// The toggle is the one client island inside an otherwise server-rendered
|
||||
// section: clicking it must swap the two server-rendered views.
|
||||
await trendBtn.click();
|
||||
await expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await yearBtn.click();
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('sticky section nav jumps to server-rendered sections', async ({ page }) => {
|
||||
const [urn] = await twoPrimaryUrns(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The nav is client-rendered from a server-computed list, while the sections
|
||||
// themselves are server-rendered. Every link must resolve to a real section:
|
||||
// the scroll-spy finds them with document.getElementById, so a mismatch
|
||||
// between the two halves would dead-end here.
|
||||
const navLinks = page.locator('nav a[href^="#"]');
|
||||
const count = await navLinks.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await navLinks.nth(i).getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
await expect(page.locator(href!)).toHaveCount(1);
|
||||
}
|
||||
|
||||
// And following one actually moves the page.
|
||||
const before = await page.evaluate(() => window.scrollY);
|
||||
await navLinks.last().click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await page.evaluate(() => window.scrollY);
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Characterization tests for the school detail pages.
|
||||
*
|
||||
* These describe behaviour that ALREADY EXISTS. They are written before the
|
||||
* server/client split and must pass UNMODIFIED after it — that is the whole
|
||||
* point. If one fails during the refactor, the refactor is wrong; fix the
|
||||
* components, never these assertions.
|
||||
*
|
||||
* All rendering goes through renderSchoolDetail / renderSecondarySchoolDetail,
|
||||
* the one file the refactor is permitted to change.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
|
||||
// Chart.js needs a canvas jsdom does not provide, and Leaflet needs a real
|
||||
// window. Both are already lazy client islands; their content is out of scope.
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
describe('Ofsted section', () => {
|
||||
it('renders the legacy OEIF grade, date and report link', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Rating')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inspected 17 May 2023/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Good').length).toBeGreaterThan(0);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Ofsted reports/ });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining(String(primaryFixture.schoolInfo.urn)),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the previous grade when it differs from the current one', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText(/Previously:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Report Card layout with its category bands', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Report Card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/From November 2025, Ofsted replaced single overall grades/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Safeguarding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Met')).toBeInTheDocument();
|
||||
expect(screen.getByText('Achievement')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS2 results', () => {
|
||||
it('renders the combined RWM figure with its England comparison', async () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('58%')).toBeInTheDocument();
|
||||
// 58 vs an England average of 61 → a -3 pts chip and the hint line.
|
||||
expect(await screen.findByText('England avg: 61%')).toBeInTheDocument();
|
||||
expect(await screen.findByText('-3 pts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS4 results', () => {
|
||||
// These figures appear in both the hero stats and the history table, so
|
||||
// assert on presence rather than uniqueness.
|
||||
it('renders Attainment 8, Progress 8 and EBacc figures', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getAllByText('46.2').length).toBeGreaterThan(0);
|
||||
// formatProgress rounds to 1 decimal and signs positives: 0.31 → "+0.3"
|
||||
expect(screen.getAllByText(/\+0\.3/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/EBacc/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-through schools', () => {
|
||||
// The highest-value assertion in this file. isAllThrough gates the KS2
|
||||
// content back on for a school that also has KS4 data, so an all-through
|
||||
// page must show BOTH key stages. This is the case most likely to break.
|
||||
it('renders both KS2 and KS4 figures on the same page', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
|
||||
expect(screen.getAllByText('63%').length).toBeGreaterThan(0); // KS2 RWM
|
||||
expect(screen.getAllByText('48.7').length).toBeGreaterThan(0); // KS4 Attainment 8
|
||||
});
|
||||
|
||||
it('labels the KS2 block explicitly', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('special schools', () => {
|
||||
// Guards the PR #70 regression, where a special school displayed
|
||||
// "0% -62 below England" against a mainstream benchmark that does not fit.
|
||||
it('suppresses the England comparison', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.queryByText(/England avg:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why the comparison is missing', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('This is a special school.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admissions', () => {
|
||||
it('toggles between the year view and the multi-year trend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
const yearBtn = screen.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = screen.getByRole('button', { name: /3-year trend/ });
|
||||
expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const yearView = container.querySelector('[class*="admissionsViewYear"]')!;
|
||||
const trendView = container.querySelector('[class*="admissionsViewTrend"]')!;
|
||||
expect(yearView).not.toHaveAttribute('hidden');
|
||||
expect(trendView).toHaveAttribute('hidden');
|
||||
|
||||
await user.click(trendBtn);
|
||||
|
||||
expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(yearView).toHaveAttribute('hidden');
|
||||
expect(trendView).not.toHaveAttribute('hidden');
|
||||
});
|
||||
|
||||
it('renders the headline admissions figures', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText('Places offered')).toBeInTheDocument();
|
||||
expect(screen.getByText('Got their first choice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the toggle when there is only one year of offer data', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
expect(screen.queryByRole('button', { name: 'This year' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section navigation', () => {
|
||||
it('lists the sections that have data', () => {
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
for (const id of ['ofsted', 'results', 'admissions', 'history', 'finances']) {
|
||||
expect(container.querySelector(`#${id}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits sections with no data', () => {
|
||||
const { container } = renderSchoolDetail(specialFixture);
|
||||
|
||||
expect(container.querySelector('#ofsted')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('#admissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isPrimary).toBe(true);
|
||||
expect(f.isSecondary).toBe(false);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies an ordinary secondary', () => {
|
||||
const f = computeSchoolFlags(secondaryFixture);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.isAllThrough).toBe(false);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
expect(f.suppressKs4Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isSpecial).toBe(false);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a gender split only when census counts are present', () => {
|
||||
expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true);
|
||||
expect(
|
||||
computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
expect(ids).toContain('history');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const label = (fixture: any) => {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
return buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
}).find((n) => n.id === 'results')?.label;
|
||||
};
|
||||
|
||||
expect(label(primaryFixture)).toBe('SATs');
|
||||
expect(label(secondaryFixture)).toBe('GCSEs');
|
||||
expect(label(allThroughFixture)).toBe('Results');
|
||||
});
|
||||
|
||||
it('keeps the engagement-led ordering', () => {
|
||||
const flags = computeSchoolFlags(primaryFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: primaryFixture.ofsted,
|
||||
admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual([
|
||||
'ofsted', 'results', 'admissions', 'inclusion',
|
||||
'history', 'school-life', 'local-area', 'finances',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSecondaryFlags', () => {
|
||||
it('reads results and sixth form from the secondary fixture', () => {
|
||||
const f = computeSecondaryFlags(secondaryFixture);
|
||||
expect(f.hasResults).toBe(true);
|
||||
expect(f.hasSixthForm).toBe(false);
|
||||
expect(f.hasWellbeing).toBe(true);
|
||||
});
|
||||
|
||||
it('reports sixth form when GIAS flags it', () => {
|
||||
expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the comparison for a special school', () => {
|
||||
const f = computeSecondaryFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressComparison).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => {
|
||||
expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSecondaryNavItems', () => {
|
||||
it('uses the secondary section ids', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: secondaryFixture.ofsted,
|
||||
admissions: secondaryFixture.admissions,
|
||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||
});
|
||||
|
||||
it('gates History on more than one year, unlike the primary page', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('history');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* This file is the ONLY thing the server/client split was allowed to change.
|
||||
* It now renders the shell + server-sections composition that
|
||||
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
|
||||
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
|
||||
* that is the proof the refactor preserved behaviour.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { nationalAveragesFixture } from './schoolFixtures';
|
||||
|
||||
// The shell calls useComparison(), which throws outside the provider. In the
|
||||
// app this wrapper comes from app/layout.tsx.
|
||||
function withProviders(ui: ReactNode) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
{...fixture}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
const flags = computeSecondaryFlags(fixture);
|
||||
const navItems = buildSecondaryNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
{...fixture}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Fixtures for the school detail characterization tests.
|
||||
*
|
||||
* Four shapes, each pinning a branch the detail views take:
|
||||
* primary — KS2 results, legacy OEIF Ofsted, 3 years of admissions
|
||||
* (enough for the year/trend toggle)
|
||||
* secondary — KS4 results, Report Card Ofsted, 1 admissions year
|
||||
* (no toggle)
|
||||
* allThrough — BOTH KS2 and KS4, so the primary content is gated back on
|
||||
* special — a special school with an all-zero KS2 row, where the
|
||||
* England comparison must be suppressed
|
||||
*
|
||||
* Field names come from lib/types.ts. If TypeScript rejects something here,
|
||||
* the fixture is wrong — never widen the type to accommodate a fixture.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders — SchoolResult has 57 required nullable fields, so every fixture
|
||||
// starts from an all-null row and overrides only what it is testing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptyResult: SchoolResult = {
|
||||
school_id: 1,
|
||||
year: 2024,
|
||||
total_pupils: null, eligible_pupils: null,
|
||||
rwm_expected_pct: null, reading_expected_pct: null, writing_expected_pct: null,
|
||||
maths_expected_pct: null, gps_expected_pct: null, science_expected_pct: null,
|
||||
rwm_high_pct: null, reading_high_pct: null, writing_high_pct: null,
|
||||
maths_high_pct: null, gps_high_pct: null,
|
||||
reading_progress: null, writing_progress: null, maths_progress: null,
|
||||
reading_avg_score: null, maths_avg_score: null, gps_avg_score: null,
|
||||
disadvantaged_pct: null, eal_pct: null, sen_support_pct: null, sen_ehcp_pct: null,
|
||||
stability_pct: null,
|
||||
reading_absence_pct: null, gps_absence_pct: null, maths_absence_pct: null,
|
||||
writing_absence_pct: null, science_absence_pct: null,
|
||||
rwm_expected_boys_pct: null, rwm_expected_girls_pct: null,
|
||||
rwm_high_boys_pct: null, rwm_high_girls_pct: null,
|
||||
rwm_expected_disadvantaged_pct: null, rwm_expected_non_disadvantaged_pct: null,
|
||||
disadvantaged_gap: null,
|
||||
rwm_expected_3yr_pct: null, reading_avg_3yr: null, maths_avg_3yr: null,
|
||||
attainment_8_score: null, progress_8_score: null,
|
||||
progress_8_lower_ci: null, progress_8_upper_ci: null,
|
||||
progress_8_english: null, progress_8_maths: null,
|
||||
progress_8_ebacc: null, progress_8_open: null,
|
||||
english_maths_strong_pass_pct: null, english_maths_standard_pass_pct: null,
|
||||
ebacc_entry_pct: null, ebacc_strong_pass_pct: null, ebacc_standard_pass_pct: null,
|
||||
ebacc_avg_score: null, gcse_grade_91_pct: null, prior_attainment_avg: null,
|
||||
};
|
||||
|
||||
export function makeResult(overrides: Partial<SchoolResult> = {}): SchoolResult {
|
||||
return { ...emptyResult, ...overrides };
|
||||
}
|
||||
|
||||
function makeSchool(overrides: Partial<School> = {}): School {
|
||||
return {
|
||||
urn: 123456,
|
||||
school_name: 'Test Primary School',
|
||||
local_authority: 'Westshire',
|
||||
local_authority_code: 900,
|
||||
school_type: 'Community school',
|
||||
school_type_code: 'CY',
|
||||
religious_denomination: 'None',
|
||||
age_range: '4-11',
|
||||
address1: '1 Test Lane',
|
||||
address2: null,
|
||||
town: 'Testville',
|
||||
postcode: 'TE1 1ST',
|
||||
address: '1 Test Lane, Testville, TE1 1ST',
|
||||
latitude: 51.5,
|
||||
longitude: -0.12,
|
||||
phase: 'Primary',
|
||||
gender: 'Mixed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyOfsted: OfstedInspection = {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
report_url: null,
|
||||
};
|
||||
|
||||
function makeOfsted(overrides: Partial<OfstedInspection> = {}): OfstedInspection {
|
||||
return { ...emptyOfsted, ...overrides };
|
||||
}
|
||||
|
||||
function makeAdmissions(overrides: Partial<SchoolAdmissions> = {}): SchoolAdmissions {
|
||||
return {
|
||||
year: 2024,
|
||||
places_offered: 60,
|
||||
total_applications: 210,
|
||||
first_preference_applications: 90,
|
||||
first_preference_offers: 54,
|
||||
first_preference_offer_pct: 60,
|
||||
oversubscribed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const census: SchoolCensus = {
|
||||
year: 2024,
|
||||
total_pupils: 420,
|
||||
female_pupils: 205,
|
||||
male_pupils: 215,
|
||||
fsm_pct: 18.4,
|
||||
eal_pct: 22.1,
|
||||
};
|
||||
|
||||
const absence: AbsenceData = {
|
||||
overall_absence_rate: 5.2,
|
||||
persistent_absence_rate: 14.8,
|
||||
};
|
||||
|
||||
const deprivation: SchoolDeprivation = {
|
||||
lsoa_code: 'E01000001',
|
||||
idaci_score: 0.21,
|
||||
idaci_decile: 4,
|
||||
};
|
||||
|
||||
const finance: SchoolFinance = {
|
||||
year: 2024,
|
||||
per_pupil_spend: 5400,
|
||||
staff_cost_pct: 72.5,
|
||||
teacher_cost_pct: 51.2,
|
||||
support_staff_cost_pct: 21.3,
|
||||
premises_cost_pct: 6.1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// National averages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const nationalAveragesFixture: NationalAverages = {
|
||||
year: 2024,
|
||||
primary: { rwm_expected_pct: 61, rwm_high_pct: 8, reading_expected_pct: 74 },
|
||||
secondary: { attainment_8_score: 45.9, progress_8_score: 0, ebacc_entry_pct: 39 },
|
||||
by_year: [
|
||||
{ year: 2023, primary: { rwm_expected_pct: 60 }, secondary: { attainment_8_score: 44.4 } },
|
||||
{ year: 2024, primary: { rwm_expected_pct: 61 }, secondary: { attainment_8_score: 45.9 } },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 1: ordinary primary — KS2 results, legacy OEIF, 3 admissions years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const primaryFixture = {
|
||||
schoolInfo: makeSchool(),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2022, rwm_expected_pct: 54, reading_expected_pct: 68, writing_expected_pct: 62, maths_expected_pct: 65, total_pupils: 58 }),
|
||||
makeResult({ year: 2023, rwm_expected_pct: 56, reading_expected_pct: 70, writing_expected_pct: 64, maths_expected_pct: 67, total_pupils: 60 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 58, rwm_high_pct: 9,
|
||||
reading_expected_pct: 72, writing_expected_pct: 66, maths_expected_pct: 69,
|
||||
reading_progress: 0.4, writing_progress: -0.2, maths_progress: 1.1,
|
||||
disadvantaged_pct: 24.5, eal_pct: 22.1, sen_support_pct: 13.2,
|
||||
total_pupils: 60,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2023-05-17',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 2,
|
||||
quality_of_education: 2,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 2,
|
||||
leadership_management: 2,
|
||||
previous_overall: 3,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024 }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2022, first_preference_offer_pct: 71 }),
|
||||
makeAdmissions({ year: 2023, first_preference_offer_pct: 65 }),
|
||||
makeAdmissions({ year: 2024, first_preference_offer_pct: 60 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 2: secondary — KS4 results, Report Card Ofsted, single admissions year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const secondaryFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 234567,
|
||||
school_name: 'Test Secondary School',
|
||||
phase: 'Secondary',
|
||||
age_range: '11-16',
|
||||
school_type: 'Academy converter',
|
||||
school_type_code: 'AC',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, attainment_8_score: 44.8, progress_8_score: 0.18 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
attainment_8_score: 46.2, progress_8_score: 0.31,
|
||||
progress_8_banding: 'Above average',
|
||||
english_maths_standard_pass_pct: 68.4,
|
||||
english_maths_strong_pass_pct: 47.1,
|
||||
ebacc_entry_pct: 41.2, ebacc_standard_pass_pct: 28.6,
|
||||
ebacc_strong_pass_pct: 19.4, ebacc_avg_score: 4.31,
|
||||
disadvantaged_pct: 27.8, eal_pct: 19.3, sen_support_pct: 11.7,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'ReportCard',
|
||||
inspection_date: '2025-11-20',
|
||||
rc_inspection_date: '2025-11-20',
|
||||
// isReportCard keys off a non-empty report_card record, NOT the rc_*
|
||||
// fields — the rc_* numbers supply the values once the layout is chosen.
|
||||
report_card: {
|
||||
inclusion: { code: 2, label: 'Strong' },
|
||||
curriculum_teaching: { code: 2, label: 'Strong' },
|
||||
achievement: { code: 3, label: 'Expected standard' },
|
||||
attendance_behaviour: { code: 2, label: 'Strong' },
|
||||
personal_development: { code: 1, label: 'Exceptional' },
|
||||
leadership_governance: { code: 2, label: 'Strong' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
rc_inclusion: 2,
|
||||
rc_curriculum_teaching: 2,
|
||||
rc_achievement: 3,
|
||||
rc_attendance_behaviour: 2,
|
||||
rc_personal_development: 1,
|
||||
rc_leadership_governance: 2,
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [makeAdmissions({ year: 2024, school_phase: 'Secondary' })],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 3: all-through — BOTH key stages on the same row.
|
||||
// The highest-value fixture: isAllThrough gates the KS2 content back on for a
|
||||
// school that also has KS4 data, and this is the case most likely to break.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const allThroughFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 345678,
|
||||
school_name: 'Test All-Through School',
|
||||
phase: 'All-through',
|
||||
age_range: '4-18',
|
||||
has_sixth_form: true,
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, rwm_expected_pct: 55, attainment_8_score: 45.0 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 63, reading_expected_pct: 76,
|
||||
writing_expected_pct: 70, maths_expected_pct: 71,
|
||||
attainment_8_score: 48.7, progress_8_score: 0.42,
|
||||
english_maths_standard_pass_pct: 71.2,
|
||||
disadvantaged_pct: 20.1, eal_pct: 17.5, sen_support_pct: 9.8,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2022-10-04',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 1,
|
||||
quality_of_education: 1,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 1,
|
||||
leadership_management: 1,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2023, school_phase: 'Secondary', first_preference_offer_pct: 58 }),
|
||||
makeAdmissions({ year: 2024, school_phase: 'Secondary', first_preference_offer_pct: 52 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 4: special school — all-zero KS2 row.
|
||||
// Guards the PR #70 regression: special/PRU/AP schools must not be shown an
|
||||
// England comparison, because their pupils sit the same tests but very few
|
||||
// reach the mainstream "expected standard".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const specialFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 456789,
|
||||
school_name: 'Test Special School',
|
||||
school_type: 'Community special school',
|
||||
school_type_code: 'CYS',
|
||||
age_range: '2-19',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0,
|
||||
writing_expected_pct: 0,
|
||||
maths_expected_pct: 0,
|
||||
total_pupils: 12,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: null,
|
||||
census,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
@@ -4,11 +4,17 @@
|
||||
* 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 { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
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 { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { NationalAverages } from '@/lib/types';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -124,10 +130,18 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
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 nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
@@ -143,13 +157,30 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
||||
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| 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
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -184,28 +215,46 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
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>
|
||||
) : (
|
||||
<SchoolDetailView
|
||||
<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}
|
||||
/>
|
||||
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 0–80 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}'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' 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'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 & 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 & 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 (0–80 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 & 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 & 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'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'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'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 & 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 & 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 (0–80 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 & Maths Grade 4+</th>
|
||||
</>
|
||||
) : isSecondary ? (
|
||||
<>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>English & Maths Grade 4+</th>
|
||||
<th>English & Maths Grade 5+</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>Reading, Writing & 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 & 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' 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 & 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 & 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 & 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 & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 4+
|
||||
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 5+
|
||||
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/* Styles for SchoolDetailShell — the interactive chrome of a detail page.
|
||||
Derived from the classes the shell's JSX references; the section styles
|
||||
live in components/school/schoolSections.module.css. Classes used by both
|
||||
appear in both files, which is correct: CSS Modules hash them per-file. */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* Standalone back link, sits above the header card on the page background. */
|
||||
.topBack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.topBack:hover {
|
||||
color: var(--accent-coral-dark, #c85a3e);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* Header Section */
|
||||
.header {
|
||||
position: relative;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* With a map band above, slide the title up under the fade so map and title
|
||||
read as one object; the Compare button floats glassy over the map. */
|
||||
.headerHasMap .headerContent {
|
||||
padding-top: 0;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* The title (not the whole content row) rises above the map fade. Keeping
|
||||
.headerContent unpositioned matters: .actions must anchor to .header so it
|
||||
floats over the map band, not over the title. */
|
||||
.headerHasMap .titleSection {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 6;
|
||||
margin: 0;
|
||||
/* Beat the mobile `.actions { width: 100% }` rule — a floating button
|
||||
must never stretch across the title. */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
border-color: transparent;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* Full label by default; phones over the map get an icon-only button
|
||||
(same compact treatment as the section-nav compare icon). */
|
||||
.btnCompareGlyph {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Inline "View on map ↗" trigger next to the address. */
|
||||
.mapLink {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mapLink:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.titleSection {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.schoolName {
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
|
||||
.address {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
/* Expanded header details (headteacher, website, trust, pupils) */
|
||||
.headerDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
|
||||
|
||||
.headerDetail strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail a {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
/* "Show all details" reveal — only rendered on mobile/tablet, where the
|
||||
header details block is collapsed below the fold. Hidden on desktop. */
|
||||
.detailsToggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
|
||||
.btnAdd,
|
||||
.btnRemove {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
|
||||
}
|
||||
|
||||
|
||||
.btnAdd {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.btnAdd:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
.btnRemove {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.btnRemove:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
/* ── Sticky Section Navigation ──────────────────────── */
|
||||
/* Docks directly under the global header; Back and "All" stay pinned while
|
||||
only the section links scroll. */
|
||||
.sectionNav {
|
||||
position: sticky;
|
||||
top: 64px; /* global header height on desktop */
|
||||
z-index: 10;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavBack {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavBack:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
|
||||
/* The scrolling middle: section links only. */
|
||||
.sectionNavLinks {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinks::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLink:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
/* ── Mobile: the scrolling links collapse into one "section" menu button ──
|
||||
(hidden on desktop, where the links fit). */
|
||||
.sectionNavMenu {
|
||||
display: none; /* shown only ≤640px */
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-height: 38px;
|
||||
padding: 0.34rem 0.7rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuCur {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuEyebrow {
|
||||
flex: none;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuNow {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuChev {
|
||||
flex: none;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
/* Compact icon version of the Compare CTA, used on mobile. */
|
||||
.sectionNavCompareIcon {
|
||||
display: none; /* shown only ≤640px */
|
||||
position: relative;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIcon svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareBadge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
border: 1.5px solid var(--accent-coral, #e07256);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIconIn {
|
||||
background: var(--bg-card, white);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
|
||||
/* Compare CTA carried into the bar once the hero's button scrolls away. */
|
||||
.sectionNavCompare {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.34rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompare:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
border-color: var(--accent-coral-darker, #9c3f26);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn {
|
||||
background: var(--bg-card, white);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
|
||||
/* "All ▾" jump menu (desktop). */
|
||||
.sectionNavAll {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.34rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavAll:hover {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
|
||||
.sectionsBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
background: rgba(26, 22, 18, 0.28);
|
||||
}
|
||||
|
||||
|
||||
.sectionsPanel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1600;
|
||||
width: 230px;
|
||||
max-height: min(70vh, 460px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionsPanelHead {
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
padding: 0.4rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionsItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionsItem:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
|
||||
.sectionsItemActive {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionsTick {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
|
||||
/* GIAS "Open, but proposed to close" notice strip */
|
||||
.closingStrip {
|
||||
background: #fdf6e3;
|
||||
border-left: 4px solid #e2c96f;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: #6e5a00;
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.closingStrip strong {
|
||||
color: #8a6200;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.headerHasMap .actions .btnCompareLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnCompareGlyph {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd,
|
||||
.headerHasMap .actions .btnRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNav {
|
||||
top: 56px; /* global header is shorter on mobile */
|
||||
padding: 0.4rem 0.6rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavLink,
|
||||
.sectionNavBack {
|
||||
min-height: 36px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavCompare {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavAll {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* SchoolDetailShell — the interactive chrome of a school detail page.
|
||||
*
|
||||
* The ONLY large client component on the route. Everything below the sticky
|
||||
* nav is server-rendered and arrives as `children`, composed in
|
||||
* app/school/[slug]/page.tsx. That indirection is required: a server component
|
||||
* imported by a client component becomes a client component, so the sections
|
||||
* cannot be imported here.
|
||||
*
|
||||
* What stays client-side is genuinely interactive: router.back(), the header
|
||||
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
|
||||
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
|
||||
* sections with document.getElementById, so it works unchanged against
|
||||
* server-rendered children.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
||||
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
|
||||
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
|
||||
import type { NavItem } from '@/lib/schoolSections';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SchoolDetailShell.module.css';
|
||||
|
||||
|
||||
/**
|
||||
* Only what the chrome itself renders. Everything the sections need — Ofsted,
|
||||
* admissions, deprivation, finance, national averages — goes straight to the
|
||||
* section composers in page.tsx and never reaches the client.
|
||||
*/
|
||||
export interface SchoolDetailShellProps {
|
||||
schoolInfo: School;
|
||||
/** Only for the header's pupil-count fallback. */
|
||||
yearlyData: SchoolResult[];
|
||||
census: SchoolCensus | null;
|
||||
/** Section list for the sticky nav, computed on the server. */
|
||||
navItems: NavItem[];
|
||||
/** The server-rendered sections. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SchoolDetailShell({
|
||||
schoolInfo, yearlyData, census, navItems, children,
|
||||
}: SchoolDetailShellProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
// Admissions view state moved to AdmissionsViewToggle, the client island
|
||||
// inside the (server-rendered) admissions section.
|
||||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
|
||||
const heroActionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
// "All ▾" jump menu listing every section.
|
||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||
// Header details (headteacher, contact, trust, area) collapse behind a
|
||||
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||||
// fall back to search so the button never dead-ends or leaves the site.
|
||||
const handleBack = () => {
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionLinksRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const overflow = el.scrollWidth - el.clientWidth;
|
||||
// No overflow → treat as "at end" so the fade is hidden.
|
||||
if (overflow <= 1) {
|
||||
setSectionNavAtEnd(true);
|
||||
return;
|
||||
}
|
||||
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||||
};
|
||||
update();
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track whether the hero's "Add to Compare" button is still on screen.
|
||||
useEffect(() => {
|
||||
const el = heroActionsRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => setHeroCtaVisible(entry.isIntersecting),
|
||||
{ rootMargin: '-64px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Close the "All ▾" menu on Escape.
|
||||
useEffect(() => {
|
||||
if (!sectionsOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [sectionsOpen]);
|
||||
|
||||
// The chrome needs only these four. The section-shape flags are computed
|
||||
// once on the server (lib/schoolSections) and consumed by the section
|
||||
// composers; recomputing them here would duplicate that work for values
|
||||
// this component never renders.
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
const phase = schoolInfo.phase ?? '';
|
||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// Page-view event with funnel attribution. Fires once per mount.
|
||||
useEffect(() => {
|
||||
track('school_viewed', {
|
||||
urn: schoolInfo.urn,
|
||||
phase: phase || 'unknown',
|
||||
local_authority: schoolInfo.local_authority || 'unknown',
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schoolInfo.urn]);
|
||||
|
||||
// Track active section as user scrolls
|
||||
useEffect(() => {
|
||||
const ids = navItems.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
const observers: IntersectionObserver[] = [];
|
||||
const ratioMap: Record<string, number> = {};
|
||||
|
||||
const pickActive = () => {
|
||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||
};
|
||||
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
ratioMap[id] = 0;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratioMap[id] = entry.intersectionRatio;
|
||||
pickActive();
|
||||
},
|
||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
observers.push(obs);
|
||||
});
|
||||
|
||||
return () => observers.forEach(o => o.disconnect());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navItems.map(n => n.id).join(',')]);
|
||||
|
||||
// Label shown in the mobile "section" menu button — the section in view.
|
||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Standalone back link, above the header — returns to wherever the
|
||||
user came from. Scrolls away with the page (the sticky bar keeps a
|
||||
"back to top" control in its place). */}
|
||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* Header — the location map band blends down into the school title. */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.meta}>
|
||||
{schoolInfo.local_authority && (
|
||||
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
||||
)}
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{isAllThrough && (
|
||||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.metaItem}>{schoolInfo.gender}'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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
+322
-1
@@ -147,6 +147,7 @@
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@@ -168,6 +169,7 @@
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: 0 0 0.75rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Expanded header details (headteacher, website, trust, pupils) */
|
||||
@@ -711,6 +713,8 @@
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
@@ -768,6 +772,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metricHint {
|
||||
@@ -1210,7 +1216,8 @@
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Pills wrap horizontally instead of stacking — short tokens like
|
||||
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
||||
@@ -1591,6 +1598,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.historyToggle::-webkit-details-marker {
|
||||
@@ -1622,3 +1630,316 @@
|
||||
.closingStrip strong {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const customJestConfig = {
|
||||
'**/__tests__/**/*.[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: [
|
||||
'app/**/*.{js,jsx,ts,tsx}',
|
||||
'components/**/*.{js,jsx,ts,tsx}',
|
||||
|
||||
@@ -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
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
|
||||
@@ -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 0–80 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 0–80 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;
|
||||
}
|
||||
Generated
+2
-23
@@ -21,7 +21,6 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"swr": "^2.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -4150,7 +4149,9 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -9154,19 +9155,6 @@
|
||||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -9627,15 +9615,6 @@
|
||||
"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": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"swr": "^2.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user