Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e763e39d1 | ||
|
|
84bca53c7e | ||
|
|
15b7493b85 | ||
|
|
1d9d2eb5ae | ||
|
|
ddb42badb6 | ||
|
|
702408d6a4 | ||
|
|
09d94e513e | ||
|
|
d02a323cdc | ||
|
|
32f8a02862 | ||
|
|
7e3096c881 | ||
|
|
1f80571b1f | ||
|
|
3132f07af2 | ||
|
|
8a9ba30cc2 | ||
|
|
388b496cc5 | ||
|
|
15da060e4a | ||
|
|
5981916b82 | ||
|
|
a5b2296240 | ||
|
|
af23fbf27e | ||
|
|
22e9eb2d48 | ||
|
|
13253116c2 | ||
|
|
1b8adb91ee | ||
|
|
ca4fc647a5 | ||
|
|
261403bcd2 | ||
|
|
e4c8b86fe7 | ||
|
|
1c1df77961 | ||
|
|
993822d769 | ||
|
|
0b36eace9d | ||
|
|
de943ded19 | ||
|
|
ae6ef6860b | ||
|
|
4e316b4e38 | ||
|
|
733faefb98 | ||
|
|
5e370d09f1 | ||
|
|
f6bb037c47 | ||
|
|
9c93c3d9c2 | ||
|
|
17bd4d5a5e | ||
|
|
31ae13451a | ||
|
|
4e6be0ce65 | ||
|
|
452ec77449 | ||
|
|
aa87fa917d | ||
|
|
e36125b24a | ||
|
|
fdda52ff0a | ||
|
|
a2c63b3cda | ||
|
|
b2b2cad5ac | ||
|
|
5ec4f3f7cd | ||
|
|
74bdbebd57 | ||
|
|
b31e71ac88 | ||
|
|
b05f3bebc7 | ||
|
|
ce422e6436 | ||
|
|
2ac26acf91 | ||
|
|
3e77057567 | ||
|
|
e0d5a9969e | ||
|
|
dc21e80a5e | ||
|
|
6d3a203699 | ||
|
|
b0c5b6bb57 | ||
|
|
a1128bd801 | ||
|
|
2fd997bfe6 | ||
|
|
bdd9bef349 | ||
|
|
e5f7f4c959 | ||
|
|
60918da483 | ||
|
|
284215fbce |
@@ -67,9 +67,12 @@ jobs:
|
|||||||
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
||||||
|
|
||||||
- name: Verify the staging E2E gate passed for this commit
|
- name: Verify the staging E2E gate passed for this commit
|
||||||
|
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
|
||||||
|
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
|
||||||
|
# token has no repo scope, so the commit-status API returns 403.
|
||||||
run: |
|
run: |
|
||||||
STATUS_JSON=$(curl -fsS \
|
STATUS_JSON=$(curl -fsS \
|
||||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
||||||
echo "$STATUS_JSON" | python3 -c "
|
echo "$STATUS_JSON" | python3 -c "
|
||||||
import json, sys
|
import json, sys
|
||||||
|
|||||||
@@ -807,6 +807,10 @@ async def get_la_averages(request: Request):
|
|||||||
_KS2_NATIONAL_METRICS = [
|
_KS2_NATIONAL_METRICS = [
|
||||||
"rwm_expected_pct", "rwm_high_pct",
|
"rwm_expected_pct", "rwm_high_pct",
|
||||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_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",
|
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
||||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
"reading_progress", "writing_progress", "maths_progress",
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ _MAIN_QUERY = text("""
|
|||||||
foi.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
foi.ofsted_date,
|
foi.ofsted_date,
|
||||||
foi.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
|
foi.ofsted_rc_date,
|
||||||
l.local_authority_name AS local_authority,
|
l.local_authority_name AS local_authority,
|
||||||
l.local_authority_code,
|
l.local_authority_code,
|
||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
@@ -256,7 +257,12 @@ _MAIN_QUERY = text("""
|
|||||||
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
||||||
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
||||||
inspection_date AS ofsted_date,
|
inspection_date AS ofsted_date,
|
||||||
framework AS ofsted_framework
|
framework AS ofsted_framework,
|
||||||
|
-- Report-card signal for list/map badges: non-null only when the
|
||||||
|
-- latest inspection carries report-card grades. framework is the
|
||||||
|
-- raw event grouping ("Schools - S5"), never "ReportCard", so it
|
||||||
|
-- can't be used to detect report cards.
|
||||||
|
rc_inspection_date AS ofsted_rc_date
|
||||||
FROM marts.fact_ofsted_inspection
|
FROM marts.fact_ofsted_inspection
|
||||||
ORDER BY urn, inspection_date DESC NULLS LAST
|
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||||
) foi ON s.urn = foi.urn
|
) foi ON s.urn = foi.urn
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ SCHOOL_COLUMNS = [
|
|||||||
"ofsted_grade",
|
"ofsted_grade",
|
||||||
"ofsted_date",
|
"ofsted_date",
|
||||||
"ofsted_framework",
|
"ofsted_framework",
|
||||||
|
"ofsted_rc_date",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class _Ks2Row:
|
|||||||
year = LATEST
|
year = LATEST
|
||||||
rwm_expected_pct = 62.1
|
rwm_expected_pct = 62.1
|
||||||
gps_expected_pct = 72.0
|
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:
|
class _Ks4Row:
|
||||||
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
|||||||
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
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):
|
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||||
# No computed stand-in: the UI labels national figures as official DfE
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
# data, so an empty mart must yield an empty secondary series.
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
|
|||||||
@@ -0,0 +1,739 @@
|
|||||||
|
# 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.
|
||||||
|
```
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 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.
|
||||||
@@ -80,6 +80,33 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
|
|||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
|
||||||
|
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
|
||||||
|
// and never reached the report-card branch, so report-card schools were
|
||||||
|
// labelled by their old grade (e.g. "Outstanding · 2021"). The list now
|
||||||
|
// carries ofsted_rc_date and the badge treats a report card as winning.
|
||||||
|
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
|
||||||
|
const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const barclay = ((await res.json()).schools ?? []).find(
|
||||||
|
(s: { urn: number }) => s.urn === RC_URN,
|
||||||
|
);
|
||||||
|
// Hard assertions, not test.skip: if the backend stops exposing
|
||||||
|
// ofsted_rc_date for this report-card school, that IS the regression this
|
||||||
|
// test exists to catch, so it must fail loudly rather than skip.
|
||||||
|
expect(barclay, 'Barclay must appear in the search results').toBeTruthy();
|
||||||
|
expect(
|
||||||
|
barclay.ofsted_rc_date,
|
||||||
|
'the list must expose ofsted_rc_date for a report-card school',
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
await searchByName(page, 'Barclay Primary');
|
||||||
|
// The Barclay row must be present…
|
||||||
|
await expect(page.locator(`a[href*="${RC_URN}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
// …badged as a Report Card, not its carried-forward "Outstanding" grade.
|
||||||
|
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('school detail page renders name and performance data', async ({ page }) => {
|
test('school detail page renders name and performance data', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
const firstSchool = schoolLinks(page).first();
|
const firstSchool = schoolLinks(page).first();
|
||||||
@@ -94,6 +121,86 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
||||||
|
// Detail views detected report cards via `framework`, which the API never
|
||||||
|
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
||||||
|
// dated to a pre-Nov-2025 inspection. Detection now keys off the report_card
|
||||||
|
// object and dates it with rc_inspection_date.
|
||||||
|
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
|
||||||
|
const res = await page.request.get(`/api/schools/${RC_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const ofsted = (await res.json()).ofsted;
|
||||||
|
test.skip(
|
||||||
|
!ofsted?.report_card || Object.keys(ofsted.report_card).length === 0,
|
||||||
|
'precondition: chosen URN must currently have a report card',
|
||||||
|
);
|
||||||
|
const rcYear = new Date(ofsted.rc_inspection_date).getFullYear();
|
||||||
|
const legacyYear = new Date(ofsted.inspection_date).getFullYear();
|
||||||
|
|
||||||
|
await page.goto(`/school/${RC_URN}`);
|
||||||
|
const ofstedSection = page.locator('#ofsted');
|
||||||
|
// Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating".
|
||||||
|
await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 });
|
||||||
|
// Dating fixed: dated to the report-card inspection, never the legacy one.
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible();
|
||||||
|
if (legacyYear !== rcYear) {
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
|
||||||
|
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
|
||||||
|
// The detail view used to flip them to isSecondary and render GCSE-only,
|
||||||
|
// hiding the primary phase. It now renders both phases and labels the school
|
||||||
|
// "All-through".
|
||||||
|
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
|
||||||
|
const res = await page.request.get(`/api/schools/${AT_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const detail = await res.json();
|
||||||
|
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
|
||||||
|
detail.yearly_data ?? [];
|
||||||
|
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
|
||||||
|
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
|
||||||
|
test.skip(
|
||||||
|
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
|
||||||
|
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/school/${AT_URN}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Labelled as all-through in the hero meta.
|
||||||
|
await expect(page.getByText(/All-through/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The combined results section carries both phases.
|
||||||
|
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 }) => {
|
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
|
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||||
@@ -331,6 +438,18 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
|||||||
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
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 }) => {
|
test('rankings page loads a populated table', async ({ page }) => {
|
||||||
await page.goto('/rankings');
|
await page.goto('/rankings');
|
||||||
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
||||||
@@ -359,3 +478,36 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
|
|||||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||||
expect(await rows.count()).toBeGreaterThan(5);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Secondary academics: every headline number carries its England anchor and a
|
||||||
|
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
|
||||||
|
* rendered as bare numbers, breaking the "anchored against England" promise).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAcademics } from '@/components/compare/CompareAcademics';
|
||||||
|
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, 'Test High'),
|
||||||
|
yearly_data: [
|
||||||
|
{
|
||||||
|
year: 202425,
|
||||||
|
attainment_8_score: 58.7,
|
||||||
|
english_maths_strong_pass_pct: 30,
|
||||||
|
ebacc_entry_pct: 10,
|
||||||
|
},
|
||||||
|
] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NATIONAL: NationalAverages = {
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: {
|
||||||
|
attainment_8_score: 46.0,
|
||||||
|
english_maths_strong_pass_pct: 45.4,
|
||||||
|
ebacc_entry_pct: 40.5,
|
||||||
|
},
|
||||||
|
by_year: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
|
||||||
|
const s = school(137086, 'Bishop Stopford School');
|
||||||
|
render(
|
||||||
|
<CompareAcademics
|
||||||
|
schools={[s]}
|
||||||
|
data={{ '137086': data(137086) }}
|
||||||
|
nationalAverages={NATIONAL}
|
||||||
|
isSecondary
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
|
||||||
|
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
|
||||||
|
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
|
||||||
|
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isValidPostcode,
|
isValidPostcode,
|
||||||
debounce,
|
debounce,
|
||||||
buildOfstedListBadge,
|
buildOfstedListBadge,
|
||||||
|
ofstedLegacyAreas,
|
||||||
metricKind,
|
metricKind,
|
||||||
shortName,
|
shortName,
|
||||||
computeYBounds,
|
computeYBounds,
|
||||||
@@ -111,6 +112,35 @@ describe('debounce', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ofstedLegacyAreas', () => {
|
||||||
|
it('returns only real grades (1-4) and drops sentinel/null values', () => {
|
||||||
|
const areas = ofstedLegacyAreas({
|
||||||
|
quality_of_education: 1,
|
||||||
|
behaviour_attitudes: 2,
|
||||||
|
personal_development: null,
|
||||||
|
leadership_management: 9, // sentinel "not applicable" — must be dropped
|
||||||
|
early_years_provision: 9, // secondary school — not applicable
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
});
|
||||||
|
expect(areas.map((a) => a.label)).toEqual([
|
||||||
|
'Quality of Teaching',
|
||||||
|
'Behaviour in School',
|
||||||
|
'Sixth Form',
|
||||||
|
]);
|
||||||
|
expect(areas.every((a) => a.value >= 1 && a.value <= 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the sixth-form judgement where present (was previously dropped)', () => {
|
||||||
|
const areas = ofstedLegacyAreas({ sixth_form_provision: 1 });
|
||||||
|
expect(areas).toEqual([{ label: 'Sixth Form', value: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Early Years for a primary school', () => {
|
||||||
|
const areas = ofstedLegacyAreas({ quality_of_education: 2, early_years_provision: 1 });
|
||||||
|
expect(areas.map((a) => a.label)).toContain('Early Years (Reception)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildOfstedListBadge', () => {
|
describe('buildOfstedListBadge', () => {
|
||||||
it('returns grade word + year for OEIF Outstanding', () => {
|
it('returns grade word + year for OEIF Outstanding', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
||||||
@@ -130,9 +160,23 @@ describe('buildOfstedListBadge', () => {
|
|||||||
expect(badge.cssClass).toBe('ofsted2');
|
expect(badge.cssClass).toBe('ofsted2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Report Card badge when framework is ReportCard', () => {
|
it('returns a Report Card badge when ofsted_rc_date is present', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
|
||||||
expect(badge.label).toBe('Report Card · 2025');
|
expect(badge.label).toBe('Report Card · 2026');
|
||||||
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a report card wins over a carried-forward legacy grade', () => {
|
||||||
|
// The production bug: a report-card school (e.g. Barclay) also carries a
|
||||||
|
// carried-forward legacy grade (ofsted_grade), which used to win and label
|
||||||
|
// the pin "Outstanding · 2021" instead of "Report Card · 2026".
|
||||||
|
const badge = buildOfstedListBadge({
|
||||||
|
ofsted_grade: 1,
|
||||||
|
ofsted_date: '2021-10-07',
|
||||||
|
ofsted_framework: 'Schools - S5',
|
||||||
|
ofsted_rc_date: '2026-02-03',
|
||||||
|
});
|
||||||
|
expect(badge.label).toBe('Report Card · 2026');
|
||||||
expect(badge.cssClass).toBe('ofstedRc');
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +269,44 @@ 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', () => {
|
describe('shortName', () => {
|
||||||
it('drops the trailing establishment-type words', () => {
|
it('drops the trailing establishment-type words', () => {
|
||||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||||
|
|||||||
@@ -183,10 +183,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: teal outline — supporting actions (+ Compare) */
|
/* Secondary: teal outline — supporting actions (+ Compare) */
|
||||||
|
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
|
||||||
|
which wins over the base `.btn`'s `1px solid transparent`. The outline
|
||||||
|
variants below therefore declare the full `border` shorthand explicitly so
|
||||||
|
they don't depend on the base border-width — otherwise `border-color` alone
|
||||||
|
has no width and the outline never renders (buttons read as plain text). */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
@@ -196,7 +201,7 @@ body {
|
|||||||
.btn-tertiary {
|
.btn-tertiary {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.btn-tertiary:hover:not(:disabled) {
|
.btn-tertiary:hover:not(:disabled) {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
@@ -207,7 +212,7 @@ body {
|
|||||||
.btn-active {
|
.btn-active {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-active:hover:not(:disabled) {
|
.btn-active:hover:not(:disabled) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ export default function RootLayout({
|
|||||||
data-performance="true"
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
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>
|
</head>
|
||||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||||
<div className="noise-overlay" />
|
<div className="noise-overlay" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2';
|
|||||||
import { ChartOptions } from 'chart.js';
|
import { ChartOptions } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import styles from './AdmissionsTrendChart.module.css';
|
import styles from './AdmissionsTrendChart.module.css';
|
||||||
|
|
||||||
@@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
||||||
if (pts.length < 2) return null;
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
const labels = pts.map((p) => formatAcademicYear(p.year));
|
// Gap-honest axis: every academic year between the first and last data point
|
||||||
const values = pts.map((p) => p.first_preference_offer_pct as number);
|
// appears, so a missing admissions year renders as a real gap (spanGaps:false)
|
||||||
const lastIdx = pts.length - 1;
|
// rather than compressing time between distant years.
|
||||||
|
const axisYears = fillAcademicYears(pts.map((p) => p.year));
|
||||||
|
const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number]));
|
||||||
|
const labels = axisYears.map(formatAcademicYear);
|
||||||
|
const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null);
|
||||||
|
const present = values
|
||||||
|
.map((v, i) => (v != null ? i : -1))
|
||||||
|
.filter((i) => i >= 0);
|
||||||
|
const lastIdx = present[present.length - 1];
|
||||||
|
|
||||||
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
||||||
const lo = Math.min(...values);
|
const numeric = values.filter((v): v is number => v != null);
|
||||||
const hi = Math.max(...values);
|
const lo = Math.min(...numeric);
|
||||||
|
const hi = Math.max(...numeric);
|
||||||
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
||||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||||
@@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
label: 'First-choice offer rate',
|
label: 'First-choice offer rate',
|
||||||
data: values,
|
data: values,
|
||||||
clip: false as const,
|
clip: false as const,
|
||||||
|
spanGaps: false,
|
||||||
borderColor: '#e07256',
|
borderColor: '#e07256',
|
||||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
|
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||||
pointBackgroundColor: '#e07256',
|
pointBackgroundColor: '#e07256',
|
||||||
pointBorderColor: '#fff',
|
pointBorderColor: '#fff',
|
||||||
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
|
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 6,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -442,6 +442,58 @@
|
|||||||
line-height: 1;
|
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 ───────────────────────────────────────────── */
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface Step {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
highlight?: 'deadline' | 'offer';
|
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 */
|
/* How SchoolCompare helps at this stage of the journey */
|
||||||
tool?: { label: string; href: string };
|
tool?: { label: string; href: string };
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,16 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Check entry criteria',
|
title: 'Check entry criteria',
|
||||||
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
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: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,14 +86,14 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
date: '31 October',
|
date: '31 October',
|
||||||
title: 'Application deadline',
|
title: 'Application deadline',
|
||||||
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.',
|
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.',
|
||||||
highlight: 'deadline',
|
highlight: 'deadline',
|
||||||
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '1 March',
|
date: '1 March',
|
||||||
title: 'National Offer Day',
|
title: 'National Offer Day',
|
||||||
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
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.',
|
||||||
highlight: 'offer',
|
highlight: 'offer',
|
||||||
tool: { label: 'Look up your allocated school', href: '/' },
|
tool: { label: 'Look up your allocated school', href: '/' },
|
||||||
},
|
},
|
||||||
@@ -101,6 +114,12 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Research entry criteria',
|
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.',
|
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: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,7 +163,7 @@ interface Tip {
|
|||||||
const TIPS: Tip[] = [
|
const TIPS: Tip[] = [
|
||||||
{
|
{
|
||||||
heading: 'Equal preference rule',
|
heading: 'Equal preference rule',
|
||||||
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.',
|
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.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Late applications go to the back',
|
heading: 'Late applications go to the back',
|
||||||
@@ -307,6 +326,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<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 && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
@@ -355,6 +387,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<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 && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
|
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
|
||||||
parent .chartContainer, whose fixed height drives Chart.js sizing via
|
The canvas has its OWN definite height (Chart.js needs one for
|
||||||
maintainAspectRatio: false. */
|
maintainAspectRatio: false); the chips and the note flow at their natural
|
||||||
|
size around it rather than competing with it for a fixed outer height —
|
||||||
|
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
|
||||||
|
never squash the chart. */
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasBox {
|
.canvasBox {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
height: 380px;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.canvasBox {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
|||||||
<div className={styles.editorialGrid}>
|
<div className={styles.editorialGrid}>
|
||||||
<div className={styles.editorialText}>
|
<div className={styles.editorialText}>
|
||||||
<div className={styles.editorialKicker}>About school data</div>
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education publishes
|
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||||
demographics — each in its own table, each with its own jargon.
|
and demographics — each in its own table, each with its own jargon.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
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
|
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
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
40-page inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.factbox}>
|
<div className={styles.factbox}>
|
||||||
|
|||||||
@@ -720,10 +720,13 @@
|
|||||||
.miniRowHead {
|
.miniRowHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.48rem;
|
align-items: baseline;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.44rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted, #6d685f);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniRowHead strong {
|
.miniRowHead strong {
|
||||||
@@ -869,28 +872,77 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedBadge {
|
.ofstedFramework {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(45, 125, 125, 0.12);
|
background: rgba(90, 58, 110, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: #5a3a6e;
|
||||||
font-size: 0.55rem;
|
font-size: 0.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict {
|
.rcList {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
display: flex;
|
||||||
font-size: 1.05rem;
|
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;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
letter-spacing: 0.03em;
|
||||||
color: var(--text-primary, #1a1612);
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict em {
|
.rcChipGood {
|
||||||
|
background: rgba(45, 125, 125, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--accent-teal, #2d7d7d);
|
||||||
font-style: normal;
|
}
|
||||||
|
|
||||||
|
.rcChipNeutral {
|
||||||
|
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
|
||||||
|
color: var(--accent-gold-text, #7a6800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedMeta {
|
.ofstedMeta {
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
<div className={styles.heroSection}>
|
<div className={styles.heroSection}>
|
||||||
<span className={styles.heroEyebrow}>
|
<span className={styles.heroEyebrow}>
|
||||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
Updated with 2026/2027 admissions results
|
Updated for the 2026/27 admissions round
|
||||||
</span>
|
</span>
|
||||||
<h1 className={styles.heroTitle}>
|
<h1 className={styles.heroTitle}>
|
||||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
export function HowItWorksSection() {
|
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 = [
|
const miniCascade = [
|
||||||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
|
||||||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||||
];
|
];
|
||||||
const compareRows = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ 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 },
|
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseBlock}>
|
<div className={styles.hiwPhaseBlock}>
|
||||||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||||
<div className={styles.miniCascade}>
|
<div className={styles.miniCascade}>
|
||||||
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
|
||||||
<div key={subj} className={styles.miniCascadeCol}>
|
<div key={subj} className={styles.miniCascadeCol}>
|
||||||
<div className={styles.miniSubj}>{subj}</div>
|
<div className={styles.miniSubj}>{subj}</div>
|
||||||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||||
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
|
||||||
<div className={styles.miniTrack}>
|
<div className={styles.miniTrack}>
|
||||||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||||
<div className={styles.att8Row}>
|
<div className={styles.att8Row}>
|
||||||
<div className={styles.att8BarWrap}>
|
<div className={styles.att8BarWrap}>
|
||||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
|
||||||
<div className={styles.att8Track}>
|
<div className={styles.att8Track}>
|
||||||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
|
||||||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.att8Score}>
|
<div className={styles.att8Score}>
|
||||||
<div className={styles.att8Value}>62.4</div>
|
<div className={styles.att8Value}>54.8</div>
|
||||||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
<div className={styles.att8Delta}>+8.9 vs national</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.ofstedBullet} />
|
<span className={styles.ofstedBullet} />
|
||||||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
|
||||||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
<div className={styles.rcList}>
|
||||||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Judgement</div>
|
<div className={styles.hiwStep}>Judgement</div>
|
||||||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@ interface PopupBadge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPopupBadge(school: School): PopupBadge {
|
function buildPopupBadge(school: School): PopupBadge {
|
||||||
|
// A report card wins over any carried-forward legacy grade — its presence is
|
||||||
|
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
||||||
|
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||||
|
if (school.ofsted_rc_date) {
|
||||||
|
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||||
|
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
|
||||||
|
}
|
||||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||||
const yearStr = year ? ` · ${year}` : '';
|
const yearStr = year ? ` · ${year}` : '';
|
||||||
if (school.ofsted_grade) {
|
if (school.ofsted_grade) {
|
||||||
@@ -55,9 +62,6 @@ function buildPopupBadge(school: School): PopupBadge {
|
|||||||
};
|
};
|
||||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||||
}
|
}
|
||||||
if (school.ofsted_framework === 'ReportCard') {
|
|
||||||
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
|
|
||||||
}
|
|
||||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||||
import styles from './MetricTooltip.module.css';
|
import { InfoPopover } from './InfoPopover';
|
||||||
|
|
||||||
interface MetricTooltipProps {
|
interface MetricTooltipProps {
|
||||||
metricKey?: string;
|
metricKey?: string;
|
||||||
@@ -13,51 +12,13 @@ interface MetricTooltipProps {
|
|||||||
|
|
||||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||||
const tooltipLabel = label ?? explanation?.label;
|
const resolvedLabel = 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 (
|
return (
|
||||||
<span className={styles.wrapper} ref={wrapperRef}>
|
<InfoPopover
|
||||||
<button
|
label={resolvedLabel}
|
||||||
type="button"
|
plain={plain ?? explanation?.plain}
|
||||||
className={styles.icon}
|
detail={detail ?? explanation?.detail}
|
||||||
aria-expanded={open}
|
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,13 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 95vh;
|
/* Bottom sheet sized against the overlay (which tracks the visual
|
||||||
|
viewport), NOT vh: when the keyboard is open the overlay is short, so
|
||||||
|
max-height:100% keeps the whole sheet — input and results — above the
|
||||||
|
keyboard. min-height gives a comfortable default without a tiny stub,
|
||||||
|
but is capped at 100% so it never exceeds the visible area. */
|
||||||
|
min-height: min(55vh, 100%);
|
||||||
|
max-height: 100%;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import styles from './Modal.module.css';
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
}, [isOpen, handleEscape]);
|
}, [isOpen, handleEscape]);
|
||||||
|
|
||||||
|
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
|
||||||
|
// the on-screen keyboard shrinks the visual viewport but not the layout one,
|
||||||
|
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
|
||||||
|
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
|
||||||
|
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
|
||||||
|
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
|
const el = overlayRef.current;
|
||||||
|
if (!vv || !el) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
el.style.top = `${vv.offsetTop}px`;
|
||||||
|
el.style.height = `${vv.height}px`;
|
||||||
|
el.style.bottom = 'auto';
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
vv.addEventListener('resize', sync);
|
||||||
|
vv.addEventListener('scroll', sync);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', sync);
|
||||||
|
vv.removeEventListener('scroll', sync);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen || typeof window === 'undefined') return null;
|
if (!isOpen || typeof window === 'undefined') return null;
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
||||||
<div className={`${styles.modal} ${styles[size]}`}>
|
<div className={`${styles.modal} ${styles[size]}`}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{title && <h2 className={styles.title}>{title}</h2>}
|
{title && <h2 className={styles.title}>{title}</h2>}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
|
|||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
@@ -35,8 +36,6 @@ interface PerformanceChartProps {
|
|||||||
nationalByYear?: NationalByYear[];
|
nationalByYear?: NationalByYear[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COVID_YEARS = new Set([201920, 202021]);
|
|
||||||
|
|
||||||
// Mobile chip definitions: which datasets render when each chip is active.
|
// Mobile chip definitions: which datasets render when each chip is active.
|
||||||
// `series` keys reference the dataset labels so we can filter cleanly.
|
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||||
@@ -67,21 +66,33 @@ export function PerformanceChart({
|
|||||||
nationalByYear,
|
nationalByYear,
|
||||||
}: PerformanceChartProps) {
|
}: PerformanceChartProps) {
|
||||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
|
||||||
|
// Gap-honest year axis: every academic year between the first and last data
|
||||||
|
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
|
||||||
|
// KS2 — 2021/22) render as real gaps rather than compressed time. School
|
||||||
|
// lines never bridge these gaps (spanGaps:false below).
|
||||||
|
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
|
||||||
|
const byYear = new Map(sortedData.map(d => [d.year, d]));
|
||||||
|
const col = (key: keyof SchoolResult): (number | null)[] =>
|
||||||
|
axisYears.map(y => {
|
||||||
|
const v = byYear.get(y)?.[key];
|
||||||
|
return typeof v === 'number' ? v : null;
|
||||||
|
});
|
||||||
|
const years = axisYears.map(formatAcademicYear);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// ── Build per-year national averages ─────────────────────────────────
|
// ── Build per-year national averages (aligned to the filled axis) ────
|
||||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
const natRefRwm: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.primary?.rwm_expected_pct ?? null;
|
return match?.primary?.rwm_expected_pct ?? null;
|
||||||
}
|
}
|
||||||
return nationalRwmAvg ?? null;
|
return nationalRwmAvg ?? null;
|
||||||
});
|
});
|
||||||
const natRefAtt8: (number | null)[] = sortedData.map(d => {
|
const natRefAtt8: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.secondary?.attainment_8_score ?? null;
|
return match?.secondary?.attainment_8_score ?? null;
|
||||||
}
|
}
|
||||||
return nationalAtt8Avg ?? null;
|
return nationalAtt8Avg ?? null;
|
||||||
@@ -107,10 +118,11 @@ export function PerformanceChart({
|
|||||||
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasCovidGap = isSecondary
|
// A gap year is any filled axis year the school has no results row for —
|
||||||
? false
|
// exactly the cancelled/unpublished years the fill introduced. Drives the
|
||||||
: COVID_YEARS.size > 0 &&
|
// honest note below (KS2 and KS4 have different gap stories).
|
||||||
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||||
|
const hasGap = gapYears.length > 0;
|
||||||
|
|
||||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||||
const refLineStyle = {
|
const refLineStyle = {
|
||||||
@@ -120,13 +132,14 @@ export function PerformanceChart({
|
|||||||
borderDash: [6, 4] as number[],
|
borderDash: [6, 4] as number[],
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0,
|
tension: 0,
|
||||||
|
spanGaps: false as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||||
{
|
{
|
||||||
label: 'Attainment 8',
|
label: 'Attainment 8',
|
||||||
data: sortedData.map(d => d.attainment_8_score),
|
data: col('attainment_8_score'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -137,7 +150,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'English & Maths Grade 4+',
|
label: 'English & Maths Grade 4+',
|
||||||
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
data: col('english_maths_standard_pass_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -147,7 +160,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Progress 8',
|
label: 'Progress 8',
|
||||||
data: sortedData.map(d => d.progress_8_score),
|
data: col('progress_8_score'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -165,7 +178,7 @@ export function PerformanceChart({
|
|||||||
] : [
|
] : [
|
||||||
{
|
{
|
||||||
label: 'Reading, Writing & Maths expected %',
|
label: 'Reading, Writing & Maths expected %',
|
||||||
data: sortedData.map(d => d.rwm_expected_pct),
|
data: col('rwm_expected_pct'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -176,7 +189,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Exceeding expected level',
|
label: 'Exceeding expected level',
|
||||||
data: sortedData.map(d => d.rwm_high_pct),
|
data: col('rwm_high_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -192,7 +205,7 @@ export function PerformanceChart({
|
|||||||
} as ChartDataset<'line'>] : []),
|
} as ChartDataset<'line'>] : []),
|
||||||
{
|
{
|
||||||
label: 'Reading progress',
|
label: 'Reading progress',
|
||||||
data: sortedData.map(d => d.reading_progress),
|
data: col('reading_progress'),
|
||||||
borderColor: 'rgb(59,130,246)',
|
borderColor: 'rgb(59,130,246)',
|
||||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -203,7 +216,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Writing progress',
|
label: 'Writing progress',
|
||||||
data: sortedData.map(d => d.writing_progress),
|
data: col('writing_progress'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -214,7 +227,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maths progress',
|
label: 'Maths progress',
|
||||||
data: sortedData.map(d => d.maths_progress),
|
data: col('maths_progress'),
|
||||||
borderColor: 'rgb(236,72,153)',
|
borderColor: 'rgb(236,72,153)',
|
||||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -275,6 +288,8 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
// Never bridge missing years — cancelled/unpublished years are real gaps.
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -326,6 +341,7 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
@@ -353,7 +369,10 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: { font: { size: 10 }, autoSkip: false },
|
// With the gap-honest axis (more year labels) autoSkip keeps the
|
||||||
|
// phone axis readable; the broken line still shows where a year is
|
||||||
|
// missing even when its tick label is skipped.
|
||||||
|
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -416,9 +435,11 @@ export function PerformanceChart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCovidGap && (
|
{hasGap && (
|
||||||
<p className={styles.covidNote}>
|
<p className={styles.covidNote}>
|
||||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
{isSecondary
|
||||||
|
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
|
||||||
|
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function RankingsView({
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>School Rankings</h1>
|
<h1>School Rankings</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Top-performing schools by {metricLabel.toLowerCase()}
|
Schools ranked by {metricLabel.toLowerCase()}
|
||||||
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -143,7 +143,10 @@ export function RankingsView({
|
|||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
{isProgressScore && (
|
{isProgressScore && (
|
||||||
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
|
<p className={styles.progressHint}>
|
||||||
|
Progress scores: 0 = national average. Positive = above average.
|
||||||
|
{isPrimary && ' KS2 progress isn’t published for 2023/24 or 2024/25 (there’s no key stage 1 baseline) — pick an earlier year to rank by it.'}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -218,7 +221,11 @@ export function RankingsView({
|
|||||||
{rankings.length === 0 ? (
|
{rankings.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No rankings found"
|
title="No rankings found"
|
||||||
message="Try selecting a different metric, area, or year."
|
message={
|
||||||
|
isPrimary && isProgressScore
|
||||||
|
? 'KS2 progress scores aren’t published for the most recent years (2023/24 and 2024/25) because there’s no key stage 1 baseline. Select an earlier year to see progress rankings.'
|
||||||
|
: 'Try selecting a different metric, area, or year.'
|
||||||
|
}
|
||||||
action={{
|
action={{
|
||||||
label: 'Clear filters',
|
label: 'Clear filters',
|
||||||
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||||
|
|||||||
@@ -44,30 +44,46 @@
|
|||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary, #f3ede4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── National average marker ── */
|
/* ── Per-bar national average marker ──
|
||||||
.natLine {
|
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 {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -3px;
|
||||||
height: calc(100% - 20px);
|
bottom: -3px;
|
||||||
width: 1.5px;
|
width: 2px;
|
||||||
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
|
transform: translateX(-50%);
|
||||||
z-index: 2;
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.natPill {
|
.natTick::before {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -3px;
|
||||||
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral, #e07256);
|
||||||
color: #fff;
|
}
|
||||||
|
|
||||||
|
.barHeaderRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natLabel {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.1rem 0.35rem;
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
border-radius: 4px;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
letter-spacing: 0.03em;
|
||||||
z-index: 3;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bar rows ── */
|
/* ── Bar rows ── */
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface SubjectData {
|
|||||||
expectedPct: number | null;
|
expectedPct: number | null;
|
||||||
exceedingPct: number | null;
|
exceedingPct: number | null;
|
||||||
nationalExpectedPct: 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 {
|
interface SatsChartProps {
|
||||||
@@ -17,11 +20,45 @@ interface SatsChartProps {
|
|||||||
const RULER_TICKS = [0, 25, 50, 75, 100];
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||||
const GRIDLINE_POSITIONS = [25, 50, 75];
|
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 }) {
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||||
const expectedRef = useRef<HTMLDivElement>(null);
|
const expectedRef = useRef<HTMLDivElement>(null);
|
||||||
const exceedingRef = useRef<HTMLDivElement>(null);
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
|
||||||
|
|
||||||
// Animate bars on mount
|
// Animate bars on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* National average marker */}
|
{/* Bars — each carries its own national marker */}
|
||||||
{nationalExpectedPct != null && (
|
|
||||||
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
|
||||||
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
<div className={styles.barGroup}>
|
<div className={styles.barGroup}>
|
||||||
{expectedPct != null && (
|
{expectedPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Expected"
|
||||||
<span className={styles.barLabelSuffix}>Expected</span>
|
value={expectedPct}
|
||||||
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
national={nationalExpectedPct}
|
||||||
</div>
|
barRef={expectedRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExpected}
|
||||||
<div
|
|
||||||
ref={expectedRef}
|
|
||||||
className={`${styles.bar} ${styles.barExpected}`}
|
|
||||||
data-width={expectedPct}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{exceedingPct != null && (
|
{exceedingPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Exceeding"
|
||||||
<span className={styles.barLabelSuffix}>Exceeding</span>
|
value={exceedingPct}
|
||||||
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
national={nationalExceedingPct}
|
||||||
</div>
|
barRef={exceedingRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExceeding}
|
||||||
<div
|
|
||||||
ref={exceedingRef}
|
|
||||||
className={`${styles.bar} ${styles.barExceeding}`}
|
|
||||||
data-width={exceedingPct}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
National average
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -855,7 +855,10 @@
|
|||||||
/* Charts Section */
|
/* Charts Section */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* 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;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1043,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
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 {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import type {
|
|||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
|
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
const PerformanceChart = dynamic(
|
||||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||||
@@ -85,6 +87,10 @@ export function SchoolDetailView({
|
|||||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||||
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||||
|
// first choices missed out" etc.) — the same banding the compare screen
|
||||||
|
// uses, so a low offer rate reads as how severe it actually is.
|
||||||
|
const admissionsSummary = summariseAdmissions(admissions);
|
||||||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||||
@@ -153,10 +159,16 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
// Phase detection
|
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||||||
|
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||||||
|
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
||||||
|
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||||
|
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||||||
const isPrimary = !isSecondary;
|
const isPrimary = !isSecondary;
|
||||||
|
// Primary-stage content shows for pure-primary AND all-through schools.
|
||||||
|
const showPrimaryContent = isPrimary || isAllThrough;
|
||||||
|
|
||||||
// National averages (fetched dynamically so they stay current)
|
// National averages (fetched dynamically so they stay current)
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
@@ -222,17 +234,38 @@ export function SchoolDetailView({
|
|||||||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||||||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||||||
|
// reach the mainstream "expected standard", so a 0% headline and an England
|
||||||
|
// comparison portray them as failing against a benchmark that doesn't fit.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||||||
|
// special/suppressed signature) is a placeholder, not a real result. This
|
||||||
|
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||||||
|
// combined (some pupils met individual subjects but not all three) stays
|
||||||
|
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||||||
|
// to form such a signature, so KS4 keys off establishment type only — a
|
||||||
|
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||||||
|
const ks2Placeholder = latestResults != null
|
||||||
|
&& latestResults.rwm_expected_pct === 0
|
||||||
|
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||||||
|
// Whether to drop the England-average deltas / national markers / "below"
|
||||||
|
// framing on the attainment measures.
|
||||||
|
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||||||
|
const suppressKs4Comparison = isSpecial;
|
||||||
|
|
||||||
// Build section nav items dynamically — only sections with data.
|
// Build section nav items dynamically — only sections with data.
|
||||||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||||||
// sections — results, admissions, inclusion, history — sit near the top,
|
// sections — results, admissions, inclusion, history — sit near the top,
|
||||||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||||||
const navItems: { id: string; label: string }[] = [];
|
const navItems: { id: string; label: string }[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
|
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
@@ -269,18 +302,28 @@ export function SchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// A report card is dated by its own inspection (rc_inspection_date); the
|
||||||
|
// legacy inspection_date belongs to an older inspection and must never
|
||||||
|
// date a report card (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
const oeifAllSameGrade =
|
||||||
const subs = [
|
!!ofsted &&
|
||||||
ofsted.quality_of_education,
|
!isReportCard &&
|
||||||
ofsted.behaviour_attitudes,
|
oeifAreas.length >= 3 &&
|
||||||
ofsted.personal_development,
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
ofsted.leadership_management,
|
|
||||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
|
||||||
].filter((v): v is number => v != null);
|
|
||||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Label shown in the mobile "section" menu button — the section in view.
|
// Label shown in the mobile "section" menu button — the section in view.
|
||||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||||
@@ -309,6 +352,9 @@ export function SchoolDetailView({
|
|||||||
{schoolInfo.school_type && (
|
{schoolInfo.school_type && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||||
)}
|
)}
|
||||||
|
{isAllThrough && (
|
||||||
|
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||||
|
)}
|
||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
)}
|
)}
|
||||||
@@ -507,10 +553,10 @@ export function SchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -525,13 +571,13 @@ export function SchoolDetailView({
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
/* ── New Report Card layout ── */
|
/* ── New Report Card layout ── */
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -568,23 +614,17 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||||
|
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||||
|
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
|
||||||
</p>
|
</p>
|
||||||
{oeifAllSameGrade ? (
|
{oeifAllSameGrade ? (
|
||||||
<p className={styles.ofstedAllSame}>
|
<p className={styles.ofstedAllSame}>
|
||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{oeifAreas.map(({ label, value }) => (
|
||||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
|
||||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
|
||||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
|
||||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
|
||||||
...(ofsted.early_years_provision != null
|
|
||||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
|
||||||
: []),
|
|
||||||
].map(({ label, value }) => value != null && (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||||
@@ -603,17 +643,31 @@ export function SchoolDetailView({
|
|||||||
{hasAnyResults && latestResults && (
|
{hasAnyResults && latestResults && (
|
||||||
<section id="results" className={styles.card}>
|
<section id="results" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
{isSecondary
|
{isSpecial
|
||||||
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
|
? (isSecondary
|
||||||
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
|
? 'GCSE results for Year 11 pupils.'
|
||||||
|
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||||||
|
: isAllThrough
|
||||||
|
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||||
|
: isSecondary
|
||||||
|
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||||||
|
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||||||
</p>
|
</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 ── */}
|
{/* ── Primary / KS2 content ── */}
|
||||||
{hasKS2Results && (
|
{hasKS2Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.heroStatGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults.rwm_expected_pct !== null && (
|
{latestResults.rwm_expected_pct !== null && (
|
||||||
<div className={styles.heroStatCard}>
|
<div className={styles.heroStatCard}>
|
||||||
@@ -623,7 +677,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_expected_pct}
|
value={latestResults.rwm_expected_pct}
|
||||||
baseline={primaryAvg.rwm_expected_pct}
|
baseline={primaryAvg.rwm_expected_pct}
|
||||||
@@ -632,8 +686,8 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -645,7 +699,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_high_pct)}
|
{formatPercentage(latestResults.rwm_high_pct)}
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_high_pct}
|
value={latestResults.rwm_high_pct}
|
||||||
baseline={primaryAvg.rwm_high_pct}
|
baseline={primaryAvg.rwm_high_pct}
|
||||||
@@ -654,14 +708,15 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{latestResults.rwm_expected_pct != null &&
|
{!suppressKs2Comparison &&
|
||||||
|
latestResults.rwm_expected_pct != null &&
|
||||||
latestResults.reading_expected_pct != null &&
|
latestResults.reading_expected_pct != null &&
|
||||||
latestResults.writing_expected_pct != null &&
|
latestResults.writing_expected_pct != null &&
|
||||||
latestResults.maths_expected_pct != null && (
|
latestResults.maths_expected_pct != null && (
|
||||||
@@ -685,28 +740,38 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All-zero placeholder rows (special / suppressed) would render as
|
||||||
|
three empty bars against the national markers — misleading, so
|
||||||
|
skip the chart. For a special school with some non-zero
|
||||||
|
subjects, keep the bars but drop the national markers. */}
|
||||||
|
{!ks2Placeholder && (
|
||||||
<SatsChart
|
<SatsChart
|
||||||
subjects={[
|
subjects={[
|
||||||
{
|
{
|
||||||
name: 'Reading',
|
name: 'Reading',
|
||||||
expectedPct: latestResults.reading_expected_pct,
|
expectedPct: latestResults.reading_expected_pct,
|
||||||
exceedingPct: latestResults.reading_high_pct,
|
exceedingPct: latestResults.reading_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Writing',
|
name: 'Writing',
|
||||||
expectedPct: latestResults.writing_expected_pct,
|
expectedPct: latestResults.writing_expected_pct,
|
||||||
exceedingPct: latestResults.writing_high_pct,
|
exceedingPct: latestResults.writing_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.writing_expected_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',
|
name: 'Maths',
|
||||||
expectedPct: latestResults.maths_expected_pct,
|
expectedPct: latestResults.maths_expected_pct,
|
||||||
exceedingPct: latestResults.maths_high_pct,
|
exceedingPct: latestResults.maths_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress scores row */}
|
{/* Progress scores row */}
|
||||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||||
@@ -752,6 +817,9 @@ export function SchoolDetailView({
|
|||||||
{/* ── Secondary / KS4 content ── */}
|
{/* ── Secondary / KS4 content ── */}
|
||||||
{hasKS4Results && (
|
{hasKS4Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{latestResults.attainment_8_score !== null && (
|
{latestResults.attainment_8_score !== null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
@@ -760,8 +828,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="attainment_8_score" />
|
<MetricTooltip metricKey="attainment_8_score" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -784,8 +852,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -796,8 +864,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -861,6 +929,15 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
|
read these as the whole-school figures. */}
|
||||||
|
{isAllThrough && admissions.school_phase && (
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
{/* This-year Q&A */}
|
{/* This-year Q&A */}
|
||||||
@@ -902,6 +979,9 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{admissionsSummary.chip && (
|
||||||
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-year trend */}
|
{/* Multi-year trend */}
|
||||||
@@ -937,7 +1017,7 @@ export function SchoolDetailView({
|
|||||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.eal_pct != null && (
|
{latestResults?.eal_pct != null && (
|
||||||
@@ -953,7 +1033,7 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.eal_pct != null && (
|
{primaryAvg.eal_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -970,7 +1050,7 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.sen_support_pct != null && (
|
{primaryAvg.sen_support_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1034,16 +1114,52 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.length > 0 && (
|
{yearlyData.length > 0 && (
|
||||||
<section id="history" className={styles.card}>
|
<section id="history" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||||
|
{isAllThrough ? (
|
||||||
|
// All-through: KS2 and KS4 trends are on different scales and have
|
||||||
|
// different gap stories, so render them as two stacked charts
|
||||||
|
// rather than crowding 8+ series onto one axis.
|
||||||
|
<>
|
||||||
|
{hasKS2Results && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={false}
|
||||||
|
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||||||
|
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasKS4Results && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={true}
|
||||||
|
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||||||
|
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<PerformanceChart
|
<PerformanceChart
|
||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={isSecondary}
|
isSecondary={isSecondary}
|
||||||
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||||
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<details className={styles.historyDisclosure}>
|
<details className={styles.historyDisclosure}>
|
||||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
@@ -1052,7 +1168,15 @@ export function SchoolDetailView({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Year</th>
|
<th>Year</th>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<th>RWM (expected %)</th>
|
||||||
|
<th>Exceeding (%)</th>
|
||||||
|
<th>Attainment 8</th>
|
||||||
|
<th>Progress 8</th>
|
||||||
|
<th>English & Maths Grade 4+</th>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<th>Attainment 8</th>
|
<th>Attainment 8</th>
|
||||||
<th>Progress 8</th>
|
<th>Progress 8</th>
|
||||||
@@ -1074,7 +1198,15 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.map((result) => (
|
{yearlyData.map((result) => (
|
||||||
<tr key={result.year}>
|
<tr key={result.year}>
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||||
|
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||||
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
|
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
@@ -1099,8 +1231,8 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Year 1 Phonics — primary only */}
|
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
||||||
{hasPhonics && isPrimary && phonics && (
|
{hasPhonics && showPrimaryContent && phonics && (
|
||||||
<section id="phonics" className={styles.card}>
|
<section id="phonics" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
@@ -1142,7 +1274,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
||||||
{primaryAvg.overall_absence_pct != null && (
|
{primaryAvg.overall_absence_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1154,7 +1286,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
||||||
{primaryAvg.persistent_absence_pct != null && (
|
{primaryAvg.persistent_absence_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SchoolRow.module.css';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
interface SchoolRowProps {
|
interface SchoolRowProps {
|
||||||
@@ -38,10 +38,23 @@ export function SchoolRow({
|
|||||||
school.religious_denomination &&
|
school.religious_denomination &&
|
||||||
school.religious_denomination !== 'Does not apply';
|
school.religious_denomination !== 'Does not apply';
|
||||||
|
|
||||||
// vs-national delta
|
// 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.
|
||||||
const rwmDelta =
|
const rwmDelta =
|
||||||
school.rwm_expected_pct != null && nationalAvgRwm != null
|
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
|
||||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleCompareClick = () => {
|
const handleCompareClick = () => {
|
||||||
@@ -87,9 +100,9 @@ export function SchoolRow({
|
|||||||
<div className={styles.line3}>
|
<div className={styles.line3}>
|
||||||
<span className={styles.stat}>
|
<span className={styles.stat}>
|
||||||
<strong className={styles.statValue}>
|
<strong className={styles.statValue}>
|
||||||
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||||
</strong>
|
</strong>
|
||||||
{school.prev_rwm_expected_pct != null && (
|
{showRwmValue && school.prev_rwm_expected_pct != null && (
|
||||||
<span
|
<span
|
||||||
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
||||||
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
||||||
|
|||||||
@@ -2,14 +2,6 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
border: 1px solid var(--accent-gold, #c9a227);
|
border: 1px solid var(--accent-gold, #c9a227);
|
||||||
@@ -119,12 +111,16 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -159,21 +155,30 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
/* One scroll container on mobile: the modal content itself scrolls, so the
|
||||||
font-size: 1.25rem;
|
results list must not add its own inner scroll (double scrollbars, and
|
||||||
|
the input would be trapped above a short 400px window when the keyboard
|
||||||
|
shrinks the sheet). */
|
||||||
|
.results {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact stacked card: name + meta, then a full-width action so the tap
|
||||||
|
target is obvious and the card doesn't waste vertical space. */
|
||||||
.resultItem {
|
.resultItem {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.resultButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
<h2 className={styles.title}>Add School to Comparison</h2>
|
|
||||||
|
|
||||||
{!canAddMore && (
|
{!canAddMore && (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>
|
||||||
Maximum 5 schools can be compared. Remove a school to add another.
|
Maximum 5 schools can be compared. Remove a school to add another.
|
||||||
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAddSchool(school)}
|
onClick={() => handleAddSchool(school)}
|
||||||
disabled={alreadySelected || !canAddMore}
|
disabled={alreadySelected || !canAddMore}
|
||||||
className={
|
className={`${styles.resultButton} ${
|
||||||
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -490,7 +490,10 @@
|
|||||||
/* ── Charts & Map ────────────────────────────────────── */
|
/* ── Charts & Map ────────────────────────────────────── */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* 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;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +626,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
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 {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
@@ -1049,8 +1087,12 @@
|
|||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile let the chart container flow naturally — PerformanceChart's
|
||||||
|
own .chartWrapper carries the definite canvas height (220px) plus the
|
||||||
|
chip strip above it. A fixed 220px here double-constrained the two and
|
||||||
|
clipped the chips onto the plot area. */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
height: 220px;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataTable {
|
.dataTable {
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import type {
|
|||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
@@ -107,6 +108,16 @@ export function SecondarySchoolDetailView({
|
|||||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||||
const hasResults = latestResults?.attainment_8_score != null;
|
const hasResults = latestResults?.attainment_8_score != null;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||||
|
// so their headline attainment is far below the mainstream average by design.
|
||||||
|
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||||
|
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||||
|
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||||
|
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||||
|
// mainstream school still shows its real value and comparison.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
const suppressComparison = isSpecial;
|
||||||
|
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
if (policy.includes('selective')) return 'Selective';
|
if (policy.includes('selective')) return 'Selective';
|
||||||
@@ -186,18 +197,27 @@ export function SecondarySchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||||
|
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
const oeifAllSameGrade =
|
||||||
const subs = [
|
!!ofsted &&
|
||||||
ofsted.quality_of_education,
|
!isReportCard &&
|
||||||
ofsted.behaviour_attitudes,
|
oeifAreas.length >= 3 &&
|
||||||
ofsted.personal_development,
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
ofsted.leadership_management,
|
|
||||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
|
||||||
].filter((v): v is number => v != null);
|
|
||||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||||
@@ -332,10 +352,10 @@ export function SecondarySchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -349,12 +369,12 @@ export function SecondarySchoolDetailView({
|
|||||||
Ofsted reports ↗
|
Ofsted reports ↗
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -390,23 +410,17 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||||
|
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||||
|
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
||||||
</p>
|
</p>
|
||||||
{oeifAllSameGrade ? (
|
{oeifAllSameGrade ? (
|
||||||
<p className={styles.ofstedAllSame}>
|
<p className={styles.ofstedAllSame}>
|
||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{oeifAreas.map(({ label, value }) => (
|
||||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
|
||||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
|
||||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
|
||||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
|
||||||
...(ofsted.early_years_provision != null
|
|
||||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
|
||||||
: []),
|
|
||||||
].map(({ label, value }) => value != null && (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||||
@@ -422,7 +436,7 @@ export function SecondarySchoolDetailView({
|
|||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
From September 2024, Ofsted no longer gives a single overall grade.
|
From September 2024, Ofsted no longer gives a single overall grade.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||||
@@ -449,12 +463,15 @@ export function SecondarySchoolDetailView({
|
|||||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
GCSE results for Year 11 pupils. National averages shown for comparison.
|
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SpecialSchoolNote school={schoolInfo} />
|
||||||
|
|
||||||
{p8Suspended && (
|
{p8Suspended && (
|
||||||
<div className={styles.p8Banner}>
|
<div className={styles.p8Banner}>
|
||||||
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||||
|
(COVID), so DfE has no starting point to measure their progress from.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -468,7 +485,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{latestResults.attainment_8_score.toFixed(1)}
|
{latestResults.attainment_8_score.toFixed(1)}
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.attainment_8_score}
|
value={latestResults.attainment_8_score}
|
||||||
baseline={secondaryAvg.attainment_8_score}
|
baseline={secondaryAvg.attainment_8_score}
|
||||||
@@ -477,8 +494,8 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -508,7 +525,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_strong_pass_pct}
|
value={latestResults.english_maths_strong_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||||
@@ -517,8 +534,8 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -530,7 +547,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_standard_pass_pct}
|
value={latestResults.english_maths_standard_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||||
@@ -539,15 +556,17 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attainment 8 visual bar (0–80 scale) */}
|
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||||
{latestResults.attainment_8_score != null && (
|
"school vs national", so it's dropped for special schools where
|
||||||
|
that comparison isn't meaningful. */}
|
||||||
|
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||||
<div className={styles.att8Viz}>
|
<div className={styles.att8Viz}>
|
||||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||||
<div className={styles.att8VizTrack}>
|
<div className={styles.att8VizTrack}>
|
||||||
@@ -739,8 +758,8 @@ export function SecondarySchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={true}
|
isSecondary={true}
|
||||||
nationalAtt8Avg={heroAtt8Nat}
|
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SecondarySchoolRow.module.css';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
function detectAdmissionsTag(school: School): string | null {
|
function detectAdmissionsTag(school: School): string | null {
|
||||||
@@ -55,8 +55,14 @@ export function SecondarySchoolRow({
|
|||||||
const ofstedBadge = buildOfstedListBadge(school);
|
const ofstedBadge = buildOfstedListBadge(school);
|
||||||
const phase = getPhaseStyle(school.phase);
|
const phase = getPhaseStyle(school.phase);
|
||||||
const att8 = school.attainment_8_score;
|
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 =
|
const laDelta =
|
||||||
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
||||||
|
? att8 - laAvgAttainment8
|
||||||
|
: null;
|
||||||
|
|
||||||
const admissionsTag = detectAdmissionsTag(school);
|
const admissionsTag = detectAdmissionsTag(school);
|
||||||
const sixthForm = hasSixthForm(school);
|
const sixthForm = hasSixthForm(school);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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 “expected standard” these measures report — so a
|
||||||
|
comparison with the England average isn’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 “expected standard” and the England-average comparison
|
||||||
|
aren’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 “expected standard” and the
|
||||||
|
England-average comparison aren’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
Where available, the progress its pupils make is a fairer measure.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||||
|
import { isSpecialSchool } from '@/lib/utils';
|
||||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
import { DotStrip } from '@/components/DotStrip';
|
import { DotStrip } from '@/components/DotStrip';
|
||||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -90,15 +91,19 @@ function Strip({
|
|||||||
urns,
|
urns,
|
||||||
schoolNames,
|
schoolNames,
|
||||||
national,
|
national,
|
||||||
|
special,
|
||||||
}: {
|
}: {
|
||||||
spec: StripSpec;
|
spec: StripSpec;
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
urns: number[];
|
urns: number[];
|
||||||
schoolNames: string[];
|
schoolNames: string[];
|
||||||
national: Record<string, number> | undefined;
|
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) =>
|
const values = latestValues(data, urns, spec.metric).map((v, i) =>
|
||||||
v != null ? Math.round(v) : null,
|
v != null && !special[i] ? Math.round(v) : null,
|
||||||
);
|
);
|
||||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||||
const anchor =
|
const anchor =
|
||||||
@@ -135,12 +140,19 @@ export function CompareAcademics({
|
|||||||
}) {
|
}) {
|
||||||
const urns = schools.map((school) => school.urn);
|
const urns = schools.map((school) => school.urn);
|
||||||
const schoolNames = schools.map((school) => school.school_name);
|
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(
|
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSecondary) {
|
if (isSecondary) {
|
||||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
|
||||||
const banding = urns.map((urn) => {
|
const banding = urns.map((urn) => {
|
||||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
for (let i = rows.length - 1; i >= 0; i--) {
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
@@ -159,9 +171,24 @@ export function CompareAcademics({
|
|||||||
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||||
return y >= 202425;
|
return y >= 202425;
|
||||||
});
|
});
|
||||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
|
||||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
|
||||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||||
|
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||||
|
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||||
|
|
||||||
|
// Every headline number gets its England anchor + verdict chip, so the
|
||||||
|
// "anchored against the England average" promise holds for the grade-5
|
||||||
|
// and EBacc rows too, not just Attainment 8.
|
||||||
|
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
|
||||||
|
if (value == null || anchor == null) return null;
|
||||||
|
const v = verdict(value, anchor, tol);
|
||||||
|
return (
|
||||||
|
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||||||
|
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -174,7 +201,8 @@ export function CompareAcademics({
|
|||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{att8[i] != null ? (
|
{att8[i] != null ? (
|
||||||
<>
|
<>
|
||||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
|
||||||
|
{anchorChip(att8[i], att8Anchor, 2)}
|
||||||
{att8Anchor != null && (
|
{att8Anchor != null && (
|
||||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -216,14 +244,38 @@ export function CompareAcademics({
|
|||||||
</RowLabel>
|
</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
|
{grade5[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(grade5[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||||||
|
{grade5Anchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
|
{ebacc[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(ebacc[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||||||
|
{ebaccAnchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
</SectionGrid>
|
</SectionGrid>
|
||||||
@@ -232,7 +284,7 @@ export function CompareAcademics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const national = nationalAverages?.primary;
|
const national = nationalAverages?.primary;
|
||||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
|
||||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||||
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||||
// groups where single pupils move the percentage — show roughly how many
|
// groups where single pupils move the percentage — show roughly how many
|
||||||
@@ -267,6 +319,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -280,6 +333,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<p className={styles.stripNote}>
|
<p className={styles.stripNote}>
|
||||||
|
|||||||
@@ -239,8 +239,17 @@ export function CompareOfsted({
|
|||||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
{/* Short visible label to save space on mobile (the coloured
|
||||||
{school.school_name}'s Ofsted page →
|
school name already leads the row); the full name stays in
|
||||||
|
aria-label so screen readers can tell the links apart. */}
|
||||||
|
<a
|
||||||
|
className={s.link}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${school.school_name}'s Ofsted page`}
|
||||||
|
>
|
||||||
|
Ofsted page →
|
||||||
</a>
|
</a>
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,18 +60,10 @@
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
|
/* ComparisonChart owns its own canvas height now (a definite px value per
|
||||||
its container's height — which must be *definite*. A min-height alone does
|
breakpoint), with the mobile chip legend above and the gap note below it
|
||||||
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
|
flowing at natural size. This box therefore only needs to not constrain
|
||||||
to its ~150px default (a squashed sliver). Give it a real height. */
|
that height — no fixed height, or the note would again eat the plot. */
|
||||||
.chartBox {
|
.chartBox {
|
||||||
height: 420px;
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
/* Taller on mobile: the mobile-only school chips sit above the canvas and
|
|
||||||
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
|
|
||||||
.chartBox {
|
|
||||||
height: 360px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,15 +72,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
font-size: 0.8rem;
|
/* Slightly larger than the values below it so the school each row belongs
|
||||||
|
to is easy to read on mobile (hidden on desktop, where the column header
|
||||||
|
names the school). */
|
||||||
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--sc, var(--text-secondary));
|
color: var(--sc, var(--text-secondary));
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cellDot {
|
.cellDot {
|
||||||
width: 9px;
|
width: 10px;
|
||||||
height: 9px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dot, var(--text-muted));
|
background: var(--dot, var(--text-muted));
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -129,20 +132,6 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
|||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||||
|
import { InfoPopover } from '@/components/InfoPopover';
|
||||||
import styles from './compareSections.module.css';
|
import styles from './compareSections.module.css';
|
||||||
|
|
||||||
export function Section({
|
export function Section({
|
||||||
@@ -52,11 +53,7 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
|
|||||||
return (
|
return (
|
||||||
<div className={styles.rowLabel}>
|
<div className={styles.rowLabel}>
|
||||||
{children}
|
{children}
|
||||||
{tip && (
|
{tip && <InfoPopover plain={tip} />}
|
||||||
<span className={styles.help} title={tip} aria-label={tip}>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export interface School {
|
|||||||
|
|
||||||
// Ofsted (for list view — summary only)
|
// Ofsted (for list view — summary only)
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
|
/** Report-card inspection date (Nov 2025+); non-null identifies a report
|
||||||
|
* card in the list/map, where the full report_card object isn't available. */
|
||||||
|
ofsted_rc_date?: string | null;
|
||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-6
@@ -680,6 +680,42 @@ export function buildSchoolSummary(
|
|||||||
return parts.join(', ') + '.';
|
return parts.join(', ') + '.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OfstedLegacyArea {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The published OEIF sub-judgement areas for the legacy Ofsted layout, in
|
||||||
|
* display order. Only real grades (1–4) are returned: Ofsted's sentinel
|
||||||
|
* codes for "not applicable / no judgement" (9, and any 0/8 variants) and
|
||||||
|
* nulls are filtered out, so a cryptic "9" never renders as a rating.
|
||||||
|
* Sixth Form provision is included where a school has one — it was
|
||||||
|
* previously dropped from the detail grid entirely.
|
||||||
|
*/
|
||||||
|
export function ofstedLegacyAreas(ofsted: {
|
||||||
|
quality_of_education?: number | null;
|
||||||
|
behaviour_attitudes?: number | null;
|
||||||
|
personal_development?: number | null;
|
||||||
|
leadership_management?: number | null;
|
||||||
|
early_years_provision?: number | null;
|
||||||
|
sixth_form_provision?: number | null;
|
||||||
|
}): OfstedLegacyArea[] {
|
||||||
|
const candidates: Array<[string, number | null | undefined]> = [
|
||||||
|
['Quality of Teaching', ofsted.quality_of_education],
|
||||||
|
['Behaviour in School', ofsted.behaviour_attitudes],
|
||||||
|
["Pupils' Wider Development", ofsted.personal_development],
|
||||||
|
['School Leadership', ofsted.leadership_management],
|
||||||
|
['Early Years (Reception)', ofsted.early_years_provision],
|
||||||
|
['Sixth Form', ofsted.sixth_form_provision],
|
||||||
|
];
|
||||||
|
return candidates
|
||||||
|
.filter((c): c is [string, number] => c[1] != null && c[1] >= 1 && c[1] <= 4)
|
||||||
|
.map(([label, value]) => ({ label, value }));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OfstedListBadge {
|
export interface OfstedListBadge {
|
||||||
@@ -691,9 +727,12 @@ export interface OfstedListBadge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Ofsted badge for a school card in the list/map view.
|
* Build the Ofsted badge for a school card in the list/map view.
|
||||||
* Three states:
|
* States, in priority order:
|
||||||
|
* - Report Card school (ofsted_rc_date set): "Report Card · YYYY" in purple.
|
||||||
|
* Checked FIRST so it wins over any carried-forward legacy grade — the
|
||||||
|
* list has no full report_card object, and ofsted_framework is the raw
|
||||||
|
* event grouping ("Schools - S5"), never "ReportCard".
|
||||||
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
||||||
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
|
|
||||||
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
||||||
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
||||||
* detail page's hero chip so a school never reads as both inspected and
|
* detail page's hero chip so a school never reads as both inspected and
|
||||||
@@ -704,7 +743,16 @@ export function buildOfstedListBadge(school: {
|
|||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
|
ofsted_rc_date?: string | null;
|
||||||
}): OfstedListBadge {
|
}): OfstedListBadge {
|
||||||
|
// A report card wins over any carried-forward legacy grade — signalled by
|
||||||
|
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
|
||||||
|
// S5"), never "ReportCard", so it can't detect report cards.
|
||||||
|
if (school.ofsted_rc_date) {
|
||||||
|
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||||
|
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
|
||||||
|
}
|
||||||
|
|
||||||
const year = school.ofsted_date
|
const year = school.ofsted_date
|
||||||
? new Date(school.ofsted_date).getFullYear()
|
? new Date(school.ofsted_date).getFullYear()
|
||||||
: null;
|
: null;
|
||||||
@@ -723,10 +771,6 @@ export function buildOfstedListBadge(school: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (school.ofsted_framework === 'ReportCard') {
|
|
||||||
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// An inspection is on record (date or framework present) but carries no
|
// An inspection is on record (date or framework present) but carries no
|
||||||
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
||||||
// that has genuinely never been inspected.
|
// that has genuinely never been inspected.
|
||||||
@@ -751,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
|
|||||||
export function isProposedToClose(school: { status?: string | null }): boolean {
|
export function isProposedToClose(school: { status?: string | null }): boolean {
|
||||||
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Generated
+60
@@ -8,6 +8,7 @@
|
|||||||
"name": "nextjs-app",
|
"name": "nextjs-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@types/node": "^25.2.0",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -832,6 +833,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -9136,6 +9190,12 @@
|
|||||||
"url": "https://opencollective.com/synckit"
|
"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": {
|
"node_modules/test-exclude": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:coverage": "jest --coverage"
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@types/node": "^25.2.0",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user