Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 21c4add408 fix(e2e): assert all-through phases via visible sub-headings, not tooltip-colliding text
The Batch E all-through journey used `#results getByText('Attainment 8').first()`,
which matched 14 nodes — mostly hidden MetricTooltip labels — so `.first()`
landed on a visibility:hidden node and `toBeVisible()` failed on staging.

Assert instead on the unique, visible all-through sub-headings
("Primary — KS2 SATs" / "Secondary — GCSEs"), which directly prove each
phase block rendered. Full suite green against staging (17/17).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 18:18:52 +01:00
38 changed files with 352 additions and 2032 deletions
-4
View File
@@ -807,10 +807,6 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
# Per-subject higher-standard nationals: reading/maths reach the "higher
# standard" in the tests; writing is teacher-assessed at "greater depth"
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress",
@@ -25,11 +25,6 @@ class _Ks2Row:
year = LATEST
rwm_expected_pct = 62.1
gps_expected_pct = 72.0
# Per-subject higher-standard nationals — reading/maths reach the higher
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
reading_high_pct = 29.0
writing_gd_pct = 13.0
maths_high_pct = 24.0
class _Ks4Row:
@@ -89,16 +84,6 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
# The SATs chart compares each bar to its own benchmark, so the per-subject
# higher-standard / greater-depth nationals must reach the payload — not
# only the combined rwm_high_pct.
body = payload(_StubSession)
assert body["primary"]["reading_high_pct"] == 29.0
assert body["primary"]["writing_gd_pct"] == 13.0
assert body["primary"]["maths_high_pct"] == 24.0
def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series.
@@ -1,739 +0,0 @@
# Info Popover 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:** Replace the two broken metric-help affordances (the off-viewport `MetricTooltip` bubble and the native-`title` compare `?` help) with one `@floating-ui/react`-backed `InfoPopover` that stays on-screen in any viewport and shows a custom (non-native) tooltip on desktop.
**Architecture:** One shared engine (`InfoPopover`) owns all Floating UI positioning and interactions. Two existing components become thin adapters over it: `MetricTooltip` keeps its public API (so ~24 detail-page call sites are untouched), and `RowLabel` in `sectionShared.tsx` swaps its native-`title` span for `InfoPopover` (so ~14 compare `tip=` call sites are untouched).
**Tech Stack:** Next.js 16, React 19, TypeScript, `@floating-ui/react`, CSS Modules, Jest + jsdom + Testing Library, Playwright (e2e).
## Global Constraints
- All work happens in `nextjs-app/` unless a path says otherwise. Commands below assume the working directory is `nextjs-app/`.
- Path alias: `@/``nextjs-app/` root (jest `moduleNameMapper` and tsconfig).
- Jest test locations: files under `__tests__/**` or named `*.test.tsx`. Env is `jest-environment-jsdom`.
- Trigger glyph is the circled **?** everywhere (replaces ⓘ on detail pages).
- Trigger is a real `<button>` with a min 24px tap target (WCAG 2.5.8) and an accessible name.
- `InfoPopover` renders `null` when it has no `plain` content (preserves current `MetricTooltip` behaviour).
- Positioning/interaction is delegated to Floating UI — do not hand-roll positioning, outside-click, or Escape handling.
- Run `npx tsc --noEmit` and `npx jest` green before every commit that changes code.
- Never push to `main`; this plan's branch is `feat/info-popover-tooltip`.
---
### Task 1: Add the `@floating-ui/react` dependency
**Files:**
- Modify: `nextjs-app/package.json` (dependencies)
- Modify: `nextjs-app/package-lock.json` (generated)
**Interfaces:**
- Consumes: nothing.
- Produces: `@floating-ui/react` importable — the hooks `useFloating`, `autoUpdate`, `offset`, `flip`, `shift`, `arrow`, `useHover`, `useFocus`, `useClick`, `useDismiss`, `useRole`, `useInteractions`, and the component `FloatingPortal`, `FloatingArrow`.
- [ ] **Step 1: Install the package**
Run:
```bash
npm install @floating-ui/react@^0.27.0
```
Expected: `package.json` gains `"@floating-ui/react": "^0.27.0"` under `dependencies`; `package-lock.json` updates; exit 0.
- [ ] **Step 2: Verify it resolves**
Run:
```bash
node -e "const f=require('@floating-ui/react'); console.log(typeof f.useFloating, typeof f.FloatingPortal, typeof f.useInteractions)"
```
Expected: `function function function`
- [ ] **Step 3: Typecheck still passes**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "build(deps): add @floating-ui/react for the info popover"
```
---
### Task 2: Build `InfoPopover` (the shared engine) with unit tests
**Files:**
- Create: `nextjs-app/components/InfoPopover.tsx`
- Create: `nextjs-app/components/InfoPopover.module.css`
- Test: `nextjs-app/__tests__/components/InfoPopover.test.tsx`
**Interfaces:**
- Consumes: `@floating-ui/react` (Task 1).
- Produces:
```ts
export interface InfoPopoverProps {
label?: string; // bold heading
plain?: string; // body text (primary explanation)
detail?: string; // muted supplementary line
ariaLabel?: string; // accessible name for the trigger button
}
export function InfoPopover(props: InfoPopoverProps): JSX.Element | null;
```
Behaviour: renders `null` if `plain` is falsy. Otherwise renders a `<button>`
(the circled `?`) with `aria-label={ariaLabel ?? 'More information'}` and
`aria-expanded`. When open, a portalled `role="tooltip"` container shows
`label` (if any), `plain`, and `detail` (if any). Opens on hover (100ms
open / 0ms close) and focus on desktop, on click for touch; closes on
outside-press and Escape.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/InfoPopover.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfoPopover } from '@/components/InfoPopover';
describe('InfoPopover', () => {
it('renders nothing when there is no plain content', () => {
const { container } = render(<InfoPopover label="X" />);
expect(container).toBeEmptyDOMElement();
});
it('renders a labelled, collapsed trigger button', () => {
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
const btn = screen.getByRole('button', { name: 'Reading score' });
expect(btn).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('opens on click and shows label, plain and detail', async () => {
const user = userEvent.setup();
render(
<InfoPopover
label="Reading, Writing & Maths"
plain="% reaching the expected standard"
detail="National average ~60%"
ariaLabel="RWM"
/>,
);
await user.click(screen.getByRole('button', { name: 'RWM' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Reading, Writing & Maths');
expect(tip).toHaveTextContent('% reaching the expected standard');
expect(tip).toHaveTextContent('National average ~60%');
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
it('closes again on a second click', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
const btn = screen.getByRole('button', { name: 'Info' });
await user.click(btn);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.click(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('closes on Escape', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
await user.click(screen.getByRole('button', { name: 'Info' }));
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('defaults the accessible name when no ariaLabel is given', () => {
render(<InfoPopover plain="body" />);
expect(
screen.getByRole('button', { name: 'More information' }),
).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/InfoPopover.test.tsx
```
Expected: FAIL — `Cannot find module '@/components/InfoPopover'`.
- [ ] **Step 3: Write the component**
Create `nextjs-app/components/InfoPopover.tsx`:
```tsx
'use client';
import { useRef, useState } from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingArrow,
} from '@floating-ui/react';
import styles from './InfoPopover.module.css';
export interface InfoPopoverProps {
label?: string;
plain?: string;
detail?: string;
ariaLabel?: string;
}
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef, padding: 8 }),
],
});
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
// so tap and hover never double-fire.
const hover = useHover(context, { delay: { open: 100, close: 0 } });
const focus = useFocus(context);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
click,
dismiss,
role,
]);
if (!plain) return null;
return (
<>
<button
type="button"
ref={refs.setReference}
className={styles.icon}
aria-label={ariaLabel ?? 'More information'}
aria-expanded={open}
{...getReferenceProps()}
>
?
</button>
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
className={styles.tooltip}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
{label && <span className={styles.label}>{label}</span>}
<span className={styles.plain}>{plain}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</div>
</FloatingPortal>
)}
</>
);
}
```
- [ ] **Step 4: Write the styles**
Create `nextjs-app/components/InfoPopover.module.css`:
```css
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin: -6px 0;
padding: 0;
border: none;
background: none;
/* font-size:0 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
/* The visible affordance: a small circled "?" centred in the 24px target. */
.icon::before {
content: '?';
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 0.65rem;
}
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/InfoPopover.test.tsx
```
Expected: PASS (6 tests).
- [ ] **Step 6: Typecheck**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 7: Commit**
```bash
git add components/InfoPopover.tsx components/InfoPopover.module.css __tests__/components/InfoPopover.test.tsx
git commit -m "feat(ui): add InfoPopover — viewport-aware metric help via Floating UI"
```
---
### Task 3: Reduce `MetricTooltip` to a thin adapter over `InfoPopover`
**Files:**
- Modify: `nextjs-app/components/MetricTooltip.tsx` (full rewrite of body)
- Delete: `nextjs-app/components/MetricTooltip.module.css`
- Test: `nextjs-app/__tests__/components/MetricTooltip.test.tsx` (create)
**Interfaces:**
- Consumes: `InfoPopover` (Task 2), `METRIC_EXPLANATIONS` from `@/lib/metrics`.
- Produces: `MetricTooltip` with unchanged public props
`{ metricKey?: string; label?: string; plain?: string; detail?: string }`.
Resolves `metricKey` → `METRIC_EXPLANATIONS[metricKey]`, with explicit
`label`/`plain`/`detail` props overriding the looked-up values. Passes the
resolved label as `InfoPopover`'s `ariaLabel`.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/MetricTooltip.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MetricTooltip } from '@/components/MetricTooltip';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
describe('MetricTooltip', () => {
it('resolves content from a metricKey', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const exp = METRIC_EXPLANATIONS[key];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} />);
await user.click(screen.getByRole('button', { name: exp.label }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent(exp.plain);
});
it('renders nothing for an unknown metricKey with no explicit content', () => {
const { container } = render(<MetricTooltip metricKey="__nope__" />);
expect(container).toBeEmptyDOMElement();
});
it('lets explicit props override the looked-up explanation', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} plain="custom text" />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/MetricTooltip.test.tsx
```
Expected: FAIL — the old `MetricTooltip` renders a `.tooltip` span even when
collapsed (no `role="tooltip"` gating on open) / different structure, so
`findByRole('tooltip')` after click and the empty-render assertion diverge from
the new contract. (If any assertion happens to pass against the old component,
it still must fail overall before Step 3.)
- [ ] **Step 3: Rewrite the component**
Replace the entire contents of `nextjs-app/components/MetricTooltip.tsx` with:
```tsx
'use client';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import { InfoPopover } from './InfoPopover';
interface MetricTooltipProps {
metricKey?: string;
label?: string;
plain?: string;
detail?: string;
}
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const resolvedLabel = label ?? explanation?.label;
return (
<InfoPopover
label={resolvedLabel}
plain={plain ?? explanation?.plain}
detail={detail ?? explanation?.detail}
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
/>
);
}
```
- [ ] **Step 4: Delete the now-unused stylesheet**
Run:
```bash
git rm components/MetricTooltip.module.css
```
Expected: file staged for deletion. (The old component was its only importer;
`InfoPopover.module.css` supersedes it.)
- [ ] **Step 5: Verify nothing else imports the deleted CSS**
Run:
```bash
grep -rn "MetricTooltip.module.css" components app lib || echo "no importers"
```
Expected: `no importers`.
- [ ] **Step 6: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/MetricTooltip.test.tsx
```
Expected: PASS (3 tests).
- [ ] **Step 7: Typecheck**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 8: Commit**
```bash
git add components/MetricTooltip.tsx __tests__/components/MetricTooltip.test.tsx
git commit -m "refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)"
```
---
### Task 4: Swap the compare `?` help (`RowLabel`) to `InfoPopover`
**Files:**
- Modify: `nextjs-app/components/compare/sectionShared.tsx` (`RowLabel`)
- Modify: `nextjs-app/components/compare/compareSections.module.css` (remove `.help`)
- Test: `nextjs-app/__tests__/components/sectionShared.test.tsx` (create)
**Interfaces:**
- Consumes: `InfoPopover` (Task 2).
- Produces: `RowLabel({ children, tip })` renders the label text plus, when
`tip` is set, an `InfoPopover` with `plain={tip}`. `ariaLabel` is omitted, so
the trigger uses `InfoPopover`'s default accessible name `"More information"`
(the row label is arbitrary `ReactNode`, so there is no clean string to derive
a per-row name from). `Measure` and all compare `tip=` call sites are
unchanged.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/sectionShared.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RowLabel } from '@/components/compare/sectionShared';
describe('RowLabel', () => {
it('renders its label text', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
});
it('shows no help affordance when no tip is given', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('opens the tip in a popover on click', async () => {
const user = userEvent.setup();
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'Average GCSE score across 8 subjects',
);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/sectionShared.test.tsx
```
Expected: FAIL — the current `RowLabel` renders a `<span title=...>` (not a
`button`/`role="tooltip"`), so the click test fails.
- [ ] **Step 3: Update `RowLabel`**
In `nextjs-app/components/compare/sectionShared.tsx`, add the import near the
other imports:
```tsx
import { InfoPopover } from '@/components/InfoPopover';
```
Then replace the `RowLabel` function:
```tsx
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
return (
<div className={styles.rowLabel}>
{children}
{tip && <InfoPopover plain={tip} />}
</div>
);
}
```
- [ ] **Step 4: Remove the dead `.help` style**
In `nextjs-app/components/compare/compareSections.module.css`, delete the entire
`.help { … }` rule (the `display: inline-flex; width: 15px; … flex: none;`
block — the circled-`?` styling now lives in `InfoPopover.module.css`).
- [ ] **Step 5: Confirm `.help` is unreferenced**
Run:
```bash
grep -rn "styles.help\|\.help\b" components/compare || echo "no references"
```
Expected: `no references`.
- [ ] **Step 6: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/sectionShared.test.tsx
```
Expected: PASS (3 tests).
- [ ] **Step 7: Full unit suite + typecheck**
Run:
```bash
npx tsc --noEmit && npx jest
```
Expected: typecheck clean; all suites pass (existing + the 3 new suites).
- [ ] **Step 8: Commit**
```bash
git add components/compare/sectionShared.tsx components/compare/compareSections.module.css __tests__/components/sectionShared.test.tsx
git commit -m "refactor(compare): row-label help uses InfoPopover, not native title"
```
---
### Task 5: E2E regression guard — popover stays within the mobile viewport
**Files:**
- Modify: `nextjs-app/../e2e/tests/journeys.spec.ts` (add one test)
**Interfaces:**
- Consumes: the running app (staging/local baseURL), the `twoPrimaryUrns`
helper already defined in `journeys.spec.ts`.
- Produces: a Playwright test asserting an opened compare help popover's
bounding box is fully within the viewport on a narrow screen.
- [ ] **Step 1: Add the failing-guard test**
Append to `nextjs-app/../e2e/tests/journeys.spec.ts` (i.e. `e2e/tests/journeys.spec.ts`):
```ts
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
// The metric-help triggers are the circled-"?" buttons in the row labels.
// "More information" is InfoPopover's default accessible name.
const help = page.getByRole('button', { name: 'More information' }).first();
await expect(help).toBeVisible({ timeout: 15_000 });
await help.click();
const tip = page.getByRole('tooltip');
await expect(tip).toBeVisible();
// The whole bubble must sit inside the viewport — the original bug pushed it
// off the right edge with no way to scroll to it.
const box = await tip.boundingBox();
const width = page.viewportSize()!.width;
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
// And the page must not have gained a horizontal scrollbar from the bubble.
const bodyOverflowsX = await page
.locator('body')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false);
});
```
- [ ] **Step 2: Lint/typecheck the e2e file**
Run (from `e2e/`):
```bash
cd ../e2e && npx tsc --noEmit -p . 2>/dev/null || npx tsc --noEmit journeys 2>/dev/null; cd ../nextjs-app
```
Expected: no type errors reported for `journeys.spec.ts`. (If the e2e package
has no standalone tsconfig, this is a no-op; the CI Playwright run type-checks
on execution.)
- [ ] **Step 3: Note on running e2e**
The e2e journeys run against a deployed environment (staging) in CI, per
`CLAUDE.md`; they are not run locally here (no local server). This test will
execute in the staging gate after merge. Do not attempt to start a local server.
- [ ] **Step 4: Commit**
```bash
git add ../e2e/tests/journeys.spec.ts
git commit -m "test(e2e): compare help popover stays within the mobile viewport"
```
---
### Task 6: Final verification and PR
**Files:** none (verification + PR).
- [ ] **Step 1: Full typecheck + unit suite**
Run (from `nextjs-app/`):
```bash
npx tsc --noEmit && npx jest 2>&1 | tail -8
```
Expected: typecheck clean; all suites pass.
- [ ] **Step 2: Production build sanity (catches client/server boundary issues)**
Run:
```bash
npx next build 2>&1 | tail -20
```
Expected: build completes without errors. (`InfoPopover` is a client
component — `'use client'` — so this confirms the portal usage compiles.)
- [ ] **Step 3: Confirm no stragglers reference removed APIs**
Run:
```bash
grep -rn "MetricTooltip.module.css\|styles.help\|title={tip}" components app || echo "clean"
```
Expected: `clean`.
- [ ] **Step 4: Push and open the PR**
```bash
git push -u origin feat/info-popover-tooltip
```
Then open a Gitea PR (base `main`, head `feat/info-popover-tooltip`) via the
credential-helper + API pattern used in this repo, summarising: the two bugs
fixed (off-viewport mobile bubble; slow native-`title` desktop hover), the
unified `InfoPopover` approach, the circled-`?` standardisation, the new
`@floating-ui/react` dependency, and the mobile-viewport e2e guard.
```
@@ -1,125 +0,0 @@
# Info Popover — unified metric-help affordance
**Date:** 2026-07-22
**Status:** Approved (design)
## Problem
Two different "info affordance" patterns explain metrics across the app, and both are broken:
1. **`MetricTooltip` (ⓘ)** — used on the two detail pages (`SchoolDetailView`,
`SecondarySchoolDetailView`). Its bubble is `position: absolute` with a fixed
`220px` width and no viewport-collision detection. Near a screen edge on
mobile the bubble renders **partly or wholly off-viewport with no way to
scroll to it** — effectively unusable. A `left: -12px` mobile hack only
shifts the problem, it doesn't solve it.
2. **Compare-section `?` help** — every compare section funnels through
`RowLabel` in `components/compare/sectionShared.tsx`, which uses the **native
`title=` attribute**. On desktop the native tooltip has a long, unconfigurable
hover delay; on touch it barely surfaces at all.
We want a single component that positions itself correctly in any viewport and
shows a custom (non-native) tooltip on desktop.
## Decisions
- **Positioning: `@floating-ui/react`** (industry standard, ~10KB gzipped, React
19 compatible). Chosen over a hand-rolled portal + JS positioning because the
current hand-rolled approach is exactly what failed, and Floating UI already
solves flip/shift/portal/interactions/ARIA.
- **Mobile presentation: repositioning popover** (not a bottom-sheet). Same small
bubble as desktop; Floating UI's `shift`/`flip` keep it fully on-screen. One
presentation to build and maintain, consistent across platforms.
- **Glyph: standardise on the circled `?`** everywhere (replaces ⓘ on the detail
pages). Matches the common "help" convention.
- **Bundle:** adding `@floating-ui/react` as a runtime dependency is accepted.
## Architecture
One shared engine, two thin adapters — **no call-site churn**.
### `InfoPopover` (new — `components/InfoPopover.tsx`)
Owns all behaviour via `@floating-ui/react`.
- **Trigger:** a real `<button>` (min 24px tap target — WCAG 2.5.8 —
keyboard-focusable) showing the circled `?` glyph. Muted colour; hover / focus
/ open → accent colour.
- **Positioning middleware:** `offset(6)` + `flip()` + `shift({ padding: 8 })`
so the bubble flips above/below and slides sideways to **always stay fully
on-screen** (the direct fix for the off-viewport bug). An `arrow()` element
tracks the trigger.
- **Portal:** rendered inside a `FloatingPortal` so it escapes the compare
grid's `overflow`/`transform` clipping contexts.
- **Interactions** (merged via `useInteractions`, one code path for both
platforms):
- `useHover(context, { delay: { open: 100, close: 0 } })` — short open delay
fixes the slow native-`title` hover; Floating UI disables hover on touch
devices so it doesn't double-fire with tap.
- `useFocus` — keyboard focus reveals it.
- `useClick` — tap toggles on touch.
- `useDismiss` — outside-press and `Escape` close it (opening another popover
closes the first, since the outside-press lands on the new trigger).
- `useRole(context, { role: 'tooltip' })` — ARIA wiring.
- **Content:** optional `label` (bold), `plain` (body), `detail` (muted) — same
content shape as today.
**Props:**
```ts
interface InfoPopoverProps {
label?: string; // bold heading
plain?: string; // body text (the primary explanation)
detail?: string; // muted supplementary line
/** Accessible name for the trigger button, e.g. the metric label. */
ariaLabel?: string;
}
```
Renders `null` when there is no `plain` content (mirrors current
`MetricTooltip` behaviour).
### Adapter 1 — `MetricTooltip` (public API unchanged)
Keeps its existing props (`metricKey`, `label`, `plain`, `detail`), still
resolves `metricKey``METRIC_EXPLANATIONS`, and renders `InfoPopover` with the
resolved content. **All ~24 detail-page call sites are untouched.** The
hand-rolled positioning/dismiss `useEffect` and the `.tooltip` absolute-position
CSS (including the `left: -12px` mobile hack) are deleted.
### Adapter 2 — `RowLabel` (in `sectionShared.tsx`)
Replaces the native-`title` `?` span with `InfoPopover`, passing the `tip`
string as `plain` and the row label as `ariaLabel`. **All ~14 compare `tip=`
call sites are untouched** (they pass through `Measure``RowLabel`).
## Removed / replaced
- `MetricTooltip`'s hand-rolled open/dismiss `useEffect`.
- `MetricTooltip.module.css` `.tooltip` absolute positioning + arrow + the
`@media (max-width: 640px)` `left: -12px` hack (superseded by Floating UI).
- Native `title=` on the compare `?` help span.
## Testing
1. **Unit (jest + jsdom)**`InfoPopover`:
- trigger button renders with the correct `aria-label` and `aria-expanded`;
- click opens and closes it;
- `label` / `plain` / `detail` render when open;
- `Escape` closes it.
Positioning (flip/shift) is Floating UI's own tested concern, not re-tested
here.
2. **E2E (Playwright, mobile viewport)** — the regression guard for the reported
bug. On a narrow viewport, open a help popover near the **right edge** of a
compare section and assert its bounding box is fully within the viewport
(`rect.right <= innerWidth && rect.left >= 0`). This is the check that would
have caught the original overflow. Satisfies the CLAUDE.md rule to extend
`e2e/` when user-facing behaviour changes.
## Out of scope
- Other `title=` usages (button/link hints, empty-state props) — these are not
metric explainers and keep native `title`.
- No bottom-sheet / alternate mobile presentation.
+3 -68
View File
@@ -172,35 +172,15 @@ test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not ju
// Labelled as all-through in the hero meta.
await expect(page.getByText(/All-through/i).first()).toBeVisible();
// The combined results section carries both phases.
// The combined results section carries both phases, each under its own
// all-through sub-heading (a unique visible node — unlike "Attainment 8",
// which also appears in hidden MetricTooltip labels).
const results = page.locator('#results');
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block
});
test('a special school is not shown as failing against the mainstream England average', async ({ page }) => {
// Special schools sit the same tests but very few pupils reach the mainstream
// "expected standard", so a "0.0% · 62 pts below England average" rendering
// portrays them as failing against a benchmark that doesn't fit. The results
// section drops the England comparison and explains the context instead.
const SP_URN = 101099; // Greenmead School — a community special school
const res = await page.request.get(`/api/schools/${SP_URN}`);
expect(res.ok()).toBeTruthy();
const detail = await res.json();
test.skip(
!/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''),
'precondition: chosen URN must currently be a special school',
);
await page.goto(`/school/${SP_URN}`);
const results = page.locator('#results');
// The special-school context note is shown…
await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 });
// …and the mainstream England-average comparison is dropped entirely.
await expect(results.getByText(/England avg/i)).toHaveCount(0);
});
test('school with no performance data still gets a working detail page', async ({ page }) => {
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
@@ -438,18 +418,6 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('admissions guide renders its key milestones', async ({ page }) => {
// Static content page — assert the guide loads and the load-bearing
// milestones parents rely on are present (dates are statutory, so these
// strings are stable invariants, not data-refresh-sensitive).
await page.goto('/admissions');
await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible();
// National Offer Day is the milestone the whole guide builds toward.
await expect(page.getByText(/National Offer Day/i).first()).toBeVisible();
});
test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
@@ -478,36 +446,3 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
// The metric-help triggers are the circled-"?" buttons in the row labels.
// "More information" is InfoPopover's default accessible name.
const help = page.getByRole('button', { name: 'More information' }).first();
await expect(help).toBeVisible({ timeout: 15_000 });
await help.click();
const tip = page.getByRole('tooltip');
await expect(tip).toBeVisible();
// The whole bubble must sit inside the viewport — the original bug pushed it
// off the right edge with no way to scroll to it.
const box = await tip.boundingBox();
const width = page.viewportSize()!.width;
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
// And the page must not have gained a horizontal scrollbar from the bubble.
const bodyOverflowsX = await page
.locator('body')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false);
});
@@ -1,64 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfoPopover } from '@/components/InfoPopover';
describe('InfoPopover', () => {
it('renders nothing when there is no plain content', () => {
const { container } = render(<InfoPopover label="X" />);
expect(container).toBeEmptyDOMElement();
});
it('renders a labelled, collapsed trigger button', () => {
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
const btn = screen.getByRole('button', { name: 'Reading score' });
expect(btn).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('opens on click and shows label, plain and detail', async () => {
const user = userEvent.setup();
render(
<InfoPopover
label="Reading, Writing & Maths"
plain="% reaching the expected standard"
detail="National average ~60%"
ariaLabel="RWM"
/>,
);
await user.click(screen.getByRole('button', { name: 'RWM' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Reading, Writing & Maths');
expect(tip).toHaveTextContent('% reaching the expected standard');
expect(tip).toHaveTextContent('National average ~60%');
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
it('closes again on a second click', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
const btn = screen.getByRole('button', { name: 'Info' });
await user.click(btn);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.click(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('closes on Escape', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
await user.click(screen.getByRole('button', { name: 'Info' }));
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('defaults the accessible name when no ariaLabel is given', () => {
render(<InfoPopover plain="body" />);
expect(
screen.getByRole('button', { name: 'More information' }),
).toBeInTheDocument();
});
});
@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MetricTooltip } from '@/components/MetricTooltip';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
describe('MetricTooltip', () => {
it('resolves content from a metricKey', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const exp = METRIC_EXPLANATIONS[key];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} />);
// Collapsed by default — the popover content is not in the DOM until opened
// (the old component left an always-present role="tooltip" span behind).
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: `What does ${exp.label} mean?` }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent(exp.plain);
});
it('renders nothing for an unknown metricKey with no explicit content', () => {
const { container } = render(<MetricTooltip metricKey="__nope__" />);
expect(container).toBeEmptyDOMElement();
});
it('lets explicit props override the looked-up explanation', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} plain="custom text" />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
});
});
@@ -1,33 +0,0 @@
/**
* SpecialSchoolNote Component Tests
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SpecialSchoolNote } from '@/components/SpecialSchoolNote';
describe('SpecialSchoolNote', () => {
it('renders nothing for a mainstream school', () => {
const { container } = render(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
expect(container).toBeEmptyDOMElement();
});
it('describes a special school as teaching pupils with SEND', () => {
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
expect(screen.getByText(/This is a special school/i)).toBeInTheDocument();
expect(screen.getByText(/special educational needs/i)).toBeInTheDocument();
});
it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => {
render(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument();
expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument();
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
});
it('does NOT call alternative-provision pupils SEND', () => {
render(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
});
});
@@ -1,24 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RowLabel } from '@/components/compare/sectionShared';
describe('RowLabel', () => {
it('renders its label text', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
});
it('shows no help affordance when no tip is given', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('opens the tip in a popover on click', async () => {
const user = userEvent.setup();
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'Average GCSE score across 8 subjects',
);
});
});
-38
View File
@@ -269,44 +269,6 @@ describe('isProposedToClose', () => {
});
});
describe('isSpecialSchool', () => {
const { isSpecialSchool } = require('@/lib/utils');
it('matches every DfE special-school establishment type', () => {
for (const t of [
'Community special school',
'Foundation special school',
'Academy special converter',
'Academy special sponsor led',
'Non-maintained special school',
'Free schools special',
'Other independent special school',
]) {
expect(isSpecialSchool({ school_type: t })).toBe(true);
}
});
it('matches pupil referral units and alternative provision', () => {
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
});
it('does not match mainstream types (no false positives)', () => {
for (const t of [
'Academy converter',
'Community school',
'Voluntary aided school',
'Free schools',
'University technical college',
'Other independent school',
null,
undefined,
]) {
expect(isSpecialSchool({ school_type: t })).toBe(false);
}
});
});
describe('shortName', () => {
it('drops the trailing establishment-type words', () => {
expect(shortName('Barclay Primary School')).toBe('Barclay');
-8
View File
@@ -85,14 +85,6 @@ export default function RootLayout({
data-performance="true"
strategy="afterInteractive"
/>
{/* Heatmap / session recorder — same data-domains guard so staging
(same image, different host) never feeds the heatmap. */}
<Script
src="https://analytics.schoolcompare.co.uk/recorder.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
strategy="afterInteractive"
/>
</head>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" />
@@ -442,58 +442,6 @@
line-height: 1;
}
/* ─── Easy-to-miss callouts ──────────────────────────── */
.calloutGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.7rem;
}
.callout {
display: flex;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
border: 1px solid rgba(201, 162, 39, 0.28);
border-left: 3px solid var(--accent-gold, #c9a227);
border-radius: 8px;
}
.calloutIcon {
flex-shrink: 0;
width: 1.05rem;
height: 1.05rem;
margin-top: 0.1rem;
border-radius: 50%;
background: var(--accent-gold, #c9a227);
color: #fff;
font-size: 0.72rem;
font-weight: 800;
line-height: 1.05rem;
text-align: center;
}
.calloutText {
min-width: 0;
}
.calloutTitle {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-gold-text, #7a6800);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.calloutBody {
font-size: 0.82rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
/* ─── Tips ───────────────────────────────────────────── */
.tips {
+3 -48
View File
@@ -54,9 +54,6 @@ interface Step {
title: string;
body: string;
highlight?: 'deadline' | 'offer';
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
rather than buried in the body prose. */
callouts?: { title: string; body: string }[];
/* How SchoolCompare helps at this stage of the journey */
tool?: { label: string; href: string };
}
@@ -65,16 +62,6 @@ const SECONDARY_STEPS: Step[] = [
{
title: 'Check entry criteria',
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
callouts: [
{
title: 'Selective schools register earlier',
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
},
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
@@ -86,14 +73,14 @@ const SECONDARY_STEPS: Step[] = [
{
date: '31 October',
title: 'Application deadline',
body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally list schools in the genuine order you want them, not strategically.',
highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
},
{
date: '1 March',
title: 'National Offer Day',
body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' },
},
@@ -114,12 +101,6 @@ const PRIMARY_STEPS: Step[] = [
{
title: 'Research entry criteria',
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
callouts: [
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
@@ -163,7 +144,7 @@ interface Tip {
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
},
{
heading: 'Late applications go to the back',
@@ -326,19 +307,6 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
@@ -387,19 +355,6 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
+5 -5
View File
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
<div className={styles.editorialGrid}>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
and demographics each in its own table, each with its own jargon.
School performance data in England is rich but fragmented. The Department for Education publishes
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
demographics each in its own table, each with its own jargon.
</p>
<p>
SchoolCompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
full inspection report.
40-page inspection report.
</p>
</div>
<div className={styles.factbox}>
+15 -67
View File
@@ -720,13 +720,10 @@
.miniRowHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.2rem;
font-size: 0.44rem;
font-size: 0.48rem;
color: var(--text-muted, #6d685f);
text-transform: uppercase;
letter-spacing: 0.01em;
white-space: nowrap;
letter-spacing: 0.04em;
}
.miniRowHead strong {
@@ -872,77 +869,28 @@
color: var(--text-primary, #1a1612);
}
.ofstedFramework {
.ofstedBadge {
align-self: flex-start;
padding: 0.15rem 0.5rem;
padding: 0.2rem 0.55rem;
border-radius: 4px;
background: rgba(90, 58, 110, 0.12);
color: #5a3a6e;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.rcList {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.rcRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rcArea {
font-size: 0.62rem;
color: var(--text-secondary, #4a453d);
}
.rcChip {
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.52rem;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
}
.rcChipGood {
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.rcChipNeutral {
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
color: var(--accent-gold-text, #7a6800);
.ofstedVerdict {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary, #1a1612);
}
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
so it sits below the five-point list behind a divider with its own chip. */
.rcSafeguarding {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.05rem;
padding-top: 0.35rem;
border-top: 1px dashed var(--border-color, #e5dfd5);
}
.rcSep {
color: var(--text-muted, #8a847a);
font-style: italic;
}
.rcChipMet {
background: transparent;
.ofstedVerdict em {
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.4);
font-style: normal;
}
.ofstedMeta {
+1 -1
View File
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.heroSection}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated for the 2026/27 admissions round
Updated with 2026/2027 admissions results
</span>
<h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em>
+15 -37
View File
@@ -5,26 +5,15 @@
import styles from './HomeView.module.css';
export function HowItWorksSection() {
// "exc" is the school's above-expected result. DfE names this differently
// by subject: "higher standard" for the reading and maths tests, "greater
// depth" for teacher-assessed writing. "nat" is the national expected %.
const miniCascade = [
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
];
const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
];
// Report card (Ofsted's framework from Nov 2025): one rating per area of
// school life on a five-point scale. Safeguarding is judged separately, as a
// binary met/not-met outcome — kept out of this list and shown on its own.
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
{ area: 'Achievement', grade: 'Strong', good: true },
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
];
return (
@@ -40,7 +29,7 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseBlock}>
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
<div className={styles.miniCascade}>
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
{miniCascade.map(({ subj, exp, exc, nat }) => (
<div key={subj} className={styles.miniCascadeCol}>
<div className={styles.miniSubj}>{subj}</div>
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
@@ -48,7 +37,7 @@ export function HowItWorksSection() {
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
</div>
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
<div className={styles.miniTrack}>
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
</div>
@@ -60,15 +49,15 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
<div className={styles.att8Row}>
<div className={styles.att8BarWrap}>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
<div className={styles.att8Track}>
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
<div className={styles.att8Fill} style={{ width: '62%' }} />
<div className={styles.att8NatLine} style={{ left: '50%' }} />
</div>
</div>
<div className={styles.att8Score}>
<div className={styles.att8Value}>54.8</div>
<div className={styles.att8Delta}>+8.9 vs national</div>
<div className={styles.att8Value}>62.4</div>
<div className={styles.att8Delta}>+12.2 vs national</div>
</div>
</div>
</div>
@@ -88,26 +77,15 @@ export function HowItWorksSection() {
<span className={styles.ofstedBullet} />
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
</div>
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
<div className={styles.rcList}>
{reportCard.map(({ area, grade, good }) => (
<div key={area} className={styles.rcRow}>
<span className={styles.rcArea}>{area}</span>
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
</div>
))}
</div>
<div className={styles.rcSafeguarding}>
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
</div>
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
<span className={styles.ofstedBadge}>OUTSTANDING</span>
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Judgement</div>
<div className={styles.hiwTitle}>Ofsted at a glance</div>
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date and, for schools not yet reinspected, the older overall grade they still carry.</p>
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline without opening a 40-page report.</p>
</div>
</div>
@@ -1,77 +0,0 @@
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin: -6px 0;
padding: 0;
border: none;
background: none;
/* font-size:0 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
/* The visible affordance: a small circled "?" centred in the 24px target. */
.icon::before {
content: '?';
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 0.65rem;
}
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
-93
View File
@@ -1,93 +0,0 @@
'use client';
import { useRef, useState } from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingArrow,
} from '@floating-ui/react';
import styles from './InfoPopover.module.css';
export interface InfoPopoverProps {
label?: string;
plain?: string;
detail?: string;
ariaLabel?: string;
}
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef, padding: 8 }),
],
});
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
// so tap and hover never double-fire.
const hover = useHover(context, { delay: { open: 100, close: 0 } });
const focus = useFocus(context);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
click,
dismiss,
role,
]);
if (!plain) return null;
return (
<>
<button
type="button"
ref={refs.setReference}
className={styles.icon}
aria-label={ariaLabel ?? 'More information'}
aria-expanded={open}
{...getReferenceProps()}
>
?
</button>
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
className={styles.tooltip}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
{label && <span className={styles.label}>{label}</span>}
<span className={styles.plain}>{plain}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</div>
</FloatingPortal>
)}
</>
);
}
@@ -0,0 +1,114 @@
.wrapper {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 0.3em;
}
.icon {
/* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin: -6px 0;
padding: 0;
border: none;
background: none;
font-size: 0.9em;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
.wrapper:hover .icon,
.icon[aria-expanded="true"] {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: 220px;
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
pointer-events: none;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
.wrapper:hover .tooltip,
.wrapper:focus-within .tooltip,
.tooltipOpen {
visibility: visible;
opacity: 1;
}
.tooltipOpen {
pointer-events: auto;
}
/* Small arrow pointing down */
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border-color, #e8ddd4);
}
.tooltipLabel {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.tooltipPlain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.tooltipDetail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
@media (max-width: 480px) {
.tooltip {
width: 180px;
}
}
/* Anchor the bubble to open rightward on phones — icons follow their labels,
which start at the left edge, so centring pushed the bubble off-screen. */
@media (max-width: 640px) {
.tooltip {
left: -12px;
right: auto;
transform: none;
}
.tooltip::after {
left: 16px;
transform: none;
}
}
+47 -8
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import { InfoPopover } from './InfoPopover';
import styles from './MetricTooltip.module.css';
interface MetricTooltipProps {
metricKey?: string;
@@ -12,13 +13,51 @@ interface MetricTooltipProps {
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const resolvedLabel = label ?? explanation?.label;
const tooltipLabel = label ?? explanation?.label;
const tooltipPlain = plain ?? explanation?.plain;
const tooltipDetail = detail ?? explanation?.detail;
// Tap/click/keyboard toggle so the definition is reachable on touch devices
// and by keyboard, not just mouse hover (hover still works on desktop).
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (e: Event) => {
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('click', dismiss);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('click', dismiss);
document.removeEventListener('keydown', onKey);
};
}, [open]);
if (!tooltipPlain) return null;
return (
<InfoPopover
label={resolvedLabel}
plain={plain ?? explanation?.plain}
detail={detail ?? explanation?.detail}
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
/>
<span className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.icon}
aria-expanded={open}
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
onClick={() => setOpen((o) => !o)}
>
</button>
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
</span>
</span>
);
}
+3 -10
View File
@@ -118,7 +118,7 @@ export function RankingsView({
<header className={styles.header}>
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Schools ranked by {metricLabel.toLowerCase()}
Top-performing schools by {metricLabel.toLowerCase()}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p>
</header>
@@ -143,10 +143,7 @@ export function RankingsView({
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
{isProgressScore && (
<p className={styles.progressHint}>
Progress scores: 0 = national average. Positive = above average.
{isPrimary && ' KS2 progress isnt published for 2023/24 or 2024/25 (theres no key stage 1 baseline) — pick an earlier year to rank by it.'}
</p>
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
)}
{/* Filters */}
@@ -221,11 +218,7 @@ export function RankingsView({
{rankings.length === 0 ? (
<EmptyState
title="No rankings found"
message={
isPrimary && isProgressScore
? 'KS2 progress scores arent published for the most recent years (2023/24 and 2024/25) because theres no key stage 1 baseline. Select an earlier year to see progress rankings.'
: 'Try selecting a different metric, area, or year.'
}
message="Try selecting a different metric, area, or year."
action={{
label: 'Clear filters',
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
+15 -31
View File
@@ -44,46 +44,30 @@
background: var(--bg-secondary, #f3ede4);
}
/* ── Per-bar national average marker ──
Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */
.natTick {
/* ── National average marker ── */
.natLine {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-50%);
background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
top: 0;
height: calc(100% - 20px);
width: 1.5px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
z-index: 2;
pointer-events: none;
}
.natTick::before {
content: '';
.natPill {
position: absolute;
top: -3px;
left: 50%;
top: -10px;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256);
}
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
color: #fff;
font-size: 0.55rem;
font-weight: 700;
color: var(--accent-coral-dark, #b04a2e);
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
z-index: 3;
letter-spacing: 0.02em;
}
/* ── Bar rows ── */
+36 -54
View File
@@ -8,9 +8,6 @@ interface SubjectData {
expectedPct: number | null;
exceedingPct: number | null;
nationalExpectedPct: number | null;
/** National higher-standard / greater-depth benchmark for the exceeding
* bar — a different figure from the expected national. */
nationalExceedingPct: number | null;
}
interface SatsChartProps {
@@ -20,45 +17,11 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75];
/** One results bar with a national marker positioned on its own track, so the
* expected and exceeding bars each compare against their own benchmark. */
function BarRow({
label,
value,
national,
barRef,
barClass,
}: {
label: string;
value: number;
national: number | null;
barRef: React.RefObject<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
// Animate bars on mount
useEffect(() => {
@@ -88,25 +51,44 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))}
</div>
{/* Bars — each carries its own national marker */}
{/* National average marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}>
{expectedPct != null && (
<BarRow
label="Expected"
value={expectedPct}
national={nationalExpectedPct}
barRef={expectedRef}
barClass={styles.barExpected}
/>
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Expected</span>
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/>
</div>
</div>
)}
{exceedingPct != null && (
<BarRow
label="Exceeding"
value={exceedingPct}
national={nationalExceedingPct}
barRef={exceedingRef}
barClass={styles.barExceeding}
/>
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Exceeding</span>
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/>
</div>
</div>
)}
</div>
@@ -153,7 +135,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
National average
</div>
</div>
@@ -855,10 +855,7 @@
/* Charts Section */
.chartContainer {
width: 100%;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
height: 280px;
position: relative;
}
@@ -1043,41 +1040,6 @@
color: var(--accent-coral-dark, #b04a2e);
}
/* ── Ofsted grade grids (Report Card + OEIF) ──
Uniform, vertically-aligned grade chips. Labels reserve two lines so
single- and double-line labels put their chips on the same baseline;
every chip (Met, Strong, Expected standard, …) shares one font size,
padding and min-height regardless of how many lines its text wraps to. */
.gradeGrid .metricCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.75rem;
}
.gradeGrid .metricLabel {
min-height: 2.6em;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.gradeGrid .metricValue {
margin-top: auto;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 2.6em;
padding: 0.3rem 0.7rem;
border-radius: 5px;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.ofstedDisclaimer {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
+45 -87
View File
@@ -18,10 +18,9 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { summariseAdmissions } from '@/lib/compareLogic';
const PerformanceChart = dynamic(
@@ -234,27 +233,6 @@ export function SchoolDetailView({
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results;
// Special schools / PRUs / AP: their pupils sit the same tests but very few
// reach the mainstream "expected standard", so a 0% headline and an England
// comparison portray them as failing against a benchmark that doesn't fit.
const isSpecial = isSpecialSchool(schoolInfo);
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
// special/suppressed signature) is a placeholder, not a real result. This
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
// combined (some pupils met individual subjects but not all three) stays
// comparable. Attainment 8 is a single 080 score with no subject breakdown
// to form such a signature, so KS4 keys off establishment type only — a
// genuine (if extreme) 0.0 still shows its real figure and comparison.
const ks2Placeholder = latestResults != null
&& latestResults.rwm_expected_pct === 0
&& (latestResults.reading_expected_pct ?? 0) === 0
&& (latestResults.writing_expected_pct ?? 0) === 0
&& (latestResults.maths_expected_pct ?? 0) === 0;
// Whether to drop the England-average deltas / national markers / "below"
// framing on the attainment measures.
const suppressKs2Comparison = isSpecial || ks2Placeholder;
const suppressKs4Comparison = isSpecial;
// 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,
@@ -577,7 +555,7 @@ export function SchoolDetailView({
<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}`}>
<div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -623,7 +601,7 @@ export function SchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -646,22 +624,13 @@ export function SchoolDetailView({
{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.'}
{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 && (
<>
@@ -677,7 +646,7 @@ export function SchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
{primaryAvg.rwm_expected_pct != null && (
<DeltaChip
value={latestResults.rwm_expected_pct}
baseline={primaryAvg.rwm_expected_pct}
@@ -686,7 +655,7 @@ export function SchoolDetailView({
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
{primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
@@ -699,7 +668,7 @@ export function SchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
{primaryAvg.rwm_high_pct != null && (
<DeltaChip
value={latestResults.rwm_high_pct}
baseline={primaryAvg.rwm_high_pct}
@@ -708,15 +677,14 @@ export function SchoolDetailView({
/>
)}
</div>
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
{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.rwm_expected_pct != null &&
latestResults.reading_expected_pct != null &&
latestResults.writing_expected_pct != null &&
latestResults.maths_expected_pct != null && (
@@ -740,38 +708,28 @@ export function SchoolDetailView({
</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,
},
]}
/>
)}
<SatsChart
subjects={[
{
name: 'Reading',
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: primaryAvg.reading_expected_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: primaryAvg.writing_expected_pct,
},
{
name: 'Maths',
expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: primaryAvg.maths_expected_pct,
},
]}
/>
{/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
@@ -828,7 +786,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
{secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
@@ -852,7 +810,7 @@ export function SchoolDetailView({
<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 && (
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -864,7 +822,7 @@ export function SchoolDetailView({
<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 && (
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -1127,8 +1085,8 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
@@ -1141,8 +1099,8 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
@@ -1154,9 +1112,9 @@ export function SchoolDetailView({
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}
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
)}
+6 -19
View File
@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -38,23 +38,10 @@ export function SchoolRow({
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// A placeholder all-zero row (every subject 0 — a special/suppressed
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
// so its figure is hidden. A genuine 0% combined (some pupils met individual
// subjects but not all three) is NOT all-zero and stays shown.
const rwmPlaceholder =
school.rwm_expected_pct === 0 &&
(school.reading_expected_pct ?? 0) === 0 &&
(school.writing_expected_pct ?? 0) === 0 &&
(school.maths_expected_pct ?? 0) === 0;
// The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value, special schools included.
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
// The vs-England delta is a mainstream benchmark: additionally dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
// vs-national delta
const rwmDelta =
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
school.rwm_expected_pct != null && nationalAvgRwm != null
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
: null;
const handleCompareClick = () => {
@@ -100,9 +87,9 @@ export function SchoolRow({
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValue}>
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong>
{showRwmValue && school.prev_rwm_expected_pct != null && (
{school.prev_rwm_expected_pct != null && (
<span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
@@ -490,10 +490,7 @@
/* ── Charts & Map ────────────────────────────────────── */
.chartContainer {
width: 100%;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
height: 280px;
position: relative;
}
@@ -626,41 +623,6 @@
color: var(--accent-coral-dark, #b04a2e);
}
/* ── Ofsted grade grids (Report Card + OEIF) ──
Uniform, vertically-aligned grade chips. Labels reserve two lines so
single- and double-line labels put their chips on the same baseline;
every chip (Met, Strong, Expected standard, …) shares one font size,
padding and min-height regardless of how many lines its text wraps to. */
.gradeGrid .metricCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.75rem;
}
.gradeGrid .metricLabel {
min-height: 2.6em;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.gradeGrid .metricValue {
margin-top: auto;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 2.6em;
padding: 0.3rem 0.7rem;
border-radius: 5px;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.ofstedDisclaimer {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
@@ -23,9 +23,8 @@ import type {
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
@@ -108,16 +107,6 @@ export function SecondarySchoolDetailView({
const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null;
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
// so their headline attainment is far below the mainstream average by design.
// Drop the England comparison + "below" framing so the page doesn't portray
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
// single 080 score with no subject breakdown to test for a placeholder, so
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
// mainstream school still shows its real value and comparison.
const isSpecial = isSpecialSchool(schoolInfo);
const suppressComparison = isSpecial;
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
@@ -374,7 +363,7 @@ export function SecondarySchoolDetailView({
<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}`}>
<div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -419,7 +408,7 @@ export function SecondarySchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -436,7 +425,7 @@ export function SecondarySchoolDetailView({
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
@@ -463,11 +452,9 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
GCSE results for Year 11 pupils. England averages shown for comparison.
</p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
@@ -485,7 +472,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)}
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
{secondaryAvg.attainment_8_score != null && (
<DeltaChip
value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score}
@@ -494,7 +481,7 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
{secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
@@ -525,7 +512,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
{secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct}
@@ -534,7 +521,7 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -547,7 +534,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
{secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct}
@@ -556,17 +543,15 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Attainment 8 visual bar (080 scale). This viz is explicitly
"school vs national", so it's dropped for special schools where
that comparison isn't meaningful. */}
{!suppressComparison && latestResults.attainment_8_score != null && (
{/* Attainment 8 visual bar (080 scale) */}
{latestResults.attainment_8_score != null && (
<div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}>
@@ -758,8 +743,8 @@ export function SecondarySchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
nationalAtt8Avg={heroAtt8Nat}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
+2 -8
View File
@@ -11,7 +11,7 @@
'use client';
import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
@@ -55,14 +55,8 @@ export function SecondarySchoolRow({
const ofstedBadge = buildOfstedListBadge(school);
const phase = getPhaseStyle(school.phase);
const att8 = school.attainment_8_score;
// The school's own Attainment 8 is a same-school figure — shown whenever it
// exists (special schools included; their type tag on line 2 gives context).
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const laDelta =
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
? att8 - laAvgAttainment8
: null;
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school);
@@ -1,15 +0,0 @@
/* Special-school / PRU / AP context note: soft, informational (teal), not a
warning. Shared by both detail views so the styling can't drift. */
.note {
background: var(--bg-secondary, #f3ede4);
border-left: 4px solid var(--accent-teal, #2d7d7d);
border-radius: 0 6px 6px 0;
padding: 0.7rem 1rem;
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-secondary, #5c564d);
}
.note strong {
color: var(--text-primary, #1a1612);
}
@@ -1,58 +0,0 @@
/**
* SpecialSchoolNote — the context note shown on special-school / PRU / AP
* detail pages explaining why the mainstream England-average comparison is
* dropped. Renders nothing for mainstream schools.
*
* The copy is type-aware: only genuine special schools have pupils with special
* educational needs. Pupil referral units and alternative provision teach
* pupils educated outside a mainstream setting (e.g. after exclusion, or for
* medical reasons) who are not necessarily SEND — so their note says so rather
* than mischaracterising them. Suppressing the England comparison is reasonable
* for all three.
*/
import { isSpecialSchool } from '@/lib/utils';
import styles from './SpecialSchoolNote.module.css';
type SpecialKind = 'special' | 'pru' | 'ap';
function specialKind(schoolType: string | null | undefined): SpecialKind {
const t = (schoolType ?? '').toLowerCase();
if (/pupil referral/.test(t)) return 'pru';
if (/alternative provision/.test(t)) return 'ap';
return 'special';
}
export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) {
if (!isSpecialSchool(school)) return null;
const kind = specialKind(school.school_type);
return (
<div className={styles.note} role="note">
{kind === 'special' && (
<>
<strong>This is a special school.</strong> Its pupils have special educational needs and
work towards individual targets. They sit the same national assessments, but very few
reach the mainstream &ldquo;expected standard&rdquo; these measures report so a
comparison with the England average isn&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'pru' && (
<>
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
mainstream school for example after exclusion, or for medical or behavioural reasons.
The mainstream &ldquo;expected standard&rdquo; and the England-average comparison
aren&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'ap' && (
<>
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
outside a mainstream school. The mainstream &ldquo;expected standard&rdquo; and the
England-average comparison aren&rsquo;t a meaningful guide to the school.
</>
)}{' '}
Where available, the progress its pupils make is a fairer measure.
</div>
);
}
@@ -8,7 +8,6 @@
'use client';
import { latestValues, verdict } from '@/lib/compareLogic';
import { isSpecialSchool } from '@/lib/utils';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -91,19 +90,15 @@ function Strip({
urns,
schoolNames,
national,
special,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
/** Per-school special-school flag; special schools' mainstream attainment is
* not a fair comparison, so it's dropped from the strip (no dot). */
special: boolean[];
}) {
const values = latestValues(data, urns, spec.metric).map((v, i) =>
v != null && !special[i] ? Math.round(v) : null,
const values = latestValues(data, urns, spec.metric).map((v) =>
v != null ? Math.round(v) : null,
);
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor =
@@ -140,19 +135,12 @@ export function CompareAcademics({
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
// Special schools / PRUs / AP: their pupils sit the same assessments but very
// few reach the mainstream standard, so their attainment isn't a fair
// like-for-like comparison — drop it (progress banding, which IS meaningful,
// is kept).
const specialFlags = schools.map((school) => isSpecialSchool(school));
const dropSpecial = (vals: Array<number | null>) =>
vals.map((v, i) => (specialFlags[i] ? null : v));
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
const att8 = latestValues(data, urns, 'attainment_8_score');
const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
@@ -171,8 +159,8 @@ export function CompareAcademics({
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
@@ -284,7 +272,7 @@ export function CompareAcademics({
}
const national = nationalAverages?.primary;
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
@@ -319,7 +307,6 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
@@ -333,7 +320,6 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
<p className={styles.stripNote}>
@@ -132,6 +132,20 @@
color: var(--text-secondary);
}
.help {
display: inline-flex;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid var(--text-muted);
color: var(--text-muted);
font-size: 0.65rem;
align-items: center;
justify-content: center;
cursor: help;
flex: none;
}
.badge {
display: inline-block;
font-weight: 700;
@@ -11,7 +11,6 @@ import type { CSSProperties, ReactNode } from 'react';
import type { School } from '@/lib/types';
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
import { InfoPopover } from '@/components/InfoPopover';
import styles from './compareSections.module.css';
export function Section({
@@ -53,7 +52,11 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
return (
<div className={styles.rowLabel}>
{children}
{tip && <InfoPopover plain={tip} />}
{tip && (
<span className={styles.help} title={tip} aria-label={tip}>
?
</span>
)}
</div>
);
}
-21
View File
@@ -795,24 +795,3 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
export function isProposedToClose(school: { status?: string | null }): boolean {
return school.status === PROPOSED_TO_CLOSE_STATUS;
}
/**
* Special schools, pupil referral units and alternative provision teach pupils
* with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
* assessments but very few reach the mainstream "expected standard", so the
* headline attainment measures — and any comparison to the England average —
* are not a fair judgement of the school. Callers use this to drop the
* mainstream-benchmark framing (deltas, "below England", national markers)
* rather than portray these schools as failing.
*
* Detection is by establishment type: every DfE special-school type contains
* "special" (e.g. "Community special school", "Academy special converter/sponsor
* led", "Foundation special school", "Non-maintained special school", "Free
* schools special", "Other independent special school"); PRUs and alternative
* provision are matched by name. Special schools carry a mainstream `phase`
* (Primary/Secondary/All-through), so `phase` alone can't identify them.
*/
export function isSpecialSchool(school: { school_type?: string | null }): boolean {
const t = (school.school_type ?? '').toLowerCase();
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
}
-60
View File
@@ -8,7 +8,6 @@
"name": "nextjs-app",
"version": "0.1.0",
"dependencies": {
"@floating-ui/react": "^0.27.20",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
@@ -833,59 +832,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -9190,12 +9136,6 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
-1
View File
@@ -13,7 +13,6 @@
"test:coverage": "jest --coverage"
},
"dependencies": {
"@floating-ui/react": "^0.27.20",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",