Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cda1c1174 | ||
|
|
97ac5c9cef | ||
|
|
dc22fd2853 | ||
|
|
6b117dd26c | ||
|
|
9ee4d45a94 | ||
|
|
b11ee3c8de | ||
|
|
eecb84fb35 | ||
|
|
39e5c11274 | ||
|
|
accc562b90 | ||
|
|
f31f6f8404 | ||
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
91b49f1a59 | ||
|
|
2433101fa0 | ||
|
|
f43a8ecb69 | ||
|
|
8c3a5cc4e9 | ||
|
|
8ab0ac0a04 | ||
|
|
c298a29b61 | ||
|
|
e098ad4bd1 | ||
|
|
5f961bf7f4 | ||
|
|
803e68970c | ||
|
|
ec2d12478e | ||
|
|
4c229aec6e | ||
|
|
752eb07310 | ||
|
|
a7f6ff4035 | ||
|
|
2f786787b9 | ||
|
|
5044c24895 | ||
|
|
d74cc95034 | ||
|
|
7c43a1baaf | ||
|
|
bcf3498086 | ||
|
|
7199c3a90b | ||
|
|
97dd69a512 | ||
|
|
d1c92ea7d5 | ||
|
|
cab7b4fe91 | ||
|
|
96d5fcf5b0 | ||
|
|
8cf2ac47c1 | ||
|
|
a102508ef1 | ||
|
|
684da611ff | ||
|
|
0186227ced | ||
|
|
59b5f3f163 | ||
|
|
8e763e39d1 | ||
|
|
84bca53c7e | ||
|
|
15b7493b85 | ||
|
|
1d9d2eb5ae | ||
|
|
ddb42badb6 | ||
|
|
702408d6a4 | ||
|
|
09d94e513e | ||
|
|
598ba9af4e | ||
|
|
dc85254ad2 |
No files matched your search
@@ -606,17 +606,21 @@ async def get_school_details(request: Request, urn: int):
|
||||
"religious_denomination": latest.get("religious_denomination", ""),
|
||||
"age_range": latest.get("age_range", ""),
|
||||
"has_sixth_form": latest.get("has_sixth_form"),
|
||||
"nursery_provision": latest.get("nursery_provision"),
|
||||
"status": latest.get("status"),
|
||||
"latitude": latest.get("latitude"),
|
||||
"longitude": latest.get("longitude"),
|
||||
"phase": latest.get("phase"),
|
||||
# GIAS fields
|
||||
"website": latest.get("website"),
|
||||
"telephone": latest.get("telephone"),
|
||||
"headteacher_name": latest.get("headteacher_name"),
|
||||
"capacity": latest.get("capacity"),
|
||||
"total_pupils": latest.get("gias_total_pupils"),
|
||||
"trust_name": latest.get("trust_name"),
|
||||
"gender": latest.get("gender"),
|
||||
"county": latest.get("county"),
|
||||
"parliamentary_constituency": latest.get("parliamentary_constituency"),
|
||||
}.items()
|
||||
}
|
||||
|
||||
|
||||
+47
-1
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
|
||||
s.total_pupils AS gias_total_pupils,
|
||||
s.headteacher_name,
|
||||
s.website,
|
||||
s.telephone,
|
||||
s.nursery_provision,
|
||||
foi.ofsted_grade,
|
||||
foi.ofsted_date,
|
||||
foi.ofsted_framework,
|
||||
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
|
||||
l.address_line1 AS address1,
|
||||
l.address_line2 AS address2,
|
||||
l.town,
|
||||
l.county,
|
||||
l.parliamentary_constituency,
|
||||
l.postcode,
|
||||
l.latitude,
|
||||
l.longitude,
|
||||
@@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
||||
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||
|
||||
# Fallback used when marts predate the optional GIAS/location detail columns
|
||||
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
|
||||
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
||||
# present as NULL so the whole data load degrades gracefully instead of failing.
|
||||
_EXTRA_OPTIONAL_COLUMNS = (
|
||||
"telephone",
|
||||
"nursery_provision",
|
||||
"county",
|
||||
"parliamentary_constituency",
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
||||
for _col, _tbl in (
|
||||
("telephone", "s"),
|
||||
("nursery_provision", "s"),
|
||||
("county", "l"),
|
||||
("parliamentary_constituency", "l"),
|
||||
):
|
||||
_src = f"{_tbl}.{_col},"
|
||||
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
||||
f"expected {_src!r} to be present in _MAIN_QUERY before replacement"
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace(
|
||||
_src, f"NULL AS {_col},"
|
||||
)
|
||||
_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS)
|
||||
|
||||
_GIAS_CODE_COLUMN_NAMES = (
|
||||
"phase_code",
|
||||
"school_type_code",
|
||||
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
|
||||
"admissions_policy_code",
|
||||
)
|
||||
|
||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
||||
# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports
|
||||
# an undefined *qualified* column unquoted as "column l.county does not exist",
|
||||
# so matching only the s. alias would miss dim_location columns (county,
|
||||
# parliamentary_constituency) and defeat the graceful-degradation fallback.
|
||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist')
|
||||
|
||||
|
||||
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
elif missing in _EXTRA_OPTIONAL_COLUMNS:
|
||||
logging.getLogger(__name__).warning(
|
||||
"marts.dim_school/dim_location is missing %s (pipeline hasn't "
|
||||
"rebuilt the mart yet on this DB) — retrying without the "
|
||||
"optional detail columns: %s",
|
||||
missing, exc,
|
||||
)
|
||||
try:
|
||||
df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, engine)
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
else:
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
return pd.DataFrame()
|
||||
|
||||
@@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed():
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_name_location_alias_prefixed():
|
||||
# dim_location columns are selected via the `l.` alias; Postgres reports a
|
||||
# missing qualified column unquoted (e.g. "column l.county does not exist").
|
||||
# The matcher must strip any alias, not just `s.`, or the county /
|
||||
# parliamentary_constituency fallback never triggers and the whole data
|
||||
# load degrades to an empty DataFrame (zero schools) instead of NULLs.
|
||||
assert (
|
||||
_missing_column_name(_fake_exc("column l.county does not exist"))
|
||||
== "county"
|
||||
)
|
||||
assert (
|
||||
_missing_column_name(
|
||||
_fake_exc("column l.parliamentary_constituency does not exist")
|
||||
)
|
||||
== "parliamentary_constituency"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_name_no_match_returns_none():
|
||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||
|
||||
|
||||
@@ -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,950 @@
|
||||
# Detail-page Server/Client Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop ~750 lines of static school-detail markup from shipping to the browser as JavaScript, by turning the page's sections into React Server Components behind a small client shell.
|
||||
|
||||
**Architecture:** A server component imported by a client component becomes a client component. So the sections cannot be children of a client `SchoolDetailView`. Instead `app/school/[slug]/page.tsx` (server) composes the sections and passes them *through* a small client shell as `children` — the pattern already used at `app/page.tsx:98-99`. The shell keeps the genuinely interactive chrome (back link, header reveal, hero map, compare CTA, sticky nav, scroll-spy); everything below it becomes server-rendered.
|
||||
|
||||
**Tech Stack:** Next.js 16.1.6 (App Router, Turbopack), React 19, TypeScript, CSS Modules, Jest + React Testing Library, Playwright (e2e).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-30-server-client-split-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch is `perf/server-client-split`, already created off `origin/main` at `cab7b4f`. Never push to `main`; this lands as a PR (CLAUDE.md SDLC).
|
||||
- All paths below are relative to the repo root. The Next.js app lives in `nextjs-app/`; run all `npm` commands from there.
|
||||
- **Do not start a local server to test** — it does not work in this environment (CLAUDE.md). Verification is `npm run typecheck`, `npm test`, `npm run build`.
|
||||
- **No visual or behavioural change** on any of the four school shapes, except the two documented CSS merges. No redesign, no copy changes.
|
||||
- Commits 6 and 7 are **pure moves**: JSX is relocated verbatim, changing only the `styles` import and prop plumbing. If you find a bug mid-move, record it in the PR description — do not fix it inline.
|
||||
- Characterization tests written in Task 2 must pass **unmodified** through Tasks 3-8. The only file permitted to change is `__tests__/support/renderSchoolDetail.tsx`.
|
||||
- End commit messages with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## What is shared and what is not
|
||||
|
||||
Measured similarity between the two views' sections (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary lines | Secondary lines | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Ofsted | 88 | 107 | 80% | **Shared** — `OfstedSection` |
|
||||
| Finances | 29 | 35 | 91% | **Shared** — `FinancesSection` |
|
||||
| History | 121 | 47 | 40% | Separate components |
|
||||
| Admissions | 89 | 55 | 14% | Separate components |
|
||||
|
||||
The spec's section-sharing table listed Admissions and History as shared; measurement shows they are not. **This costs no bytes** — the client-JS win comes from a section being a *server* component, not from being shared. Sharing is a maintainability bonus, taken only where it is cheap.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create — `nextjs-app/lib/`**
|
||||
- `schoolSections.ts` — pure derived flags (`hasInclusionData`, `hasKS2Results`, `suppressKs2Comparison`, …) and `buildNavItems()`. No JSX, no React. Unit-testable in isolation; consumed by `page.tsx` and the sections.
|
||||
|
||||
**Create — `nextjs-app/components/school/`** (mirrors the existing `components/compare/` layout)
|
||||
- `schoolSections.module.css` — merged stylesheet (Task 5)
|
||||
- `sectionShared.tsx` — `Section`, `MetricGrid`, `MetricCard`, `StatRow` primitives, all server
|
||||
- `OfstedSection.tsx`, `FinancesSection.tsx` — shared, server
|
||||
- `ResultsSection.tsx`, `AdmissionsSection.tsx`, `InclusionSection.tsx`, `HistorySection.tsx`, `SchoolLifeSection.tsx`, `LocalAreaSection.tsx` — primary, server
|
||||
- `GcseSection.tsx`, `SecondaryAdmissionsSection.tsx`, `SecondaryHistorySection.tsx`, `WellbeingSection.tsx` — secondary, server
|
||||
- `AdmissionsViewToggle.tsx` — **the only new client component**
|
||||
- `PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` — server composers
|
||||
- `SchoolDetailShell.tsx` + `SchoolDetailShell.module.css` — the client shell
|
||||
|
||||
**Create — `nextjs-app/__tests__/`**
|
||||
- `support/schoolFixtures.ts` — four fixtures: primary, secondary, all-through, special
|
||||
- `support/renderSchoolDetail.tsx` — the swappable render helper
|
||||
- `components/schoolDetail.characterization.test.tsx`
|
||||
- `lib/schoolSections.test.ts`
|
||||
|
||||
**Modify**
|
||||
- `nextjs-app/jest.setup.js` — `IntersectionObserver`, `window.scrollTo`
|
||||
- `nextjs-app/app/school/[slug]/page.tsx` — fetch national averages, compose sections
|
||||
- `nextjs-app/package.json` — drop `swr`
|
||||
- `e2e/tests/journeys.spec.ts` — detail-page assertions
|
||||
|
||||
**Deliberately untouched**
|
||||
- `components/Navigation.tsx` — genuinely needs client (`usePathname` plus the comparison count). No task.
|
||||
- `components/Footer.tsx` — already a server component; no `'use client'` directive. No task.
|
||||
- `app/layout.tsx` — unchanged. The shared ~172 KB baseline is out of scope; see Task 9 Step 4.
|
||||
|
||||
**Delete**
|
||||
- `nextjs-app/components/SchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/components/SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Test environment and fixtures
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/jest.setup.js`
|
||||
- Create: `nextjs-app/__tests__/support/schoolFixtures.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `primaryFixture`, `secondaryFixture`, `allThroughFixture`, `specialFixture` — each an object matching the prop shape of `SchoolDetailView` (`{ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance }`), plus `nationalAveragesFixture` typed `NationalAverages`.
|
||||
|
||||
- [ ] **Step 1: Add the missing jsdom globals**
|
||||
|
||||
jsdom implements neither API; both detail views use `IntersectionObserver` twice and `window.scrollTo` once. Append to `nextjs-app/jest.setup.js`:
|
||||
|
||||
```javascript
|
||||
// jsdom implements neither of these; the detail views use both.
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
};
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the fixtures**
|
||||
|
||||
Create `nextjs-app/__tests__/support/schoolFixtures.ts`. Derive the exact field names from `nextjs-app/lib/types.ts` (`School`, `SchoolResult`, `OfstedInspection`, `SchoolCensus`, `SchoolAdmissions`, `SchoolDeprivation`, `SchoolFinance`, `NationalAverages`) — do not invent fields; TypeScript will reject them.
|
||||
|
||||
The four fixtures must differ in exactly these ways, because these are the branches the characterization tests exercise:
|
||||
|
||||
| Fixture | `schoolInfo.phase` | Results present | Ofsted | Admissions history | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `primaryFixture` | `'Primary'` | `rwm_expected_pct: 58` | legacy OEIF, `overall_effectiveness: 2` | 3 years with `first_preference_offer_pct` | drives the trend toggle |
|
||||
| `secondaryFixture` | `'Secondary'` | `attainment_8_score: 46.2`, `progress_8_score: 0.31` | Report Card (`rc_*` fields set) | 1 year only | no trend toggle |
|
||||
| `allThroughFixture` | `'All-through'` | **both** `rwm_expected_pct` and `attainment_8_score` | legacy OEIF | 2 years | KS2 content gated back on |
|
||||
| `specialFixture` | `'Primary'`, `school_type: 'Community special school'` | `rwm_expected_pct: 0`, reading/writing/maths all `0` | none (`null`) | none (`null`) | England comparison suppressed |
|
||||
|
||||
Every fixture needs `urn` in the valid 6-digit range, a `school_name`, and a `local_authority`. Give `primaryFixture` at least 3 entries in `yearlyData` so the history table and chart have data.
|
||||
|
||||
- [ ] **Step 3: Verify the fixtures typecheck**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. Any error here means a fixture field does not exist on the real type — fix the fixture, never the type.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/jest.setup.js nextjs-app/__tests__/support/schoolFixtures.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: add jsdom globals and school detail fixtures
|
||||
|
||||
jsdom provides neither IntersectionObserver nor scrollTo; both detail views
|
||||
use them. Adds four fixtures (primary, secondary, all-through, special)
|
||||
covering the branches the characterization tests will pin.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Characterization tests
|
||||
|
||||
This is the safety net. **It must be green before any production code moves.**
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Create: `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: fixtures from Task 1.
|
||||
- Produces: `renderSchoolDetail(fixture, opts?)` and `renderSecondarySchoolDetail(fixture, opts?)`. These are the **only** functions the tests call to render. Task 7 rewrites their bodies; their signatures and every assertion stay frozen.
|
||||
|
||||
- [ ] **Step 1: Write the render helper against the CURRENT components**
|
||||
|
||||
Create `nextjs-app/__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* Task 7 rewrites the bodies of these two functions to render the new
|
||||
* shell + server-sections composition. Nothing else in the test suite may
|
||||
* change — assertions passing unmodified across that rewrite is the proof
|
||||
* that behaviour was preserved.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} />);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
return render(<SecondarySchoolDetailView {...fixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the characterization tests**
|
||||
|
||||
Create `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`. Mock the charts and analytics at the top — Chart.js needs a canvas jsdom does not provide, and `track()` fires on mount:
|
||||
|
||||
```tsx
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
}));
|
||||
```
|
||||
|
||||
Then cover, using `renderSchoolDetail` / `renderSecondarySchoolDetail` only:
|
||||
|
||||
1. **Ofsted, legacy OEIF** (`primaryFixture`) — the grade label `Good` renders; the inspected date renders; the `reports.ofsted.gov.uk` link carries the fixture's URN in its `href`.
|
||||
2. **Ofsted, Report Card** (`secondaryFixture`) — heading reads `Ofsted Report Card`; the November 2025 disclaimer renders; each populated `rc_*` category renders its label and band.
|
||||
3. **KS2 results + England delta** (`primaryFixture`) — the RWM percentage renders; a delta versus the England average renders.
|
||||
4. **KS4 results** (`secondaryFixture`) — Attainment 8 and Progress 8 values render; EBacc rows render.
|
||||
5. **All-through** (`allThroughFixture`) — **both** a KS2 figure and an Attainment 8 figure render in the same document. This is the highest-value assertion in the file; it is the case most likely to break.
|
||||
6. **Special school** (`specialFixture`) — no England-comparison delta renders, and the special-school note renders. Guards the PR #70 regression.
|
||||
7. **Admissions toggle** (`primaryFixture`) — both buttons render; the trend view starts `hidden`; after `userEvent.click` on the trend button the trend view is visible and the year view is `hidden`. Assert on the `hidden` attribute, matching how the current code toggles.
|
||||
8. **Admissions without trend** (`secondaryFixture`, 1 year) — no toggle buttons render.
|
||||
9. **Nav items** (`primaryFixture`) — the expected section ids are present in the document; and for `specialFixture` (no ofsted, no admissions) those two are absent.
|
||||
|
||||
Use `getByText` / `queryByText` / `getByRole` — never snapshots, and never assert on CSS-module class names, which are hashed and will change.
|
||||
|
||||
- [ ] **Step 3: Run the tests and confirm they PASS**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS.** This is the inverse of normal TDD — these tests describe behaviour that already exists, so green means they correctly pin the current behaviour. If one fails, the test is wrong, not the component. Fix the test.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/__tests__/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: pin school detail behaviour before refactor
|
||||
|
||||
Characterization tests covering Ofsted (both layouts), KS2/KS4 results,
|
||||
all-through dual rendering, special-school comparison suppression, and the
|
||||
admissions toggle. All rendering goes through renderSchoolDetail(), the one
|
||||
seam that the server/client split is allowed to change.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move national averages to the server
|
||||
|
||||
The prerequisite for everything after it: `primaryAvg` / `secondaryAvg` feed the England deltas across most sections, and while they arrive via `useEffect` those sections cannot be server components.
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/components/SchoolDetailView.tsx:173-179`
|
||||
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:91-93`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `fetchNationalAverages()` from `@/lib/api` (already exists, `revalidate: 3600`).
|
||||
- Produces: both view components gain a required prop `nationalAvg: NationalAverages | null`.
|
||||
|
||||
- [ ] **Step 1: Fetch it in parallel in the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the lone `fetchSchoolDetails` call in the default export with a parallel pair. Import `fetchNationalAverages` alongside the existing imports. National averages must never break the page, so it degrades to `null`:
|
||||
|
||||
```tsx
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
```
|
||||
|
||||
Leave `generateMetadata`'s own `fetchSchoolDetails(urn)` call alone — Next.js memoises identical `fetch` calls within a render pass, so it does not cost a second request.
|
||||
|
||||
- [ ] **Step 2: Pass it to both views**
|
||||
|
||||
Add `nationalAvg={nationalAvg}` to both the `<SecondarySchoolDetailView>` and `<SchoolDetailView>` elements at the bottom of the page.
|
||||
|
||||
- [ ] **Step 3: Accept the prop and delete the useEffect**
|
||||
|
||||
In `components/SchoolDetailView.tsx`: add `nationalAvg: NationalAverages | null;` to `SchoolDetailViewProps`, add `nationalAvg` to the destructured parameters, and delete lines 172-179 — the `useState` and the `useEffect` that fetches `/api/national-averages`. The two lines that follow it stay exactly as they are:
|
||||
|
||||
```tsx
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
```
|
||||
|
||||
Apply the identical change to `components/SecondarySchoolDetailView.tsx` at lines 91-93.
|
||||
|
||||
- [ ] **Step 4: Update the render helper to supply the prop**
|
||||
|
||||
In `__tests__/support/renderSchoolDetail.tsx`, spread `nationalAveragesFixture` in:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
This is a permitted change — the helper is the designated seam. The test file itself must not change.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test -- schoolDetail.characterization`
|
||||
Expected: both PASS, with the characterization test file unmodified.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/app/school nextjs-app/components/SchoolDetailView.tsx \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/__tests__/support/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): fetch national averages on the server
|
||||
|
||||
Both detail views fetched /api/national-averages in a useEffect, so the
|
||||
England-comparison deltas popped in after hydration and the sections that
|
||||
use them could not become server components. The page now fetches it in
|
||||
parallel with the school details (backend-cached 1h) and passes it down.
|
||||
|
||||
Removes one client round-trip per detail page.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract the derived-flags module
|
||||
|
||||
Pulls the pure data-shape logic out of the components so `page.tsx` can compute `navItems` on the server without importing a client component.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/lib/schoolSections.ts`
|
||||
- Create: `nextjs-app/__tests__/lib/schoolSections.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: types from `@/lib/types`; `isSpecialSchool` from `@/lib/utils`.
|
||||
- Produces:
|
||||
- `type SchoolFlags = { isAllThrough: boolean; isSecondary: boolean; isPrimary: boolean; latestResults: SchoolResult | null; hasGenderSplit: boolean; hasInclusionData: boolean; hasSchoolLife: boolean; hasDeprivation: boolean; hasFinance: boolean; hasLocation: boolean; hasKS2Results: boolean; hasKS4Results: boolean; hasAnyResults: boolean; isSpecial: boolean; ks2Placeholder: boolean; suppressKs2Comparison: boolean; suppressKs4Comparison: boolean }`
|
||||
- `computeSchoolFlags(input): SchoolFlags` where `input` is `{ schoolInfo, yearlyData, absenceData, census, deprivation, finance }`
|
||||
- `type NavItem = { id: string; label: string }`
|
||||
- `buildNavItems(flags: SchoolFlags, opts: { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; yearlyDataLength: number }): NavItem[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `nextjs-app/__tests__/lib/schoolSections.test.ts` covering the branches that matter:
|
||||
|
||||
```ts
|
||||
import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections';
|
||||
import { primaryFixture, allThroughFixture, specialFixture } from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const primary = computeSchoolFlags(primaryFixture);
|
||||
const items = buildNavItems(primary, {
|
||||
ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
});
|
||||
expect(items.find((n) => n.id === 'results')?.label).toBe('SATs');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: FAIL — `Cannot find module '@/lib/schoolSections'`.
|
||||
|
||||
- [ ] **Step 3: Implement the module**
|
||||
|
||||
Create `nextjs-app/lib/schoolSections.ts` by moving the logic verbatim from `components/SchoolDetailView.tsx` lines **211-267**. Lines 205-209 are the `deprivationDesc` helper — leave it where it is; Task 6 moves it into `LocalAreaSection`. Also bring `latestResults` (line 160). Preserve the comments — they explain the special-school and all-through reasoning and are load-bearing for future readers. The `navItems` push order must stay exactly as it is at lines 258-267; it is engagement-ordered from analytics.
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Consume it from both views**
|
||||
|
||||
Replace the inline flag computations in `SchoolDetailView.tsx` and `SecondarySchoolDetailView.tsx` with a call to `computeSchoolFlags` / `buildNavItems`, destructuring the flags so the JSX below is untouched.
|
||||
|
||||
- [ ] **Step 6: Verify nothing regressed**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test`
|
||||
Expected: all PASS, characterization tests unmodified.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.test.ts \
|
||||
nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract derived flags and nav items to lib
|
||||
|
||||
Pure data-shape logic moves out of the client components so page.tsx can
|
||||
compute the section list on the server. Adds direct unit coverage for the
|
||||
all-through and special-school branches, which were previously only
|
||||
reachable through a full component render.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Merge the stylesheets
|
||||
|
||||
Isolated so that a visual regression bisects to the CSS merge rather than to a JSX move. Highest-risk task in the plan; no test catches a mistake here.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/schoolSections.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: a stylesheet exporting every class name used by section markup in either view, plus `genderBarSecondary` and `heroStatValueSecondary` variant classes.
|
||||
|
||||
- [ ] **Step 1: Copy the primary stylesheet as the base**
|
||||
|
||||
```bash
|
||||
mkdir -p nextjs-app/components/school
|
||||
cp nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/school/schoolSections.module.css
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the secondary-only classes**
|
||||
|
||||
33 class names exist only in `SecondarySchoolDetailView.module.css`. Append their rules unchanged. Confirm the list:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
c=lambda p: set(re.findall(r'\.([a-zA-Z][\w-]*)', open(p).read()))
|
||||
a=c('components/SchoolDetailView.module.css'); b=c('components/SecondarySchoolDetailView.module.css')
|
||||
print('\n'.join(sorted(b-a)))
|
||||
PY
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Union the 14 incidental-drift differences**
|
||||
|
||||
For these classes, add any property present in the secondary rule but missing from the primary rule. All are defensive overflow properties — `min-width: 0`, `overflow-wrap: break-word`, `word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. Never remove a property the primary rule already has.
|
||||
|
||||
Affected: `.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, `.metricsGrid`, `.tableWrapper`, `.heroStatCard`, `.heroStatLabel`, `.historyToggle`, `.ofstedReportLink`, `.genderSplitBoys`, `.genderSplitGirls`, `.genderSplitSep`, and the remaining drift class from the audit in Step 5.
|
||||
|
||||
- [ ] **Step 4: Add variant classes for the two real differences**
|
||||
|
||||
Keep the primary look on the base class and add the secondary look as a separate class:
|
||||
|
||||
```css
|
||||
/* Secondary detail pages render a slimmer gender bar on a translucent track.
|
||||
Kept as an explicit variant rather than reconciled, so the divergence is
|
||||
deliberate instead of accidental drift. */
|
||||
.genderBarSecondary {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* Secondary hero stats sit tighter and omit the Georgia fallback. */
|
||||
.heroStatValueSecondary {
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Audit the merge**
|
||||
|
||||
Every shared class must now be a superset of both originals, except the two variants:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
def rules(p):
|
||||
out={}
|
||||
for m in re.finditer(r'\.([a-zA-Z][\w-]*)\s*\{([^{}]*)\}', open(p).read()):
|
||||
out.setdefault(m.group(1),[]).append({x.strip() for x in
|
||||
re.sub(r'\s+',' ',m.group(2)).strip().rstrip(';').split(';') if x.strip()})
|
||||
return out
|
||||
m=rules('components/school/schoolSections.module.css')
|
||||
for src in ['components/SchoolDetailView.module.css','components/SecondarySchoolDetailView.module.css']:
|
||||
for name, bodies in rules(src).items():
|
||||
if name in ('genderBar','heroStatValue'): continue
|
||||
if name not in m: print(f"MISSING .{name} (from {src})"); continue
|
||||
missing = bodies[0] - m[name][0]
|
||||
if missing: print(f"LOST .{name}: {missing} (from {src})")
|
||||
print("audit complete")
|
||||
PY
|
||||
```
|
||||
Expected: no `MISSING` or `LOST` lines before `audit complete`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/schoolSections.module.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
style(detail): merge the two detail stylesheets
|
||||
|
||||
Unions the 14 incidental-drift differences between the primary and secondary
|
||||
modules -- defensive overflow properties (min-width, overflow-wrap,
|
||||
max-width) that were fixed on one page and never back-ported. Keeps the two
|
||||
genuine visual differences, .genderBar and .heroStatValue, as explicit
|
||||
variant classes.
|
||||
|
||||
No intended visual change. Isolated so a regression bisects here rather than
|
||||
to a JSX move.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Extract the server sections
|
||||
|
||||
The bulk of the work, and a **pure move**. Every section below becomes a server component: no `'use client'`, no hooks, no event handlers.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/sectionShared.tsx`
|
||||
- Create: the 12 section files listed in File Structure
|
||||
- Create: `nextjs-app/components/school/AdmissionsViewToggle.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SchoolFlags` and `NavItem` from `@/lib/schoolSections`; `schoolSections.module.css`.
|
||||
- Produces: each section is a named export taking exactly the props its JSX reads. `AdmissionsViewToggle` is the only `'use client'` file created here, with props `{ title: ReactNode; subtitle: ReactNode; trendLabel: string; yearView: ReactNode; trendView: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Extract the shared primitives**
|
||||
|
||||
Create `sectionShared.tsx` mirroring `components/compare/sectionShared.tsx`. Export server components `Section` (renders `<section id className={styles.card}>`), `SectionTitle`, `MetricGrid`, `MetricCard`. Re-export the stylesheet as `sectionStyles`, matching the compare module's convention.
|
||||
|
||||
- [ ] **Step 2: Move the shared sections**
|
||||
|
||||
These are the only two sections where you are merging two implementations rather than moving one, so the reconciliation rule matters. **Start from the primary version and add only what the secondary version has that the primary lacks.** Diff them first so you are working from the actual delta rather than reading both in full:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && diff <(sed -n '598,687p' components/SchoolDetailView.tsx | sed 's/^[ \t]*//') \
|
||||
<(sed -n '395,502p' components/SecondarySchoolDetailView.tsx | sed 's/^[ \t]*//')
|
||||
```
|
||||
|
||||
For every line the secondary has and the primary does not, decide one of two things and write the decision as a code comment: either it is **phase-specific** (gate it on a prop — e.g. `showSixthForm`), or it is **an improvement the primary never received** (keep it unconditionally). Never delete a branch the primary version has. If a difference is neither — a genuine behavioural conflict — stop and report it rather than guessing.
|
||||
|
||||
- `OfstedSection.tsx` — primary `SchoolDetailView.tsx:598-687`, secondary `SecondarySchoolDetailView.tsx:395-502` (80% similar). Props: `{ ofsted, urn, isReportCard, ofstedInspectedDate, oeifAllSameGrade, oeifAreas }`. Note `RC_CATEGORIES` (lines 44-53) includes `rc_sixth_form`, which only ever populates for secondary — it is already null-guarded, so it needs no prop.
|
||||
- `FinancesSection.tsx` — primary `SchoolDetailView.tsx:1314-1344`, secondary `SecondarySchoolDetailView.tsx:937-973` (91% similar). Same rule; run the equivalent diff.
|
||||
|
||||
- [ ] **Step 3: Move the primary-only sections**
|
||||
|
||||
Verbatim moves, changing only the `styles` import to `schoolSections.module.css` and replacing closed-over variables with props:
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `ResultsSection.tsx` | `SchoolDetailView.tsx:688-959` | `latestResults, flags, primaryAvg, secondaryAvg, schoolInfo` |
|
||||
| `AdmissionsSection.tsx` | `SchoolDetailView.tsx:960-1051` | `admissions, admissionsHistory, admissionsSummary, isAllThrough` |
|
||||
| `InclusionSection.tsx` | `SchoolDetailView.tsx:1052-1135` | `latestResults, census, hasGenderSplit` |
|
||||
| `HistorySection.tsx` | `SchoolDetailView.tsx:1136-1256` | `yearlyData, flags` |
|
||||
| `SchoolLifeSection.tsx` | `SchoolDetailView.tsx:1257-1289` | `absenceData` |
|
||||
| `LocalAreaSection.tsx` | `SchoolDetailView.tsx:1290-1313` | `deprivation` — bring `deprivationDesc` (lines 205-209) with it |
|
||||
|
||||
- [ ] **Step 4: Move the secondary-only sections**
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `GcseSection.tsx` | `SecondarySchoolDetailView.tsx:503-732` | `latestResults, flags, secondaryAvg, schoolInfo` |
|
||||
| `SecondaryAdmissionsSection.tsx` | `SecondarySchoolDetailView.tsx:733-792` | `admissions` |
|
||||
| `SecondaryHistorySection.tsx` | `SecondarySchoolDetailView.tsx:793-839` | `yearlyData, flags` |
|
||||
| `WellbeingSection.tsx` | `SecondarySchoolDetailView.tsx:840-936` | `absenceData, census` — pass `variant="secondary"` to select `genderBarSecondary` |
|
||||
|
||||
- [ ] **Step 5: Build the admissions toggle island**
|
||||
|
||||
The current code renders both views always and toggles the `hidden` attribute, so the island only owns `hidden` and `aria-pressed`. The buttons sit inside `.admissionsHeader` beside the `<h2>` while the viewport is a sibling below, so the component must span both to preserve the DOM exactly.
|
||||
|
||||
Create `nextjs-app/components/school/AdmissionsViewToggle.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
/**
|
||||
* The only interactive part of the admissions section. Both views are always
|
||||
* in the DOM (matching the previous behaviour) and this toggles `hidden`, so
|
||||
* the server-rendered markup passed in as yearView/trendView never ships as
|
||||
* client JavaScript.
|
||||
*/
|
||||
export function AdmissionsViewToggle({
|
||||
title, subtitle, trendLabel, yearView, trendView,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>{yearView}</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>{trendView}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `AdmissionsSection.tsx`, when `showAdmissionsTrend` is false, render the header and year view directly with **no** client component — those schools then ship zero admissions JavaScript.
|
||||
|
||||
- [ ] **Step 6: Verify it compiles**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. The old views still exist and still render; nothing is wired up yet.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract sections as server components
|
||||
|
||||
Moves the section markup out of the two client views into
|
||||
components/school/, mirroring the components/compare/ layout. Sections are
|
||||
server components; the only new client file is AdmissionsViewToggle, which
|
||||
owns the hidden/aria-pressed state and receives both views as server-rendered
|
||||
children.
|
||||
|
||||
Ofsted and Finances are shared between phases (80% and 91% similar);
|
||||
Admissions and History are not (14% and 40%) and stay separate.
|
||||
|
||||
Pure move -- no logic or markup changes. Not yet wired up.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add the shell and wire it up
|
||||
|
||||
Where the bytes actually move.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/SchoolDetailShell.tsx` + `.module.css`
|
||||
- Create: `nextjs-app/components/school/PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx`
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Delete: `SchoolDetailView.tsx` + `.module.css`, `SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 4-6.
|
||||
- Produces: `SchoolDetailShell` — `'use client'`, props `{ schoolInfo: School; navItems: NavItem[]; flags: SchoolFlags; children: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Build the shell**
|
||||
|
||||
Create `SchoolDetailShell.tsx` from `SchoolDetailView.tsx` lines 1-595 — the back link, header, details reveal, hero map, compare CTA, sticky nav, jump sheet — plus the hooks at lines 79-104 and the effects at 120-203 and 270-300. Keep `'use client'`. It renders `{children}` where the sections used to begin (after the nav closes, at what is currently line 595).
|
||||
|
||||
`navItems` becomes a prop instead of being computed inline. The scroll-spy at line 283 uses `document.getElementById(id)` and needs no change — it finds server-rendered sections exactly as before.
|
||||
|
||||
For the shell's stylesheet, do not eyeball which rules are shell-only — determine it mechanically. Every class the shell's JSX references goes into `SchoolDetailShell.module.css`; classes referenced by both the shell and a section stay duplicated in both files (CSS Modules hash them independently, so duplication is correct, not a smell). List the shell's classes with:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -o 'styles\.[a-zA-Z][A-Za-z0-9_]*' components/school/SchoolDetailShell.tsx \
|
||||
| sort -u | sed 's/styles\.//'
|
||||
```
|
||||
|
||||
Copy each of those rules — including every media-query and `:hover`/`:focus` block that mentions them — from `SchoolDetailView.module.css` before deleting it in Step 6.
|
||||
|
||||
- [ ] **Step 2: Build the two composers**
|
||||
|
||||
Server components — no `'use client'`. Each returns the section sequence in the current order, gated by the same flags:
|
||||
|
||||
```tsx
|
||||
export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) {
|
||||
const { ofsted, admissions, /* … */ } = data;
|
||||
return (
|
||||
<>
|
||||
{ofsted && <OfstedSection ofsted={ofsted} urn={data.schoolInfo.urn} /* … */ />}
|
||||
{flags.hasAnyResults && flags.latestResults && <ResultsSection /* … */ />}
|
||||
{admissions && <AdmissionsSection /* … */ />}
|
||||
{flags.hasInclusionData && <InclusionSection /* … */ />}
|
||||
{data.yearlyData.length > 0 && <HistorySection /* … */ />}
|
||||
{flags.hasSchoolLife && <SchoolLifeSection /* … */ />}
|
||||
{flags.hasDeprivation && <LocalAreaSection /* … */ />}
|
||||
{flags.hasFinance && <FinancesSection /* … */ />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The conditions must match `buildNavItems` exactly, or the nav will link to sections that do not exist.
|
||||
|
||||
`SecondarySchoolSections` follows the same shape with `GcseSection`, `SecondaryAdmissionsSection`, `SecondaryHistorySection`, `WellbeingSection`, `FinancesSection`.
|
||||
|
||||
- [ ] **Step 3: Rewire the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the `isSecondary ? <SecondarySchoolDetailView/> : <SchoolDetailView/>` block. Compute flags and nav items on the server, then wrap:
|
||||
|
||||
```tsx
|
||||
const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance });
|
||||
const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length });
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<SchoolDetailShell schoolInfo={school_info} navItems={navItems} flags={flags}>
|
||||
{isSecondary
|
||||
? <SecondarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />
|
||||
: <PrimarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />}
|
||||
</SchoolDetailShell>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
Keep the existing `isSecondary` / `isAllThrough` derivation exactly as it is — all-through schools must still route to the primary composer, which renders both KS2 and KS4 content.
|
||||
|
||||
- [ ] **Step 4: Swap the render helper — the moment of truth**
|
||||
|
||||
Rewrite **only** `__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted, admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
return render(
|
||||
<SchoolDetailShell schoolInfo={fixture.schoolInfo} navItems={navItems} flags={flags}>
|
||||
<PrimarySchoolSections data={fixture} flags={flags} nationalAvg={nationalAveragesFixture} />
|
||||
</SchoolDetailShell>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the characterization tests**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS, with `schoolDetail.characterization.test.tsx` byte-identical to Task 2.**
|
||||
|
||||
This is the proof the refactor preserved behaviour. If a test fails, the refactor is wrong — fix the components, never the test. Confirm the file is untouched:
|
||||
|
||||
```bash
|
||||
git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Delete the old views**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.module.css
|
||||
```
|
||||
|
||||
Confirm nothing still imports them:
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "SchoolDetailView\|SecondarySchoolDetailView" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 7: Full verification**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all three PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): render sections on the server behind a client shell
|
||||
|
||||
page.tsx now composes the sections and passes them through
|
||||
SchoolDetailShell as children, so ~750 lines of static markup stop shipping
|
||||
as client JavaScript. The shell keeps what is genuinely interactive: back
|
||||
link, header reveal, hero map, compare CTA, sticky nav and scroll-spy.
|
||||
|
||||
The scroll-spy already located sections via document.getElementById, so it
|
||||
works unchanged against server-rendered children.
|
||||
|
||||
Characterization tests from the earlier commit pass unmodified.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete the unused SWR hooks
|
||||
|
||||
**Files:**
|
||||
- Delete: `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
- Modify: `nextjs-app/package.json`
|
||||
|
||||
- [ ] **Step 1: Confirm nothing imports them**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "useSchools\|useFilters\|useMetrics\|useSchoolDetails" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output. If anything appears, stop and report it — do not delete.
|
||||
|
||||
- [ ] **Step 2: Delete and drop the dependency**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/hooks/useSchools.ts nextjs-app/hooks/useFilters.ts \
|
||||
nextjs-app/hooks/useMetrics.ts nextjs-app/hooks/useSchoolDetails.ts
|
||||
cd nextjs-app && npm uninstall swr
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: drop unused SWR hooks and dependency
|
||||
|
||||
useSchools, useFilters, useMetrics and useSchoolDetails were imported by
|
||||
nothing and were the only consumers of swr. All data fetching goes through
|
||||
lib/api.ts on the server.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E coverage and measurement
|
||||
|
||||
CLAUDE.md requires e2e updates in the same PR as user-facing changes; these journeys gate whether staging is fit for human testing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/tests/journeys.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Read the existing journeys**
|
||||
|
||||
Run: `cat e2e/tests/journeys.spec.ts` and match its existing helpers, selectors and naming. Note the staging-data constraint: year filters must use the **latest** year, not the oldest — staging carries only partial history.
|
||||
|
||||
- [ ] **Step 2: Add detail-page assertions**
|
||||
|
||||
Add a journey that visits a school detail page and asserts: the Ofsted section renders; the results section renders a figure; the sticky section nav renders and a jump link scrolls to its section; and — the regression this whole refactor risks — the admissions year/trend toggle switches views. Assert on user-visible text and roles, never CSS-module class names.
|
||||
|
||||
- [ ] **Step 3: Verify the spec parses**
|
||||
|
||||
Run: `cd e2e && npx playwright test --list`
|
||||
Expected: the new tests appear. Do not run them locally — they target staging, which is deployed from `main`.
|
||||
|
||||
- [ ] **Step 4: Measure the result**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && rm -rf .next && npx next build >/dev/null 2>&1 && python3 - <<'PY'
|
||||
import re,os,gzip,glob
|
||||
for html in sorted(glob.glob('.next/server/app/**/*.html', recursive=True)):
|
||||
src=set(re.findall(r'/_next/(static/[^"\']+\.js)', open(html).read()))
|
||||
tot=sum(len(gzip.compress(open(os.path.join('.next',s),'rb').read()))
|
||||
for s in src if os.path.exists(os.path.join('.next',s)))
|
||||
print(f"{tot/1024:8.1f} KB gz {len(src):3d} scripts {html}")
|
||||
PY
|
||||
```
|
||||
|
||||
Record the figures for the PR description. Compare against the `cab7b4f` baseline: 172 KB gz shared baseline, `/admissions` 177 KB gz. **Report the route-specific figure and the shared baseline separately** — this change does not target the baseline, and an unchanged baseline is not a failure.
|
||||
|
||||
- [ ] **Step 5: Commit and open the PR**
|
||||
|
||||
```bash
|
||||
git add e2e/tests/journeys.spec.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(e2e): cover school detail sections and admissions toggle
|
||||
|
||||
The server/client split moves the detail sections out of the client bundle;
|
||||
these journeys gate that the rendered page still behaves the same on staging.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push -u origin perf/server-client-split
|
||||
```
|
||||
|
||||
Open the PR against `main` per the Gitea flow in `docs/DEPLOY.md`. The description must include: the before/after byte table from Step 4, the note that the shared baseline is deliberately unchanged, the two CSS variants introduced in Task 5, and any bugs found-but-not-fixed during the pure-move tasks.
|
||||
|
||||
Do **not** trigger the production promotion workflow — that is the human's call.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before claiming the work complete, confirm each of these with actual command output:
|
||||
|
||||
- [ ] `cd nextjs-app && npm run typecheck` — passes
|
||||
- [ ] `cd nextjs-app && npm test` — passes
|
||||
- [ ] `cd nextjs-app && npm run build` — passes
|
||||
- [ ] `git diff cab7b4f -- nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx` — **empty** after Task 2's commit
|
||||
- [ ] `grep -rn "SchoolDetailView" nextjs-app/app nextjs-app/components` — no output
|
||||
- [ ] `grep -rn "swr" nextjs-app/package.json` — no output
|
||||
- [ ] CSS audit script from Task 5 Step 5 — no `MISSING` or `LOST` lines
|
||||
- [ ] Byte measurement recorded in the PR description
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,295 @@
|
||||
# Detail pages: server/client split
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Branch:** `perf/server-client-split`
|
||||
|
||||
## Problem
|
||||
|
||||
Every page of the site ships ~172 KB gzipped of JavaScript before any
|
||||
page-specific code loads. The 404 page costs that much; `/admissions`, a static
|
||||
content page with no data, costs 177 KB. A well-tuned Next.js app baseline is
|
||||
90-110 KB.
|
||||
|
||||
The cause is that nearly the whole site is a client component.
|
||||
`SchoolDetailView.tsx` is 65 KB of source marked `'use client'`;
|
||||
`SecondarySchoolDetailView.tsx` is 46 KB. Both are overwhelmingly presentational
|
||||
markup driven by props the server already fetched. Every stat tile, table row and
|
||||
Ofsted badge is serialised into the RSC payload *and* shipped again as JavaScript
|
||||
to hydrate.
|
||||
|
||||
Measured on a production build of `cab7b4f` (Next 16.1.6, Turbopack):
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Baseline JS, every page | 172 KB gz across 8 chunks |
|
||||
| `/admissions` (no data) | 177 KB gz |
|
||||
| Chart.js chunk | 64 KB gz (correctly lazy) |
|
||||
| Leaflet chunk | 41 KB gz (correctly lazy) |
|
||||
| CSS | ~30 KB gz, route-split |
|
||||
|
||||
**Scope of this document.** The table above characterises the whole problem; this
|
||||
design addresses one part of it — the route-level client JS on school detail
|
||||
pages, the site's highest-traffic and most SEO-critical route. The shared
|
||||
baseline and the other routes are separate work, tracked elsewhere.
|
||||
|
||||
## What makes this tractable
|
||||
|
||||
Two properties of the existing code make an aggressive split low-risk.
|
||||
|
||||
**The section bodies are almost entirely static.** Across ~750 lines of sections
|
||||
in `SchoolDetailView` (lines 596-1350), the only interactivity is five charts
|
||||
(already `dynamic({ ssr: false })` islands), fourteen `<MetricTooltip>`
|
||||
instances (already a small client leaf), and two buttons driving the admissions
|
||||
year/trend toggle. Everything else renders props.
|
||||
|
||||
**The scroll-spy is already decoupled.** `SchoolDetailView.tsx:283` locates
|
||||
sections with `document.getElementById(id)`, and `navItems` (lines 258-267) is
|
||||
computed purely from data. The navigation does not care which component renders
|
||||
the sections, so server-rendered children work unchanged.
|
||||
|
||||
There is also a precedent to follow: `components/compare/` is already decomposed
|
||||
this way — one file per section plus a `sectionShared.tsx` of primitives.
|
||||
|
||||
## Architecture
|
||||
|
||||
A server component imported by a client component becomes a client component. So
|
||||
the sections cannot be children of a client `SchoolDetailView`. They are composed
|
||||
in `page.tsx` and passed *through* the client shell as `children` — the pattern
|
||||
already used at `app/page.tsx:98-99`.
|
||||
|
||||
The header and navigation sit above the sections, and the sections are plain
|
||||
siblings, so a single `children` slot suffices. Named slots were considered and
|
||||
rejected: more ceremony, no benefit when sections render in sequence.
|
||||
|
||||
```
|
||||
app/school/[slug]/page.tsx server; fetches details + nationalAverages
|
||||
│ computes isSecondary / isAllThrough / navItems
|
||||
└── <SchoolDetailShell schoolInfo navItems> 'use client' — the only large client file
|
||||
│ back link · header + details reveal · hero map · compare CTA
|
||||
│ sticky section nav · scroll spy · jump sheet
|
||||
└── children: <PrimarySchoolSections/> | <SecondarySchoolSections/> server
|
||||
└── components/school/ all server, mirroring components/compare/
|
||||
OfstedSection · ResultsSection · AdmissionsSection
|
||||
InclusionSection · HistorySection · SchoolLifeSection
|
||||
LocalAreaSection · FinancesSection · WellbeingSection
|
||||
sectionShared.tsx Section/Card/Row primitives
|
||||
```
|
||||
|
||||
Phase branching stays out of the route file: two thin server components
|
||||
(`PrimarySchoolSections`, `SecondarySchoolSections`) return the section sequence,
|
||||
and `page.tsx` picks one.
|
||||
|
||||
### Client leaves inside server sections
|
||||
|
||||
Importing a client component into a server component is the legal direction, so
|
||||
these stay client and are used from server sections unchanged:
|
||||
|
||||
- `MetricTooltip` / `InfoPopover` — exist, unchanged
|
||||
- `PerformanceChart`, `SatsChart`, `AdmissionsTrendChart` — exist, stay `dynamic({ ssr: false })`
|
||||
- `AdmissionsViewToggle` — **new**, ~25 lines; receives server-rendered year-view
|
||||
and trend-view as two props and swaps between them
|
||||
- `DeltaChip`, `SpecialSchoolNote` — already server-compatible (no `'use client'`)
|
||||
|
||||
### What stays in the client shell, and why
|
||||
|
||||
The shell is genuinely interactive throughout: `router.back()`, the details
|
||||
reveal, `IntersectionObserver` hero-CTA tracking, the nav overflow fade, the
|
||||
Escape-to-close sheet, and the comparison context. The goal is not to shrink the
|
||||
shell but to stop the other ~750 lines being attached to it.
|
||||
|
||||
### Section sharing
|
||||
|
||||
Sharing is decided by measured similarity, not assumption. Comparing the two
|
||||
views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary | Secondary | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Finances | 29 lines | 35 | 91% | Shared |
|
||||
| Ofsted | 88 | 107 | 80% | Shared |
|
||||
| History | 121 | 47 | 40% | Separate |
|
||||
| Admissions | 89 | 55 | 14% | Separate |
|
||||
|
||||
Admissions and History were assumed shareable when this design was first drafted;
|
||||
measurement during planning showed they are not — primary's admissions carries
|
||||
the year/trend toggle and chart, secondary's is a much simpler panel.
|
||||
|
||||
**This costs nothing in bytes.** The client-JS win comes from a section being a
|
||||
*server* component, not from being shared between views. Sharing is a
|
||||
maintainability benefit, taken only where it is cheap. Forcing the two
|
||||
low-similarity sections together would inflate the diff and the regression risk
|
||||
for no performance gain.
|
||||
|
||||
| Shared | Primary only | Secondary only |
|
||||
| --- | --- | --- |
|
||||
| Ofsted, Finances, `sectionShared` primitives, and the shell | Results (KS2), Admissions, History, Inclusion, School Life, Local Area | GCSEs, Admissions, History, Wellbeing |
|
||||
|
||||
All-through schools render both the KS2 and KS4 section sets via the primary
|
||||
composer.
|
||||
|
||||
### CSS strategy
|
||||
|
||||
Shared section components need one stylesheet, but the two existing CSS modules
|
||||
disagree. Measured on `cab7b4f`: `SchoolDetailView.module.css` defines 142
|
||||
classes, `SecondarySchoolDetailView.module.css` 112, with 79 names in common — of
|
||||
which 22 have different rules, and 16 of those are used inside section markup by
|
||||
both views (`.card`, `.sectionTitle`, `.metricCard`, `.metricValue`,
|
||||
`.metricsGrid`, `.tableWrapper`, the `heroStat*` set, the `genderSplit*` set,
|
||||
`.historyToggle`, `.ofstedReportLink`).
|
||||
|
||||
The 16 conflicts split into two kinds:
|
||||
|
||||
**Incidental drift (14 classes).** One view carries defensive overflow properties
|
||||
the other lacks — `min-width: 0`, `overflow-wrap: break-word`,
|
||||
`word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`.
|
||||
These are independent bug-fixes that were never back-ported, not design
|
||||
decisions. **Resolution: take the union.** No visible change, and it fixes latent
|
||||
long-school-name overflow on whichever page currently lacks the property.
|
||||
|
||||
**Real visual differences (2 classes).**
|
||||
|
||||
| Class | Primary | Secondary |
|
||||
| --- | --- | --- |
|
||||
| `.genderBar` | `height: 6px`, `background: var(--border-color, #e5dfd5)`, `width: 100%` | `height: 4px`, `background: rgba(0, 0, 0, 0.08)`, `margin-top: 0.45rem` |
|
||||
| `.heroStatValue` | `gap: 0.5rem`, `justify-content: flex-start`, Playfair fallback includes Georgia | `gap: 0.4rem`, Playfair fallback omits Georgia |
|
||||
|
||||
**Resolution: keep both looks as distinct classes** and have the owning section
|
||||
take a `variant: 'primary' | 'secondary'` prop selecting between them. Zero
|
||||
intended visual change, one shared stylesheet, and the divergence becomes
|
||||
explicit rather than accidental.
|
||||
|
||||
The merged stylesheet is `components/school/schoolSections.module.css`. Shell-only
|
||||
rules stay behind in the per-view modules. Classes outside the 79 shared names
|
||||
move across unchanged.
|
||||
|
||||
### Prerequisite: national averages move server-side
|
||||
|
||||
`SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`,
|
||||
and `primaryAvg` / `secondaryAvg` feed the England-comparison deltas across most
|
||||
sections. Those sections cannot be server components until that data arrives as a
|
||||
prop. `SecondarySchoolDetailView.tsx:91-93` has the same fetch.
|
||||
|
||||
`page.tsx` will fetch it in parallel with the school details (the endpoint is
|
||||
backend-cached for an hour) and pass `nationalAvg` down. This is a prerequisite,
|
||||
not an optional extra. It independently removes a client round-trip per detail
|
||||
page and puts the deltas in the initial HTML instead of popping in after
|
||||
hydration.
|
||||
|
||||
### Layout
|
||||
|
||||
`Footer` is already a server component — no change needed. `Navigation` genuinely
|
||||
requires client (`usePathname` plus the comparison count) and stays as it is.
|
||||
This item is much smaller than the original review estimated.
|
||||
|
||||
### Unused dependency
|
||||
|
||||
`hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts` and `useSchoolDetails.ts`
|
||||
are imported by nothing. They are the only consumers of `swr`. All four files and
|
||||
the `swr` dependency are deleted.
|
||||
|
||||
## Testing
|
||||
|
||||
`SchoolDetailView` will not exist as a single component afterwards, so
|
||||
characterization tests must not import it directly. A single render helper
|
||||
absorbs the change:
|
||||
|
||||
```
|
||||
__tests__/support/renderSchoolDetail.tsx the only file that changes at the split
|
||||
before: render(<SchoolDetailView {...props} />)
|
||||
after: render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)
|
||||
```
|
||||
|
||||
Every assertion imports that helper and never changes. Assertions passing
|
||||
untouched across the refactor is the proof that behaviour is preserved.
|
||||
|
||||
**Coverage:** Ofsted panel (rating, date, report link); KS2 results rows and
|
||||
England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the
|
||||
year/trend toggle; inclusion and wellbeing figures; the history table; finances;
|
||||
and the conditional `navItems` set.
|
||||
|
||||
**Fixtures:** four shapes — primary, secondary, all-through, and special school.
|
||||
The special-school path drops the England comparison via `isSpecialSchool()` and
|
||||
is the site of a known past regression (PR #70), so it gets explicit coverage.
|
||||
|
||||
**Environment gaps to close in `jest.setup.js`:** jsdom provides neither
|
||||
`IntersectionObserver` (used twice in each view) nor `window.scrollTo`. Chart.js
|
||||
needs a canvas, so the chart components are mocked. `next/jest` already handles
|
||||
CSS modules, and `next/navigation` is already globally mocked. Section components
|
||||
are synchronous functions, so React Testing Library renders both the server and
|
||||
client pieces in jsdom without special handling.
|
||||
|
||||
**E2E:** `e2e/journeys.spec.ts` gains detail-page assertions. CLAUDE.md requires
|
||||
e2e updates in the same PR as user-facing changes.
|
||||
|
||||
## Commit sequence
|
||||
|
||||
One PR, but the diff is large, so it is structured to stay reviewable and
|
||||
bisectable:
|
||||
|
||||
1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures
|
||||
2. `test:` characterization tests against the current components — all green before anything moves
|
||||
3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches
|
||||
4. `refactor:` extract the shared derived-flags/`navItems` module
|
||||
5. `style:` merge the two CSS modules into `components/school/schoolSections.module.css` per the CSS strategy above
|
||||
6. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits)
|
||||
7. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views
|
||||
8. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json`
|
||||
9. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions
|
||||
|
||||
Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes
|
||||
them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge
|
||||
rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect.
|
||||
|
||||
## Verification
|
||||
|
||||
Before the work is claimed complete:
|
||||
|
||||
- `npm run typecheck`, `npm test` and `npm run build` all green
|
||||
- characterization tests passing **unmodified** from commit 2
|
||||
- before/after gzipped JS per route recorded in the PR description, measured the
|
||||
same way as the figures in the Problem section
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `/school/[slug]` route-specific JS drops to roughly 40-60 KB gz, from a client
|
||||
tree currently built out of a 65 KB and a 46 KB source file
|
||||
- No visual or behavioural change on any of the four school shapes
|
||||
- One fewer client API round-trip per detail page
|
||||
|
||||
**This change does not reduce the 172 KB baseline.** That baseline is react-dom,
|
||||
the Next.js runtime, and the layout's client components (`Navigation`,
|
||||
`ComparisonProvider`, `ComparisonToast`) — none of which this design touches, as
|
||||
`Navigation` legitimately needs `usePathname` and the comparison count. The
|
||||
baseline is a separate piece of work; it is cited above only to establish why
|
||||
route-level client JS matters. Reporting must state the two numbers separately so
|
||||
the unchanged baseline is not read as a failure of this change.
|
||||
|
||||
Budget enforcement in CI was considered and deliberately deferred: measurement is
|
||||
recorded in the PR, but no new CI gate is added in this change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No visual redesign or copy changes to any section
|
||||
- No change to `Navigation`, `Footer`, or any other route
|
||||
- No CI bundle-budget gate
|
||||
- Bugs found mid-move are recorded, not fixed inline
|
||||
|
||||
## Risks
|
||||
|
||||
**DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS
|
||||
modules depend on. Commits 4 and 5 are strict moves and the section CSS moves
|
||||
with them; any styling change beyond the CSS-merge rules above is out of scope.
|
||||
|
||||
**CSS merge.** Merging two large stylesheets is the highest-risk part of the
|
||||
work. The union rule is mechanical, but a missed conflict shows up as a visual
|
||||
regression that no unit test catches. The merge gets its own commit, and the
|
||||
16 known conflicts are enumerated above so they can be verified individually.
|
||||
|
||||
**All-through schools.** The trickiest case: `isAllThrough` gates KS2 content
|
||||
back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It
|
||||
gets a dedicated fixture.
|
||||
|
||||
**Large diff.** Accepted deliberately in favour of designing the shared section
|
||||
library against both consumers at once. Mitigated by the commit sequence above.
|
||||
|
||||
**`swr` removal** is safe — nothing imports those hooks.
|
||||
@@ -69,6 +69,146 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
|
||||
await expect(nearMe).toBeVisible();
|
||||
});
|
||||
|
||||
/*
|
||||
* The landing page's claims must match the product and the data.
|
||||
*
|
||||
* Every failure this group guards against is silent: a hardcoded fallback that
|
||||
* renders as if it were live, a number that contradicts a constant elsewhere in
|
||||
* the codebase, illustrative copy that reads as fact. None of them throw, none
|
||||
* fail a build, and none show up in a unit test.
|
||||
*/
|
||||
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
|
||||
const info = await (await page.request.get('/api/data-info')).json();
|
||||
test.skip(info.status !== 'loaded', 'environment has no data loaded');
|
||||
const expected: number = info.unique_schools;
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
|
||||
await page.goto('/');
|
||||
const body = await page.locator('body').innerText();
|
||||
|
||||
// The real count, formatted as the page formats it.
|
||||
expect(body).toContain(expected.toLocaleString('en-GB'));
|
||||
// The fallback that used to render in its place. `total_schools` is not a
|
||||
// field this API returns, so reading it yielded undefined and the "24,000+"
|
||||
// floor was shown on every request while the database held far more.
|
||||
expect(body).not.toContain('24,000+');
|
||||
});
|
||||
|
||||
test('the landing page states the real comparison limit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const body = await page.locator('body').innerText();
|
||||
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
|
||||
// promise three while a card lower on the same page promised five.
|
||||
expect(body).toMatch(/five schools/i);
|
||||
expect(body).not.toMatch(/three schools/i);
|
||||
});
|
||||
|
||||
test('the landing page invents no data about real schools', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const preview = page.locator('table, [class*="comparePreview"]').first();
|
||||
const body = await page.locator('body').innerText();
|
||||
// The compare preview's columns carried two real school names beside
|
||||
// invented results and invented Ofsted grades.
|
||||
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
|
||||
await expect(preview).toContainText(/School A/);
|
||||
});
|
||||
|
||||
test('the home page has a real title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// It was the bare string "Home" — the browser tab, the bookmark and the
|
||||
// search result all read that.
|
||||
expect(await page.title()).not.toBe('Home');
|
||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
||||
});
|
||||
|
||||
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const gaps = await page.evaluate(() => {
|
||||
const landing = document.querySelector('main div[class*="landing"]');
|
||||
if (!landing) return null;
|
||||
const kids = [...landing.children].map(el => el.getBoundingClientRect());
|
||||
const out: number[] = [];
|
||||
for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom));
|
||||
return out;
|
||||
});
|
||||
expect(gaps).not.toBeNull();
|
||||
expect(gaps!.length).toBeGreaterThan(1);
|
||||
// Every band gap comes from the page container's single `gap`, so they are
|
||||
// identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set
|
||||
// its own margin, and the page read as a stack of unrelated strips.
|
||||
expect(new Set(gaps!).size).toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* The hero artwork. Every assertion here covers a failure that renders as a
|
||||
* perfectly valid page: a path that 404s leaves an empty box, a crop that
|
||||
* stops switching silently decapitates the subject, and a format negotiation
|
||||
* that regresses just serves a bigger file.
|
||||
*/
|
||||
function heroImageState(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
|
||||
if (!img) return null;
|
||||
return {
|
||||
// naturalWidth is 0 for an image that failed to load, whatever the src says.
|
||||
loaded: img.complete && img.naturalWidth > 0,
|
||||
file: img.currentSrc.split('/').pop() ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
const wide = await heroImageState(page);
|
||||
expect(wide).not.toBeNull();
|
||||
expect(wide!.loaded).toBe(true);
|
||||
expect(wide!.file).toContain('hero-wide');
|
||||
|
||||
/*
|
||||
* Below the one-column breakpoint the slot becomes a band up to 4.9:1. The
|
||||
* desktop frame centre-cropped to that shape loses the schoolhouse, which is
|
||||
* how the previous hero failed on phones — so <picture> must switch to the
|
||||
* pre-cropped band file, not merely to a smaller version of the same frame.
|
||||
*/
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
const band = await heroImageState(page);
|
||||
expect(band!.loaded).toBe(true);
|
||||
expect(band!.file).toContain('hero-band');
|
||||
});
|
||||
|
||||
test('the hero artwork is served in a modern format', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
const state = await heroImageState(page);
|
||||
// The JPEG exists only as the <img> fallback. Chromium takes AVIF, so seeing
|
||||
// the fallback here means the <source> negotiation broke — which costs about
|
||||
// 24kB extra on the LCP element and nothing visible.
|
||||
expect(state!.file).toMatch(/\.(avif|webp)$/);
|
||||
});
|
||||
|
||||
test('the hero artwork is dimmed for the dark theme', async ({ browser }) => {
|
||||
const filterFor = async (colorScheme: 'light' | 'dark') => {
|
||||
const ctx = await browser.newContext({ colorScheme });
|
||||
const p = await ctx.newPage();
|
||||
await p.goto('/');
|
||||
const value = await p.evaluate(() => {
|
||||
const img = document.querySelector('[class*="heroArt"] img');
|
||||
return img ? getComputedStyle(img).filter : 'missing';
|
||||
});
|
||||
await ctx.close();
|
||||
return value;
|
||||
};
|
||||
|
||||
// The artwork is a fixed raster, so it cannot be re-graded per theme the way
|
||||
// the drawn hero was. Undimmed it is the brightest object on a near-black
|
||||
// page and out-shouts the H1 and the search box — the exact regression the
|
||||
// drawn version shipped with.
|
||||
expect(await filterFor('light')).toBe('none');
|
||||
expect(await filterFor('dark')).toMatch(/brightness/);
|
||||
});
|
||||
|
||||
test('searching by name returns school results', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
@@ -121,6 +261,51 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => {
|
||||
const [urn] = await twoPrimaryUrns(page);
|
||||
const res = await page.request.get(`/api/schools/${urn}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const info = (await res.json()).school_info;
|
||||
|
||||
await page.goto(`/school/${urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Phonics, SEN-type breakdown and average class size were never populated by
|
||||
// the backend — the sections have been removed, so the Phonics section (its
|
||||
// own #phonics anchor) must no longer exist.
|
||||
await expect(page.locator('#phonics')).toHaveCount(0);
|
||||
|
||||
// Newly surfaced GIAS/location fields render when the record carries them.
|
||||
const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||||
if (ageMatch) {
|
||||
await expect(page.getByText(`Ages ${ageMatch[1]}–${ageMatch[2]}`).first()).toBeVisible();
|
||||
}
|
||||
if (info.telephone) {
|
||||
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
|
||||
}
|
||||
if (info.parliamentary_constituency) {
|
||||
await expect(page.getByText('Constituency:').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const [urn] = await twoPrimaryUrns(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Collapsed by default on mobile/tablet …
|
||||
const details = page.locator('#school-header-details');
|
||||
await expect(details).toBeHidden();
|
||||
const toggle = page.getByRole('button', { name: /show all details/i });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// … and the link reveals them (label flips to "Hide details").
|
||||
await toggle.click();
|
||||
await expect(details).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /hide details/i })).toBeVisible();
|
||||
});
|
||||
|
||||
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
|
||||
@@ -478,3 +663,395 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
expect(await rows.count()).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The metric-help triggers are the circled-"?" buttons in the row labels.
|
||||
// "More information" is InfoPopover's default accessible name.
|
||||
const help = page.getByRole('button', { name: 'More information' }).first();
|
||||
await expect(help).toBeVisible({ timeout: 15_000 });
|
||||
await help.click();
|
||||
|
||||
const tip = page.getByRole('tooltip');
|
||||
await expect(tip).toBeVisible();
|
||||
|
||||
// The whole bubble must sit inside the viewport — the original bug pushed it
|
||||
// off the right edge with no way to scroll to it.
|
||||
const box = await tip.boundingBox();
|
||||
const width = page.viewportSize()!.width;
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
|
||||
// And the page must not have gained a horizontal scrollbar from the bubble.
|
||||
const bodyOverflowsX = await page
|
||||
.locator('body')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
|
||||
expect(bodyOverflowsX).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The following two journeys cover the server/client split of the detail page.
|
||||
* The sections are now React Server Components composed in the route and passed
|
||||
* through a client shell; these assert that the two halves still meet correctly
|
||||
* in a real browser, which no unit test can prove.
|
||||
*/
|
||||
|
||||
test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => {
|
||||
// Find a school with at least two years carrying an offer rate — the toggle
|
||||
// only appears then. Data-invariant: uses whatever the environment holds.
|
||||
const res = await page.request.get('/api/schools?search=primary&per_page=50');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn);
|
||||
|
||||
let target: number | null = null;
|
||||
for (const urn of candidates.slice(0, 12)) {
|
||||
const detail = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!detail.ok()) continue;
|
||||
const history = (await detail.json()).admissions_history ?? [];
|
||||
const withRate = history.filter(
|
||||
(h: { first_preference_offer_pct?: number | null }) => h.first_preference_offer_pct != null,
|
||||
);
|
||||
if (withRate.length >= 2) { target = urn; break; }
|
||||
}
|
||||
test.skip(target === null, 'no school in this environment has 2+ years of admissions offer data');
|
||||
|
||||
await page.goto(`/school/${target}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const yearBtn = page.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = page.getByRole('button', { name: /-year trend$/ });
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
// The toggle is the one client island inside an otherwise server-rendered
|
||||
// section: clicking it must swap the two server-rendered views.
|
||||
await trendBtn.click();
|
||||
await expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await yearBtn.click();
|
||||
await expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('sticky section nav jumps to server-rendered sections', async ({ page }) => {
|
||||
const [urn] = await twoPrimaryUrns(page);
|
||||
await page.goto(`/school/${urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The nav is client-rendered from a server-computed list, while the sections
|
||||
// themselves are server-rendered. Every link must resolve to a real section:
|
||||
// the scroll-spy finds them with document.getElementById, so a mismatch
|
||||
// between the two halves would dead-end here.
|
||||
const navLinks = page.locator('nav a[href^="#"]');
|
||||
const count = await navLinks.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await navLinks.nth(i).getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
await expect(page.locator(href!)).toHaveCount(1);
|
||||
}
|
||||
|
||||
// And following one actually moves the page.
|
||||
const before = await page.evaluate(() => window.scrollY);
|
||||
await navLinks.last().click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await page.evaluate(() => window.scrollY);
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
/**
|
||||
* Brand surface. The identity work replaced a favicon-only asset set that
|
||||
* broke iOS home-screen icons, Android PWA installs, and every link preview.
|
||||
* These are silent failures — nothing on the page looks wrong — so they need
|
||||
* a gate.
|
||||
*/
|
||||
test('the brand asset set is complete and served', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
expect(response?.ok()).toBe(true);
|
||||
|
||||
// The share card: without it, every link pasted into a chat renders bare.
|
||||
const ogImage = page.locator('meta[property="og:image"]');
|
||||
await expect(ogImage).toHaveCount(1);
|
||||
const ogUrl = await ogImage.getAttribute('content');
|
||||
expect(ogUrl).toBeTruthy();
|
||||
// metadataBase pins canonical URLs to the production host, which is correct
|
||||
// for prod but means the absolute URL points off-environment on staging.
|
||||
// Fetch the path against whichever environment we're actually testing.
|
||||
const og = await page.request.get(new URL(ogUrl!).pathname + new URL(ogUrl!).search);
|
||||
expect(og.ok()).toBe(true);
|
||||
expect(og.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||
const apple = await page.request.get('/apple-icon');
|
||||
expect(apple.ok()).toBe(true);
|
||||
expect(apple.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// Android needs a maskable PNG or the install prompt has no icon.
|
||||
for (const icon of ['/icon-192.png', '/icon-512.png', '/icon-maskable-512.png']) {
|
||||
const res = await page.request.get(icon);
|
||||
expect(res.ok(), `${icon} should be served`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('theme colour matches the page background in both themes', async ({ browser }) => {
|
||||
// A mismatch here paints a stripe of the wrong colour across the top of the
|
||||
// screen on mobile. Previously the dark themeColor was declared with no dark
|
||||
// styling behind it at all.
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
const context = await browser.newContext({ colorScheme });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
const declared = await page
|
||||
.locator(`meta[name="theme-color"][media*="${colorScheme}"]`)
|
||||
.getAttribute('content');
|
||||
expect(declared, `theme-color declared for ${colorScheme}`).toBeTruthy();
|
||||
|
||||
const painted = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
|
||||
);
|
||||
expect(painted.toLowerCase()).toBe(declared!.toLowerCase());
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the dark theme actually repaints the page', async ({ browser }) => {
|
||||
// Guards the token layer: if a component reintroduces a hardcoded colour,
|
||||
// the surface below stays light while everything around it flips.
|
||||
const read = async (colorScheme: 'light' | 'dark') => {
|
||||
const context = await browser.newContext({ colorScheme });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
const values = await page.evaluate(() => {
|
||||
const s = getComputedStyle(document.body);
|
||||
return { bg: s.backgroundColor, fg: s.color };
|
||||
});
|
||||
await context.close();
|
||||
return values;
|
||||
};
|
||||
|
||||
const light = await read('light');
|
||||
const dark = await read('dark');
|
||||
expect(dark.bg).not.toBe(light.bg);
|
||||
expect(dark.fg).not.toBe(light.fg);
|
||||
});
|
||||
|
||||
/**
|
||||
* Typography and palette integrity.
|
||||
*
|
||||
* The identity PR shipped with every font-family silently falling back to
|
||||
* Times: the font variables landed on <body> while the tokens referencing
|
||||
* them were declared on :root, so --font-display computed to the
|
||||
* guaranteed-invalid value. Nothing threw, no test failed, and the build was
|
||||
* green — the only symptom was visual. These assertions make that class of
|
||||
* failure loud.
|
||||
*/
|
||||
test('the brand typefaces actually load and apply', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const fonts = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
// Only the FIRST family in the stack is the one actually asked for; the
|
||||
// rest are fallbacks and always end in a generic like sans-serif.
|
||||
const first = (el: Element) =>
|
||||
getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
|
||||
const headings = [...document.querySelectorAll('h1, h2')].map(first);
|
||||
return {
|
||||
body: first(document.body),
|
||||
headings,
|
||||
bodyStack: getComputedStyle(document.body).fontFamily,
|
||||
displayToken: root.getPropertyValue('--font-display').trim(),
|
||||
uiToken: root.getPropertyValue('--font-ui').trim(),
|
||||
proseToken: root.getPropertyValue('--font-prose').trim(),
|
||||
};
|
||||
});
|
||||
|
||||
// An empty token means the var() chain broke, which is the exact failure
|
||||
// mode this guards — the computed font-family would look plausible either
|
||||
// way, because an invalid font-family just inherits.
|
||||
expect(fonts.displayToken, '--font-display resolved').not.toBe('');
|
||||
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
|
||||
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
|
||||
|
||||
// Inter carries body copy, every control and every figure; Manrope carries
|
||||
// headings and key messaging. Both must actually resolve, not merely be
|
||||
// named in a stack that never loads.
|
||||
expect(fonts.body, 'body uses Inter').toBe('Inter');
|
||||
expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0);
|
||||
expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope');
|
||||
|
||||
// The Times fallback is the specific failure that shipped once. Match the
|
||||
// family name only — a stack legitimately ends in sans-serif, so anchoring
|
||||
// on /serif$/ would flag a perfectly healthy page.
|
||||
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The wordmark is the one piece of brand chrome a user reads on every page,
|
||||
* and it is set as two spans so "compare" can take the brand colour. A
|
||||
* refactor that drops the second span, or reverts the lowercase styling,
|
||||
* changes the brand without failing anything else.
|
||||
*/
|
||||
test('the header carries the schoolcompare lockup', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i });
|
||||
await expect(home).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Assert on rendered text rather than CSS-module class names, which are
|
||||
// hashed at build time and change on any unrelated edit.
|
||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||
|
||||
const mark = home.locator('svg').first();
|
||||
await expect(mark).toBeVisible();
|
||||
});
|
||||
|
||||
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||
// Form controls don't inherit colour from their parent, so a missing
|
||||
// declaration renders pure black — subtle in light mode, invisible in dark.
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const blacks = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('body *')]
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) return false;
|
||||
if (el.closest('.leaflet-tile-pane')) return false;
|
||||
return getComputedStyle(el).color === 'rgb(0, 0, 0)';
|
||||
})
|
||||
.map((el) => el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(' ')[0])
|
||||
.slice(0, 10)
|
||||
);
|
||||
|
||||
expect(blacks, `elements rendering pure black: ${blacks.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('rendered colours all come from the token palette', async ({ page }) => {
|
||||
// Turns the manual design audit into a gate: anything painted with a colour
|
||||
// the token layer doesn't define has escaped the system, and will not
|
||||
// follow the dark theme.
|
||||
await page.goto('/rankings');
|
||||
await expect(page.locator('table, [class*="rankings"]').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const strays = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const palette = new Set<string>();
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules: CSSRuleList;
|
||||
try { rules = sheet.cssRules; } catch { continue; }
|
||||
for (const rule of rules) {
|
||||
const r = rule as CSSStyleRule;
|
||||
if (r.selectorText !== ':root' || !r.style) continue;
|
||||
for (const prop of r.style) {
|
||||
if (!prop.startsWith('--')) continue;
|
||||
const v = root.getPropertyValue(prop).trim();
|
||||
if (v) palette.add(v.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
const norm = (c: string) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.color = c;
|
||||
document.body.appendChild(d);
|
||||
const v = getComputedStyle(d).color;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const allowed = new Set([...palette].filter((v) => /^#|^rgb/.test(v)).map(norm));
|
||||
|
||||
const found: string[] = [];
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width < 2 || box.height < 2) continue;
|
||||
if (el.closest('.leaflet-tile-pane')) continue; // OSM tiles are imagery, not palette
|
||||
const s = getComputedStyle(el);
|
||||
const checks: Array<[string, string]> = [['color', s.color]];
|
||||
if (s.backgroundColor !== 'rgba(0, 0, 0, 0)') checks.push(['background', s.backgroundColor]);
|
||||
for (const [prop, value] of checks) {
|
||||
if (!value || value.startsWith('rgba(') || allowed.has(value)) continue;
|
||||
const cls = (el.getAttribute('class') || '(none)').split(' ')[0];
|
||||
const entry = `${value} as ${prop} on ${cls}`;
|
||||
if (!found.includes(entry)) found.push(entry);
|
||||
}
|
||||
}
|
||||
return found.slice(0, 12);
|
||||
});
|
||||
|
||||
expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Contrast, in both themes.
|
||||
*
|
||||
* The status hues were originally specced against --bg-primary, but they are
|
||||
* used as chip text on their own tint, which sits on darker surfaces — so the
|
||||
* real ratios were 3.85–4.44:1, under AA, on every school row and result card.
|
||||
* The page ground is the easy case; the tinted chip is the one that fails.
|
||||
*
|
||||
* Waits for transitions to settle before measuring: several components carry
|
||||
* `transition: color`, and reading mid-transition reports colours that were
|
||||
* never on screen at rest.
|
||||
*/
|
||||
const CONTRAST_PROBE = `(() => {
|
||||
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
|
||||
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
|
||||
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
|
||||
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
|
||||
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
|
||||
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
|
||||
const BG = el => { const ls=[]; let n=el;
|
||||
while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor);
|
||||
if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; }
|
||||
const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1};
|
||||
let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base;
|
||||
for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; };
|
||||
const out=[], seen=new Set();
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
if (el.closest('.leaflet-container')) continue;
|
||||
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
|
||||
if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue;
|
||||
if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue;
|
||||
const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue;
|
||||
const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc);
|
||||
const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700;
|
||||
const need=(px>=24||(px>=18.66&&bold))?3:4.5;
|
||||
if (ratio >= need) continue;
|
||||
const key=(el.getAttribute('class')||'')+s.color;
|
||||
if (seen.has(key)) continue; seen.add(key);
|
||||
out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+
|
||||
') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+
|
||||
el.textContent.trim().slice(0,28)+'"');
|
||||
}
|
||||
return out.slice(0, 12);
|
||||
})()`;
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ colorScheme: scheme });
|
||||
const page = await context.newPage();
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const path of ['/', '/rankings', '/admissions']) {
|
||||
await page.goto(path);
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 });
|
||||
// Let `transition: color` settle — the longest in the app is 0.4s.
|
||||
await page.waitForTimeout(700);
|
||||
const found = (await page.evaluate(CONTRAST_PROBE)) as string[];
|
||||
failures.push(...found.map((f) => `${path} → ${f}`));
|
||||
}
|
||||
|
||||
await context.close();
|
||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -47,6 +47,12 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Fonts for the generated share card. File tracing already places these in
|
||||
# .next/standalone/assets, so the COPY above carries them — this makes the
|
||||
# dependency explicit rather than implicit in the tracer's behaviour, because
|
||||
# a miss here is a silent 500 on /opengraph-image, not a build failure.
|
||||
COPY --from=builder /app/assets ./assets
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -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,177 @@
|
||||
/**
|
||||
* Characterization tests for the school detail pages.
|
||||
*
|
||||
* These describe behaviour that ALREADY EXISTS. They are written before the
|
||||
* server/client split and must pass UNMODIFIED after it — that is the whole
|
||||
* point. If one fails during the refactor, the refactor is wrong; fix the
|
||||
* components, never these assertions.
|
||||
*
|
||||
* All rendering goes through renderSchoolDetail / renderSecondarySchoolDetail,
|
||||
* the one file the refactor is permitted to change.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
|
||||
// Chart.js needs a canvas jsdom does not provide, and Leaflet needs a real
|
||||
// window. Both are already lazy client islands; their content is out of scope.
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
describe('Ofsted section', () => {
|
||||
it('renders the legacy OEIF grade, date and report link', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Rating')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inspected 17 May 2023/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Good').length).toBeGreaterThan(0);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Ofsted reports/ });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining(String(primaryFixture.schoolInfo.urn)),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the previous grade when it differs from the current one', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText(/Previously:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Report Card layout with its category bands', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Report Card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/From November 2025, Ofsted replaced single overall grades/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Safeguarding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Met')).toBeInTheDocument();
|
||||
expect(screen.getByText('Achievement')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS2 results', () => {
|
||||
it('renders the combined RWM figure with its England comparison', async () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('58%')).toBeInTheDocument();
|
||||
// 58 vs an England average of 61 → a -3 pts chip and the hint line.
|
||||
expect(await screen.findByText('England avg: 61%')).toBeInTheDocument();
|
||||
expect(await screen.findByText('-3 pts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS4 results', () => {
|
||||
// These figures appear in both the hero stats and the history table, so
|
||||
// assert on presence rather than uniqueness.
|
||||
it('renders Attainment 8, Progress 8 and EBacc figures', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getAllByText('46.2').length).toBeGreaterThan(0);
|
||||
// formatProgress rounds to 1 decimal and signs positives: 0.31 → "+0.3"
|
||||
expect(screen.getAllByText(/\+0\.3/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/EBacc/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-through schools', () => {
|
||||
// The highest-value assertion in this file. isAllThrough gates the KS2
|
||||
// content back on for a school that also has KS4 data, so an all-through
|
||||
// page must show BOTH key stages. This is the case most likely to break.
|
||||
it('renders both KS2 and KS4 figures on the same page', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
|
||||
expect(screen.getAllByText('63%').length).toBeGreaterThan(0); // KS2 RWM
|
||||
expect(screen.getAllByText('48.7').length).toBeGreaterThan(0); // KS4 Attainment 8
|
||||
});
|
||||
|
||||
it('labels the KS2 block explicitly', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('special schools', () => {
|
||||
// Guards the PR #70 regression, where a special school displayed
|
||||
// "0% -62 below England" against a mainstream benchmark that does not fit.
|
||||
it('suppresses the England comparison', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.queryByText(/England avg:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why the comparison is missing', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('This is a special school.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admissions', () => {
|
||||
it('toggles between the year view and the multi-year trend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
const yearBtn = screen.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = screen.getByRole('button', { name: /3-year trend/ });
|
||||
expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const yearView = container.querySelector('[class*="admissionsViewYear"]')!;
|
||||
const trendView = container.querySelector('[class*="admissionsViewTrend"]')!;
|
||||
expect(yearView).not.toHaveAttribute('hidden');
|
||||
expect(trendView).toHaveAttribute('hidden');
|
||||
|
||||
await user.click(trendBtn);
|
||||
|
||||
expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(yearView).toHaveAttribute('hidden');
|
||||
expect(trendView).not.toHaveAttribute('hidden');
|
||||
});
|
||||
|
||||
it('renders the headline admissions figures', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText('Places offered')).toBeInTheDocument();
|
||||
expect(screen.getByText('Got their first choice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the toggle when there is only one year of offer data', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
expect(screen.queryByRole('button', { name: 'This year' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section navigation', () => {
|
||||
it('lists the sections that have data', () => {
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
for (const id of ['ofsted', 'results', 'admissions', 'history', 'finances']) {
|
||||
expect(container.querySelector(`#${id}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits sections with no data', () => {
|
||||
const { container } = renderSchoolDetail(specialFixture);
|
||||
|
||||
expect(container.querySelector('#ofsted')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('#admissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Guards against "stranded" CSS: when markup moves between components, its
|
||||
* rules must move with it. CSS Modules hash class names per file, so a rule
|
||||
* left behind in another stylesheet silently stops applying — exactly how the
|
||||
* header's mobile `.headerDetails { display: none }` (and the section-nav
|
||||
* mobile swap) stopped working when the detail view was split into a client
|
||||
* shell plus server sections.
|
||||
*/
|
||||
|
||||
const SCHOOL_DIR = path.join(__dirname, '..', '..', 'components', 'school');
|
||||
|
||||
function classesDefinedIn(css: string): Set<string> {
|
||||
return new Set(Array.from(css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
function classesUsedIn(tsx: string): Set<string> {
|
||||
return new Set(Array.from(tsx.matchAll(/\bstyles\.([_a-zA-Z][\w]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
const components = fs
|
||||
.readdirSync(SCHOOL_DIR)
|
||||
.filter((f) => f.endsWith('.tsx'))
|
||||
.map((file) => {
|
||||
const source = fs.readFileSync(path.join(SCHOOL_DIR, file), 'utf8');
|
||||
const importMatch = source.match(/import\s+styles\s+from\s+['"](.+?\.module\.css)['"]/);
|
||||
return { file, source, stylesheet: importMatch?.[1] };
|
||||
})
|
||||
.filter((c) => c.stylesheet);
|
||||
|
||||
describe('school detail stylesheets', () => {
|
||||
it.each(components.map((c) => [c.file, c.source, c.stylesheet as string]))(
|
||||
'%s only uses classes its own stylesheet defines',
|
||||
(_file, source, stylesheet) => {
|
||||
const css = fs.readFileSync(path.join(SCHOOL_DIR, stylesheet), 'utf8');
|
||||
const defined = classesDefinedIn(css);
|
||||
const missing = Array.from(classesUsedIn(source)).filter((c) => !defined.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,30 @@ describe('buildCompareChart', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a no-results school whose only row has a null year', () => {
|
||||
// A school with no performance rows comes back from /api/compare with a
|
||||
// single phantom yearly_data row (LEFT JOIN) where year and every metric
|
||||
// are null. That null year must NOT pollute the axis: Math.trunc(null) is
|
||||
// 0, and filling from year 0 blows past the real years, blanking every
|
||||
// school's line. Regression guard for "add a no-data school → chart empty".
|
||||
const withNoData = {
|
||||
...THREE_SCHOOLS,
|
||||
'4': school(4, [[null as unknown as number, null]]),
|
||||
};
|
||||
const list = [...SCHOOL_LIST, { urn: 4, school_name: 'School 4' }];
|
||||
const chart = buildCompareChart(withNoData, list, 'rwm_expected_pct');
|
||||
|
||||
// The real years still drive the axis; the phantom year 0 is gone.
|
||||
expect(chart.years).toContain(201819);
|
||||
expect(chart.years).toContain(202425);
|
||||
expect(chart.years).not.toContain(0);
|
||||
// The three real schools still render their lines.
|
||||
for (const urn of [1, 2, 3]) {
|
||||
const ds = chart.schoolDatasets[urn - 1];
|
||||
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles float years from the API (202425.0 style)', () => {
|
||||
const floaty = {
|
||||
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isPrimary).toBe(true);
|
||||
expect(f.isSecondary).toBe(false);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies an ordinary secondary', () => {
|
||||
const f = computeSchoolFlags(secondaryFixture);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.isAllThrough).toBe(false);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
expect(f.suppressKs4Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isSpecial).toBe(false);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a gender split only when census counts are present', () => {
|
||||
expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true);
|
||||
expect(
|
||||
computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
expect(ids).toContain('history');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const label = (fixture: any) => {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
return buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
}).find((n) => n.id === 'results')?.label;
|
||||
};
|
||||
|
||||
expect(label(primaryFixture)).toBe('SATs');
|
||||
expect(label(secondaryFixture)).toBe('GCSEs');
|
||||
expect(label(allThroughFixture)).toBe('Results');
|
||||
});
|
||||
|
||||
it('keeps the engagement-led ordering', () => {
|
||||
const flags = computeSchoolFlags(primaryFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: primaryFixture.ofsted,
|
||||
admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual([
|
||||
'ofsted', 'results', 'admissions', 'inclusion',
|
||||
'history', 'school-life', 'local-area', 'finances',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSecondaryFlags', () => {
|
||||
it('reads results and sixth form from the secondary fixture', () => {
|
||||
const f = computeSecondaryFlags(secondaryFixture);
|
||||
expect(f.hasResults).toBe(true);
|
||||
expect(f.hasSixthForm).toBe(false);
|
||||
expect(f.hasWellbeing).toBe(true);
|
||||
});
|
||||
|
||||
it('reports sixth form when GIAS flags it', () => {
|
||||
expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the comparison for a special school', () => {
|
||||
const f = computeSecondaryFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressComparison).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => {
|
||||
expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSecondaryNavItems', () => {
|
||||
it('uses the secondary section ids', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: secondaryFixture.ofsted,
|
||||
admissions: secondaryFixture.admissions,
|
||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||
});
|
||||
|
||||
it('gates History on more than one year, unlike the primary page', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('history');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* This file is the ONLY thing the server/client split was allowed to change.
|
||||
* It now renders the shell + server-sections composition that
|
||||
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
|
||||
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
|
||||
* that is the proof the refactor preserved behaviour.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { nationalAveragesFixture } from './schoolFixtures';
|
||||
|
||||
// The shell calls useComparison(), which throws outside the provider. In the
|
||||
// app this wrapper comes from app/layout.tsx.
|
||||
function withProviders(ui: ReactNode) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
{...fixture}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
const flags = computeSecondaryFlags(fixture);
|
||||
const navItems = buildSecondaryNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
{...fixture}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Fixtures for the school detail characterization tests.
|
||||
*
|
||||
* Four shapes, each pinning a branch the detail views take:
|
||||
* primary — KS2 results, legacy OEIF Ofsted, 3 years of admissions
|
||||
* (enough for the year/trend toggle)
|
||||
* secondary — KS4 results, Report Card Ofsted, 1 admissions year
|
||||
* (no toggle)
|
||||
* allThrough — BOTH KS2 and KS4, so the primary content is gated back on
|
||||
* special — a special school with an all-zero KS2 row, where the
|
||||
* England comparison must be suppressed
|
||||
*
|
||||
* Field names come from lib/types.ts. If TypeScript rejects something here,
|
||||
* the fixture is wrong — never widen the type to accommodate a fixture.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders — SchoolResult has 57 required nullable fields, so every fixture
|
||||
// starts from an all-null row and overrides only what it is testing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptyResult: SchoolResult = {
|
||||
school_id: 1,
|
||||
year: 2024,
|
||||
total_pupils: null, eligible_pupils: null,
|
||||
rwm_expected_pct: null, reading_expected_pct: null, writing_expected_pct: null,
|
||||
maths_expected_pct: null, gps_expected_pct: null, science_expected_pct: null,
|
||||
rwm_high_pct: null, reading_high_pct: null, writing_high_pct: null,
|
||||
maths_high_pct: null, gps_high_pct: null,
|
||||
reading_progress: null, writing_progress: null, maths_progress: null,
|
||||
reading_avg_score: null, maths_avg_score: null, gps_avg_score: null,
|
||||
disadvantaged_pct: null, eal_pct: null, sen_support_pct: null, sen_ehcp_pct: null,
|
||||
stability_pct: null,
|
||||
reading_absence_pct: null, gps_absence_pct: null, maths_absence_pct: null,
|
||||
writing_absence_pct: null, science_absence_pct: null,
|
||||
rwm_expected_boys_pct: null, rwm_expected_girls_pct: null,
|
||||
rwm_high_boys_pct: null, rwm_high_girls_pct: null,
|
||||
rwm_expected_disadvantaged_pct: null, rwm_expected_non_disadvantaged_pct: null,
|
||||
disadvantaged_gap: null,
|
||||
rwm_expected_3yr_pct: null, reading_avg_3yr: null, maths_avg_3yr: null,
|
||||
attainment_8_score: null, progress_8_score: null,
|
||||
progress_8_lower_ci: null, progress_8_upper_ci: null,
|
||||
progress_8_english: null, progress_8_maths: null,
|
||||
progress_8_ebacc: null, progress_8_open: null,
|
||||
english_maths_strong_pass_pct: null, english_maths_standard_pass_pct: null,
|
||||
ebacc_entry_pct: null, ebacc_strong_pass_pct: null, ebacc_standard_pass_pct: null,
|
||||
ebacc_avg_score: null, gcse_grade_91_pct: null, prior_attainment_avg: null,
|
||||
};
|
||||
|
||||
export function makeResult(overrides: Partial<SchoolResult> = {}): SchoolResult {
|
||||
return { ...emptyResult, ...overrides };
|
||||
}
|
||||
|
||||
function makeSchool(overrides: Partial<School> = {}): School {
|
||||
return {
|
||||
urn: 123456,
|
||||
school_name: 'Test Primary School',
|
||||
local_authority: 'Westshire',
|
||||
local_authority_code: 900,
|
||||
school_type: 'Community school',
|
||||
school_type_code: 'CY',
|
||||
religious_denomination: 'None',
|
||||
age_range: '4-11',
|
||||
address1: '1 Test Lane',
|
||||
address2: null,
|
||||
town: 'Testville',
|
||||
postcode: 'TE1 1ST',
|
||||
address: '1 Test Lane, Testville, TE1 1ST',
|
||||
latitude: 51.5,
|
||||
longitude: -0.12,
|
||||
phase: 'Primary',
|
||||
gender: 'Mixed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyOfsted: OfstedInspection = {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
report_url: null,
|
||||
};
|
||||
|
||||
function makeOfsted(overrides: Partial<OfstedInspection> = {}): OfstedInspection {
|
||||
return { ...emptyOfsted, ...overrides };
|
||||
}
|
||||
|
||||
function makeAdmissions(overrides: Partial<SchoolAdmissions> = {}): SchoolAdmissions {
|
||||
return {
|
||||
year: 2024,
|
||||
places_offered: 60,
|
||||
total_applications: 210,
|
||||
first_preference_applications: 90,
|
||||
first_preference_offers: 54,
|
||||
first_preference_offer_pct: 60,
|
||||
oversubscribed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const census: SchoolCensus = {
|
||||
year: 2024,
|
||||
total_pupils: 420,
|
||||
female_pupils: 205,
|
||||
male_pupils: 215,
|
||||
fsm_pct: 18.4,
|
||||
eal_pct: 22.1,
|
||||
};
|
||||
|
||||
const absence: AbsenceData = {
|
||||
overall_absence_rate: 5.2,
|
||||
persistent_absence_rate: 14.8,
|
||||
};
|
||||
|
||||
const deprivation: SchoolDeprivation = {
|
||||
lsoa_code: 'E01000001',
|
||||
idaci_score: 0.21,
|
||||
idaci_decile: 4,
|
||||
};
|
||||
|
||||
const finance: SchoolFinance = {
|
||||
year: 2024,
|
||||
per_pupil_spend: 5400,
|
||||
staff_cost_pct: 72.5,
|
||||
teacher_cost_pct: 51.2,
|
||||
support_staff_cost_pct: 21.3,
|
||||
premises_cost_pct: 6.1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// National averages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const nationalAveragesFixture: NationalAverages = {
|
||||
year: 2024,
|
||||
primary: { rwm_expected_pct: 61, rwm_high_pct: 8, reading_expected_pct: 74 },
|
||||
secondary: { attainment_8_score: 45.9, progress_8_score: 0, ebacc_entry_pct: 39 },
|
||||
by_year: [
|
||||
{ year: 2023, primary: { rwm_expected_pct: 60 }, secondary: { attainment_8_score: 44.4 } },
|
||||
{ year: 2024, primary: { rwm_expected_pct: 61 }, secondary: { attainment_8_score: 45.9 } },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 1: ordinary primary — KS2 results, legacy OEIF, 3 admissions years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const primaryFixture = {
|
||||
schoolInfo: makeSchool(),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2022, rwm_expected_pct: 54, reading_expected_pct: 68, writing_expected_pct: 62, maths_expected_pct: 65, total_pupils: 58 }),
|
||||
makeResult({ year: 2023, rwm_expected_pct: 56, reading_expected_pct: 70, writing_expected_pct: 64, maths_expected_pct: 67, total_pupils: 60 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 58, rwm_high_pct: 9,
|
||||
reading_expected_pct: 72, writing_expected_pct: 66, maths_expected_pct: 69,
|
||||
reading_progress: 0.4, writing_progress: -0.2, maths_progress: 1.1,
|
||||
disadvantaged_pct: 24.5, eal_pct: 22.1, sen_support_pct: 13.2,
|
||||
total_pupils: 60,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2023-05-17',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 2,
|
||||
quality_of_education: 2,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 2,
|
||||
leadership_management: 2,
|
||||
previous_overall: 3,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024 }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2022, first_preference_offer_pct: 71 }),
|
||||
makeAdmissions({ year: 2023, first_preference_offer_pct: 65 }),
|
||||
makeAdmissions({ year: 2024, first_preference_offer_pct: 60 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 2: secondary — KS4 results, Report Card Ofsted, single admissions year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const secondaryFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 234567,
|
||||
school_name: 'Test Secondary School',
|
||||
phase: 'Secondary',
|
||||
age_range: '11-16',
|
||||
school_type: 'Academy converter',
|
||||
school_type_code: 'AC',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, attainment_8_score: 44.8, progress_8_score: 0.18 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
attainment_8_score: 46.2, progress_8_score: 0.31,
|
||||
progress_8_banding: 'Above average',
|
||||
english_maths_standard_pass_pct: 68.4,
|
||||
english_maths_strong_pass_pct: 47.1,
|
||||
ebacc_entry_pct: 41.2, ebacc_standard_pass_pct: 28.6,
|
||||
ebacc_strong_pass_pct: 19.4, ebacc_avg_score: 4.31,
|
||||
disadvantaged_pct: 27.8, eal_pct: 19.3, sen_support_pct: 11.7,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'ReportCard',
|
||||
inspection_date: '2025-11-20',
|
||||
rc_inspection_date: '2025-11-20',
|
||||
// isReportCard keys off a non-empty report_card record, NOT the rc_*
|
||||
// fields — the rc_* numbers supply the values once the layout is chosen.
|
||||
report_card: {
|
||||
inclusion: { code: 2, label: 'Strong' },
|
||||
curriculum_teaching: { code: 2, label: 'Strong' },
|
||||
achievement: { code: 3, label: 'Expected standard' },
|
||||
attendance_behaviour: { code: 2, label: 'Strong' },
|
||||
personal_development: { code: 1, label: 'Exceptional' },
|
||||
leadership_governance: { code: 2, label: 'Strong' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
rc_inclusion: 2,
|
||||
rc_curriculum_teaching: 2,
|
||||
rc_achievement: 3,
|
||||
rc_attendance_behaviour: 2,
|
||||
rc_personal_development: 1,
|
||||
rc_leadership_governance: 2,
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [makeAdmissions({ year: 2024, school_phase: 'Secondary' })],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 3: all-through — BOTH key stages on the same row.
|
||||
// The highest-value fixture: isAllThrough gates the KS2 content back on for a
|
||||
// school that also has KS4 data, and this is the case most likely to break.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const allThroughFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 345678,
|
||||
school_name: 'Test All-Through School',
|
||||
phase: 'All-through',
|
||||
age_range: '4-18',
|
||||
has_sixth_form: true,
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, rwm_expected_pct: 55, attainment_8_score: 45.0 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 63, reading_expected_pct: 76,
|
||||
writing_expected_pct: 70, maths_expected_pct: 71,
|
||||
attainment_8_score: 48.7, progress_8_score: 0.42,
|
||||
english_maths_standard_pass_pct: 71.2,
|
||||
disadvantaged_pct: 20.1, eal_pct: 17.5, sen_support_pct: 9.8,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2022-10-04',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 1,
|
||||
quality_of_education: 1,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 1,
|
||||
leadership_management: 1,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2023, school_phase: 'Secondary', first_preference_offer_pct: 58 }),
|
||||
makeAdmissions({ year: 2024, school_phase: 'Secondary', first_preference_offer_pct: 52 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 4: special school — all-zero KS2 row.
|
||||
// Guards the PR #70 regression: special/PRU/AP schools must not be shown an
|
||||
// England comparison, because their pupils sit the same tests but very few
|
||||
// reach the mainstream "expected standard".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const specialFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 456789,
|
||||
school_name: 'Test Special School',
|
||||
school_type: 'Community special school',
|
||||
school_type_code: 'CYS',
|
||||
age_range: '2-19',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0,
|
||||
writing_expected_pct: 0,
|
||||
maths_expected_pct: 0,
|
||||
total_pupils: 12,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: null,
|
||||
census,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+460
-1912
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+39
-25
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import { Manrope, Inter } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -7,17 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
// modern personality". It never sets body copy or a control.
|
||||
const manrope = Manrope({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-dm-sans',
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
// Inter carries body copy, every control and every figure — chosen in the
|
||||
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['600', '700'],
|
||||
variable: '--font-playfair',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -27,41 +32,43 @@ export const viewport: Viewport = {
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'SchoolCompare',
|
||||
title: 'schoolcompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
default: 'schoolcompare | Compare School Performance',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/favicon.svg',
|
||||
},
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
metadataBase: new URL('https://schoolcompare.co.uk'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
url: 'https://schoolcompare.co.uk',
|
||||
siteName: 'SchoolCompare',
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
},
|
||||
};
|
||||
@@ -72,7 +79,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
// var(--font-inter), and a custom property's var() references resolve on
|
||||
// the element that declares it. With the classes on <body>, those two are
|
||||
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
||||
// value and every font-family referencing it silently falls back — which
|
||||
// once shipped the whole site in Times. Nothing throws and no unit test
|
||||
// fails, so the e2e typeface assertion is the only guard.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
@@ -94,8 +109,7 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
<body>
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
/**
|
||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||
* intent channel this product has.
|
||||
*
|
||||
* The card is the brand's own proposition on the Sand ground, with the mark
|
||||
* and a suggestion of the illustration's rolling landscape along the bottom.
|
||||
* It states the promise rather than decorating it.
|
||||
*/
|
||||
export const alt = 'schoolcompare — find the school where your child will flourish';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const SAND = '#F5EFE6';
|
||||
const INK = '#1C2731';
|
||||
const MUTED = '#5F6A75';
|
||||
const TEAL = '#0F766E';
|
||||
const SAGE = '#A7D7C5';
|
||||
const SAGE_DEEP = '#7FC3AC';
|
||||
const SAGE_PALE = '#D6EDE2';
|
||||
|
||||
async function font(file: string) {
|
||||
return readFile(join(process.cwd(), 'assets', file));
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark, as a data URI. Satori has no access to the public/ URL space, so
|
||||
* the artwork is read off disk and inlined. This is the same file the header
|
||||
* serves — public/brand/mark.png — so the card can never drift from the site.
|
||||
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
|
||||
* this has to move with it, or Satori will stretch it.
|
||||
*/
|
||||
async function markDataUri() {
|
||||
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold, mark] = await Promise.all([
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
markDataUri(),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: SAND,
|
||||
padding: '58px 72px 0',
|
||||
fontFamily: 'Manrope',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Rolling hills along the bottom edge — the illustration style,
|
||||
reduced to the one gesture that survives at thumbnail size. */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={mark} width={47} height={56} alt="" />
|
||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||
<span style={{ color: INK }}>school</span>
|
||||
<span style={{ color: TEAL }}>compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.035em',
|
||||
lineHeight: 1.05,
|
||||
color: INK,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||
<span style={{ marginRight: 18 }}>your child will</span>
|
||||
<span style={{ color: TEAL }}>flourish.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
+30
-5
@@ -3,7 +3,9 @@
|
||||
* Main landing page with school search and browsing
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
@@ -24,8 +26,14 @@ interface HomePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Home',
|
||||
/*
|
||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||
* bookmark and the search result all read. `absolute` opts out of the root
|
||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||
description: 'Search and compare school performance across England',
|
||||
};
|
||||
|
||||
@@ -79,14 +87,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
const total = dataInfo?.total_schools ?? null;
|
||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||
// endpoint returns, and reading it silently yielded null on every request.
|
||||
const total = dataInfo?.unique_schools ?? null;
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={total}
|
||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -99,7 +117,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={null}
|
||||
localAuthorityCount={0}
|
||||
earliestYearLabel={null}
|
||||
latestYearLabel={null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails, fetchSchools } from '@/lib/api';
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { NationalAverages } from '@/lib/types';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -92,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
@@ -124,16 +130,24 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data
|
||||
// Fetch school data. National averages feed the England-comparison deltas
|
||||
// across most sections; fetching them here rather than in a client effect
|
||||
// keeps those sections server-renderable and puts the deltas in the initial
|
||||
// HTML. They are supplementary, so they degrade to null rather than 404ing
|
||||
// the page.
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -143,13 +157,30 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
||||
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
|
||||
// Section list is computed on the server so the client shell never needs to
|
||||
// derive it -- and so it can never disagree with what the sections render.
|
||||
const sectionInput = {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
const navInput = {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -184,32 +215,46 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{isSecondary ? (
|
||||
<SecondarySchoolDetailView
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
navItems={secondaryNavItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
) : (
|
||||
<SchoolDetailView
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
navItems={primaryNavItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={primaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -9,6 +9,7 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
@@ -38,6 +39,11 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||
|
||||
// Canvas can't read var(), so pull the resolved tokens through the bridge.
|
||||
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
|
||||
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card'
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: '#e07256',
|
||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||
borderColor: cBrand,
|
||||
backgroundColor: alpha('--brand', 0.10),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: '#e07256',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: cBrand,
|
||||
pointBorderColor: cCard,
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
@@ -51,33 +51,33 @@
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
border-left-color: var(--brand);
|
||||
}
|
||||
|
||||
.navDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────── */
|
||||
@@ -95,8 +95,8 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--brand);
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -107,22 +107,22 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.55;
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
.countdownSection {
|
||||
padding: 0 0 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
@@ -155,11 +155,11 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
@@ -177,12 +177,12 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||
.chipDeadline::before { background: var(--brand); }
|
||||
.chipOffer::before { background: var(--status-above); }
|
||||
|
||||
.chipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||
background: rgba(var(--status-below-rgb), 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
@@ -196,8 +196,8 @@
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||
.chipTrackDeadline { color: var(--brand-strong); }
|
||||
.chipTrackOffer { color: var(--status-above); }
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
@@ -208,7 +208,8 @@
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -217,17 +218,17 @@
|
||||
|
||||
.chipDeadline .chipDays,
|
||||
.chipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
@@ -236,14 +237,14 @@
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
@@ -251,8 +252,8 @@
|
||||
|
||||
.track {
|
||||
margin-bottom: 3rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -263,7 +264,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -278,22 +279,22 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trackSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -317,13 +318,13 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trackDateVal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Timeline ───────────────────────────────────────── */
|
||||
@@ -356,27 +357,27 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDot {
|
||||
background: rgba(224, 114, 86, 0.15);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: rgba(var(--brand-rgb), 0.15);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.stepOffer .stepDot {
|
||||
background: rgba(45, 125, 125, 0.15);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(var(--status-above-rgb), 0.15);
|
||||
border-color: var(--status-above);
|
||||
}
|
||||
|
||||
.stepLine {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -395,27 +396,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepDate { color: var(--brand-strong); }
|
||||
.stepOffer .stepDate { color: var(--status-above); }
|
||||
|
||||
.stepTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepTitle { color: var(--brand-strong); }
|
||||
.stepOffer .stepTitle { color: var(--status-above); }
|
||||
|
||||
.stepBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -427,13 +429,13 @@
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
text-decoration: none;
|
||||
transition: gap 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.stepTool:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--brand-strong);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -455,9 +457,9 @@
|
||||
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);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid rgba(var(--status-below-rgb), 0.28);
|
||||
border-left: 3px solid var(--status-below);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -467,8 +469,8 @@
|
||||
height: 1.05rem;
|
||||
margin-top: 0.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gold, #c9a227);
|
||||
color: #fff;
|
||||
background: var(--status-below);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05rem;
|
||||
@@ -482,14 +484,15 @@
|
||||
.calloutTitle {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold-text, #7a6800);
|
||||
color: var(--status-below);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.calloutBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -501,10 +504,10 @@
|
||||
}
|
||||
|
||||
.tipsHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -515,16 +518,17 @@
|
||||
}
|
||||
|
||||
.tipCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipNumber {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
color: var(--text-muted); /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
line-height: 1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -532,14 +536,15 @@
|
||||
.tipHeading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tipBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -557,8 +562,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 0;
|
||||
background: var(--bg-primary, #faf8f3);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -1.25rem 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
z-index: 10;
|
||||
@@ -590,8 +595,8 @@
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background: var(--text-primary, #1a1612);
|
||||
color: #fff;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '31 October',
|
||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '15 January',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
.chip[aria-pressed="true"] {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
border-color: rgba(0, 0, 0, .35);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
@@ -48,6 +49,13 @@ const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'st
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||||
// stay the same colour.
|
||||
const seriesColors = useSeriesColors();
|
||||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
// A focused school that leaves the basket must not linger.
|
||||
@@ -67,7 +75,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const color = seriesColors[series.schoolIndex % seriesColors.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: isMobile ? 10 : 12,
|
||||
titleFont: {
|
||||
size: isMobile ? 12 : 14,
|
||||
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
...(isMobile && { maxTicksLimit: 5 }),
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
color: cGrid,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.18), 0 2px 8px rgba(var(--shadow-rgb), 0.08);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
@@ -62,13 +62,13 @@
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schoolList {
|
||||
@@ -95,13 +95,13 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -111,7 +111,7 @@
|
||||
.removeSchoolBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.removeSchoolBtn:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
@@ -129,13 +129,13 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btnClearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -145,13 +145,13 @@
|
||||
}
|
||||
|
||||
.btnClearAll:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btnCompare {
|
||||
flex: 1;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
@@ -43,7 +43,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -53,8 +53,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
@@ -87,23 +87,23 @@
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--brand);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
@@ -126,12 +126,12 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
@@ -142,7 +142,7 @@
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -185,22 +185,22 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.barCaptionCount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -212,9 +212,9 @@
|
||||
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
title: 'School comparison · schoolcompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||
: 'schoolcompare',
|
||||
url,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
}
|
||||
|
||||
.good {
|
||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.bad {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(var(--shadow-rgb), 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
|
||||
@@ -5,54 +5,60 @@ import styles from './HomeView.module.css';
|
||||
interface EditorialSectionProps {
|
||||
totalSchools: number | null;
|
||||
localAuthorityCount: number;
|
||||
latestYearLabel: string | null;
|
||||
earliestYearLabel: string | null;
|
||||
}
|
||||
|
||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||
export function EditorialSection({
|
||||
totalSchools,
|
||||
localAuthorityCount,
|
||||
latestYearLabel,
|
||||
earliestYearLabel,
|
||||
}: EditorialSectionProps) {
|
||||
/*
|
||||
* The coverage line replaces a six-row "Coverage at a glance" table.
|
||||
*
|
||||
* That table put six numbers on the page to say one thing, and the headline
|
||||
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
||||
* DataInfoResponse declared a `total_schools` field the API does not send
|
||||
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
||||
* undefined, so the fallback rendered and nothing failed. Every figure here
|
||||
* is now live, and any figure that isn't available is dropped rather than
|
||||
* replaced with a guess.
|
||||
*/
|
||||
const coverage = [
|
||||
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
||||
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
||||
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className={styles.editorial}>
|
||||
<div className={styles.editorialGrid}>
|
||||
// No class on the section: the page container owns the vertical rhythm
|
||||
// now, and this band carries its own ground on the card inside it.
|
||||
<section>
|
||||
<div className={styles.editorialCard}>
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>About school data</p>
|
||||
<h2 className={styles.sectionHeading}>
|
||||
Making England's school performance data actually readable
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||
and demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
full inspection report.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.factbox}>
|
||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Schools covered</span>
|
||||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Local authorities</span>
|
||||
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Phases</span>
|
||||
<span className={styles.factVal}>Primary & Secondary</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Latest results year</span>
|
||||
<span className={styles.factVal}>2024/25</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Historical data</span>
|
||||
<span className={styles.factVal}>2016–2025</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Metrics per school</span>
|
||||
<span className={styles.factVal}>40+</span>
|
||||
</div>
|
||||
</div>
|
||||
{coverage.length > 0 && (
|
||||
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-card, white);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 16px;
|
||||
min-height: 400px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -22,14 +22,14 @@
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-rgb), 0.3);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
/*
|
||||
* FilterBar — one component, two shapes.
|
||||
*
|
||||
* hero a single white pill on the Sand hero panel, carrying the one
|
||||
* coral action the page exists for. No card of its own: the panel
|
||||
* behind it belongs to the page, so the pill reads as the object.
|
||||
* compact the filter rail above the results — a white card of quiet
|
||||
* controls, brand for what is active, sage for what is selected.
|
||||
*
|
||||
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||
* from currentColor rather than a background image so they follow the theme.
|
||||
*/
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────── */
|
||||
|
||||
.filterBar {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: opacity 0.2s ease;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.filterBar.isLoading {
|
||||
@@ -14,112 +29,16 @@
|
||||
}
|
||||
|
||||
.heroMode {
|
||||
padding: 2.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem auto;
|
||||
box-shadow: 0 8px 24px rgba(26, 22, 18, 0.08);
|
||||
border-width: 2px;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
/* ── Search ───────────────────────────────────────────────────────── */
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
@@ -127,43 +46,90 @@
|
||||
|
||||
.omniBoxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||
.heroMode .omniBoxContainer {
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.heroMode .omniBoxContainer:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
.omniIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
|
||||
16px, which throws the user out of the hero on first tap. */
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card, white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.omniInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* Inside the pill the input carries no chrome — the pill holds the border
|
||||
and the focus ring, so the two never double up. */
|
||||
.heroMode .omniInput {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.6875rem 1.25rem;
|
||||
font-size: var(--step--1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 1.375rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -171,103 +137,86 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
/* ── Hero supporting copy ─────────────────────────────────────────── */
|
||||
|
||||
.searchHint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.875rem center;
|
||||
background-size: 10px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.searchHint strong {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.radiusWrapper {
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
white-space: nowrap;
|
||||
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||
action and nothing beside it may compete for the same weight. */
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.45;
|
||||
color: var(--brand-strong);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
||||
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -275,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
@@ -289,76 +238,157 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Pill-style inline filter controls (radius + phase) */
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65rem center;
|
||||
background-size: 10px 6px;
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.radiusLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||
|
||||
.selectShell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.selectShell select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Drawn, not imaged: a background-image arrow has to hardcode its own
|
||||
colour, and would stay light-mode grey on the dark ground. */
|
||||
.selectChevron {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -2px;
|
||||
pointer-events: none;
|
||||
color: var(--text-muted);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
}
|
||||
|
||||
.filterSelect,
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
box-shadow var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterSelect:hover:not(:disabled),
|
||||
.controlSelect:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filterSelect:focus,
|
||||
.controlSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
|
||||
line: the same pairing the results use for "on". */
|
||||
.selectActive {
|
||||
background-color: rgba(var(--sage-rgb), 0.38);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selectActive:hover:not(:disabled) {
|
||||
background-color: rgba(var(--sage-rgb), 0.5);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ──────────────────────────────────────── */
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* When filters are applied, promote the toggle to a coral pill so users
|
||||
can see at a glance that the result list is being narrowed. */
|
||||
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||
the list below is being narrowed. */
|
||||
.advancedToggleActive {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advancedToggleActive:hover {
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
@@ -377,3 +407,70 @@
|
||||
.chevronUp {
|
||||
border-bottom: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
/* The hero pill stays a pill at every width — stacking it would lose the
|
||||
one shape the page is built around. Only the results bar stacks. */
|
||||
.filterBar:not(.heroMode) .omniBoxContainer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.geoError {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Below this the pin costs more room than it earns. */
|
||||
.heroMode .omniIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
|
||||
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||
* colour is the one thing in a select that can't follow the theme.
|
||||
*/
|
||||
function SelectShell({
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.selectChevron} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
||||
const isSecondaryMode =
|
||||
currentPhase === "secondary" || genderOptions.length > 0;
|
||||
|
||||
// A select that is narrowing the results carries a sage tint; the class is
|
||||
// only ever additive, so the control's behaviour is untouched.
|
||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||
>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
{isHero && (
|
||||
<span className={styles.omniIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${styles.searchButton}`}
|
||||
className={`btn btn-action ${styles.searchButton}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
@@ -248,34 +292,38 @@ export function FilterBar({
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -307,87 +355,97 @@ export function FilterBar({
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* The footer band.
|
||||
*
|
||||
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||
* band rather than to the page.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
background: var(--accent-navy, #2c3e50);
|
||||
color: var(--bg-secondary, #f3ede4);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--on-sunken);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -12,7 +22,7 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -20,31 +30,81 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||
|
||||
.lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg-primary, #faf7f2);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.lockupMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
/* Height drives; the artwork is taller than it is wide. */
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.lockupMark img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.wordmarkAccent {
|
||||
color: var(--sage);
|
||||
}
|
||||
|
||||
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||
for a full sentence. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
/* --sage flips with the theme; this band does not (it is teal in both), so
|
||||
the pairing only held in one of them — the dark sage measured 4.08:1 here
|
||||
against a 4.5 floor, on every page. The --on-sunken-* family exists
|
||||
precisely because this surface is theme-invariant. */
|
||||
color: var(--on-sunken-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.links {
|
||||
@@ -57,43 +117,64 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.linkDisabled {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||
* colour the token layer exists to remove.
|
||||
*/
|
||||
.bottom {
|
||||
position: relative;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(250, 247, 242, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--on-sunken);
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.copyright,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.6);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
}
|
||||
|
||||
.disclaimer .link {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--on-sunken-link);
|
||||
}
|
||||
|
||||
.disclaimer .link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Site footer with links and info
|
||||
*
|
||||
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||
* that stays teal in both themes. Everything inside it therefore reads from
|
||||
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||
* invert and would go dark against the teal in one theme or the other.
|
||||
*/
|
||||
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
@@ -13,7 +18,20 @@ export function Footer() {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
{/*
|
||||
On the sunken band the mark inverts: the pin takes the band's
|
||||
foreground and the leaf is knocked out in the band itself. The
|
||||
header's defaults (brand pin, card leaf) would disappear here.
|
||||
*/}
|
||||
<h3 className={styles.lockup}>
|
||||
<span className={styles.lockupMark}>
|
||||
<LogoMark variant="onDark" size={34} />
|
||||
</span>
|
||||
<span className={styles.wordmark}>
|
||||
school<span className={styles.wordmarkAccent}>compare</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
@@ -31,7 +49,9 @@ export function Footer() {
|
||||
<ul className={styles.links}>
|
||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||
{/* "Compare", not "shortlist" — the nav, this link and the
|
||||
landing page all name the same feature the same way. */}
|
||||
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -69,7 +89,7 @@ export function Footer() {
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<p className={styles.copyright}>
|
||||
© {currentYear} SchoolCompare.co.uk
|
||||
© {currentYear} schoolcompare.co.uk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+296
-117
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -39,13 +40,22 @@ function daysUntil(month: number, day: number): number {
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
function nextOccurrence(month: number, day: number): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const target = new Date(y, month - 1, day);
|
||||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatShortDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface CountdownChipData {
|
||||
@@ -56,6 +66,16 @@ interface CountdownChipData {
|
||||
day: number;
|
||||
}
|
||||
|
||||
interface AdmissionsMilestone {
|
||||
chip: CountdownChipData;
|
||||
/** Whole days from today. Always present — computed during render. */
|
||||
days: number;
|
||||
/** "Sat, 31 October 2026" — for the headline bar. */
|
||||
dateLabel: string;
|
||||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||||
shortDate: string;
|
||||
}
|
||||
|
||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||
@@ -63,6 +83,103 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||
local because they exist for this one row and nothing else. */
|
||||
|
||||
const ICON_PROPS = {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 21,
|
||||
height: 21,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const;
|
||||
|
||||
function ShieldCheckIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
icon: React.ReactNode;
|
||||
tintClass: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||
* favourites). That feature does not exist in this product, so it is
|
||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||
* and is backed by the next-deadline bar further down this page.
|
||||
*
|
||||
* Every claim here must name something the product actually does. Two of the
|
||||
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
||||
* correctly said five), and "class sizes" described data the codebase has never
|
||||
* held — grep for it and this line was the only hit. Both are corrected below
|
||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
title: 'Official & trusted',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to five schools side by side, on the measures that matter.',
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon />,
|
||||
tintClass: styles.propIconContext,
|
||||
title: 'Beyond the numbers',
|
||||
body: 'SEN support, pupil premium and attendance — not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
tintClass: styles.propIconDeadline,
|
||||
title: 'Never miss a deadline',
|
||||
body: 'Application and offer dates for both primary and secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -83,9 +200,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const mapParamsRef = useRef<string>('');
|
||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||
);
|
||||
/*
|
||||
* Computed during render, on the server as well as the client, so the
|
||||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||||
*
|
||||
* The previous version deferred this to an effect to dodge a hydration
|
||||
* mismatch, which meant the section had to reserve its own height — and the
|
||||
* reservation was a single guessed number for a block whose supporting line
|
||||
* wraps to a different height at every width. Measured, it was short at
|
||||
* every breakpoint, shifting the page by up to 108px on a phone.
|
||||
*
|
||||
* The mismatch it was dodging is real but tiny: a server rendering at
|
||||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||||
*/
|
||||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||||
chip,
|
||||
days: daysUntil(chip.month, chip.day),
|
||||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||||
shortDate: formatShortDate(chip.month, chip.day),
|
||||
}));
|
||||
measured.sort((a, b) => a.days - b.days);
|
||||
return measured;
|
||||
});
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -148,13 +286,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||
setSortedChips(withDays);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
track('results_load_more', { next_page: currentPage + 1 });
|
||||
@@ -243,6 +374,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
});
|
||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||
|
||||
/*
|
||||
* The coverage figure, or nothing at all.
|
||||
*
|
||||
* This used to be the string "24,000+", hardcoded in three places while the
|
||||
* database held 27,230 — and the fact box that was meant to show the live
|
||||
* number silently rendered its fallback, because DataInfoResponse declared a
|
||||
* `total_schools` field the API has never sent. Now there is one source: if
|
||||
* the real count is unavailable the sentence simply omits it rather than
|
||||
* inventing a floor.
|
||||
*/
|
||||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||||
|
||||
/*
|
||||
* The headline milestone is the next thing a parent can actually miss — an
|
||||
* application deadline. Offer days are dates you receive something on, so
|
||||
* they belong in the supporting line, not in the page's largest numeral.
|
||||
* Four equally-sized cards counting down to things 245 days away was the
|
||||
* loudest element on the page and asked nothing of anyone.
|
||||
*/
|
||||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||||
|
||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||
// split which surface drives compare adds.
|
||||
const addSchoolFromSearch = useCallback((school: School) => {
|
||||
@@ -255,113 +408,138 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||
CSS); the search arrangement is a filter bar directly above its results
|
||||
and wants none of it. */
|
||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
which is why FilterBar is rendered in two places rather than moved.
|
||||
The hero and the four value props are one group, so they sit closer
|
||||
together than the page's section gap. */}
|
||||
{!isSearchActive ? (
|
||||
<div className={styles.heroGroup}>
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<h1 className={styles.heroTitle}>
|
||||
Find the school where your child will{' '}
|
||||
<em className={styles.heroEmph}>flourish.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
Compare every school in England with clarity and confidence.
|
||||
{/* The coverage sentence is the detail a first-time visitor needs
|
||||
but a returning one does not, so it drops away on phones where
|
||||
every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||
</div>
|
||||
<div
|
||||
className={styles.countdownRail}
|
||||
style={{
|
||||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{sortedChips.map(({ chip, days }) => {
|
||||
const isUrgent = days !== null && days <= 14;
|
||||
const chipClass = [
|
||||
styles.countdownChip,
|
||||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||
isUrgent ? styles.countdownChipUrgent : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const trackClass = [
|
||||
styles.chipTrack,
|
||||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||
].join(' ');
|
||||
return (
|
||||
<div key={chip.milestone} className={chipClass}>
|
||||
<span className={trackClass}>
|
||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||
{chip.track}
|
||||
</span>
|
||||
<div>
|
||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||
</div>
|
||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||
<div className={styles.chipDate}>
|
||||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
Rendered after the content on purpose. On desktop it is absolutely
|
||||
positioned behind the panel, so order does not matter; below the
|
||||
one-column breakpoint it returns to normal flow, where DOM order is
|
||||
what puts the band under the search rather than above the headline.
|
||||
*/}
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why this site, in four lines. These titles are labels on a list,
|
||||
not section headings — as <h2> they outranked the page's real
|
||||
headings in the document outline and gave a screen-reader user
|
||||
four false landmarks before any content. */}
|
||||
<ul className={styles.valueProps}>
|
||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||
<li key={title} className={styles.valueProp}>
|
||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||
<div>
|
||||
<p className={styles.propTitle}>{title}</p>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={false}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary discovery — moved below deadlines so the admissions
|
||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.exploringRow}>
|
||||
<span className={styles.exploringLabel}>Start exploring</span>
|
||||
<div className={styles.exploringChips}>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated primary schools
|
||||
</a>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated secondary schools
|
||||
</a>
|
||||
<a href="/compare" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Start a comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Next admissions deadline — one bar, not four cards. */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||
{nextDeadline && (
|
||||
<>
|
||||
<a
|
||||
href="/admissions"
|
||||
className={
|
||||
nextDeadline.days <= 14
|
||||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||||
: styles.nextDeadline
|
||||
}
|
||||
>
|
||||
<span className={styles.nextDeadlineBody}>
|
||||
<span className={styles.nextDeadlineKicker}>
|
||||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||||
</span>
|
||||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||||
{nextDeadline.dateLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.nextDeadlineCount}>
|
||||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||||
</span>
|
||||
{nextDeadline.days !== 0 && (
|
||||
<span className={styles.nextDeadlineUnit}>days left</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
<p className={styles.laterDates} suppressHydrationWarning>
|
||||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||
{' '}
|
||||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||
{!isSearchActive && howItWorks}
|
||||
{!isSearchActive && editorial}
|
||||
|
||||
{/* Results Section */}
|
||||
{/* Results Section. Skipped entirely on the landing page when there is
|
||||
nothing to list — an empty <section> is still a flex child, so it was
|
||||
contributing a full section gap of blank space above the footer. */}
|
||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
@@ -547,6 +725,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -573,10 +752,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: rwmDelta >= 2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||
: rwmDelta <= -2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
@@ -601,13 +780,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
fontWeight: 600,
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||||
{ background: '#e0e0e0', color: '#666' }),
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||
}}
|
||||
>
|
||||
{ofstedBadge.label}
|
||||
|
||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||
];
|
||||
/*
|
||||
* The columns are deliberately unnamed.
|
||||
*
|
||||
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
||||
* and "St Mary's Catholic Primary" — beside invented results and invented
|
||||
* Ofsted grades. On a site whose entire proposition is official DfE and
|
||||
* Ofsted data, publishing made-up figures against identifiable schools is
|
||||
* the one thing it cannot do, and no illustrative intent survives being
|
||||
* screenshotted. Generic labels make the layout point just as well.
|
||||
*/
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
||||
|
||||
return (
|
||||
<section className={styles.howItWorks}>
|
||||
<div className={styles.hiwHeader}>
|
||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||
{/* One header pattern across every band on this page: kicker, heading,
|
||||
optional aside. The page previously ran four different treatments
|
||||
(two uppercase micro-labels — one left, one centred — a large heading
|
||||
with right-aligned grey text, and a kicker-plus-heading), which is
|
||||
most of why a designed page read as a stack of unrelated strips. */}
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>Every school page</p>
|
||||
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
||||
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
||||
</div>
|
||||
<div className={styles.hiwGrid}>
|
||||
{/* Card 1 — Performance */}
|
||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
||||
<div className={styles.comparePreview}>
|
||||
<div className={styles.compareHead}>
|
||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||
<div className={styles.compareHeadCell}>School A</div>
|
||||
<div className={styles.compareHeadCell}>School B</div>
|
||||
</div>
|
||||
{compareRows.map(({ label, a, b, aHi }) => (
|
||||
<div key={label} className={styles.compareRow}>
|
||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
||||
<span className={styles.compareRowVal}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Compare</div>
|
||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
<div className={styles.hiwTitle}>Side by side</div>
|
||||
{/* One verb for one feature. The site previously called this
|
||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||
which reads as three separate things it does not have. */}
|
||||
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The landing hero artwork.
|
||||
*
|
||||
* This is the supplied illustration, not a drawing of one — see
|
||||
* assets/hero-source.png and scripts/build-hero-images.js, which produces
|
||||
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
|
||||
*
|
||||
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
|
||||
* renders in the RSC pass and never reaches the client bundle.
|
||||
*
|
||||
* TWO CROPS, ONE <picture>
|
||||
* ------------------------
|
||||
* The slot is two different shapes. On desktop the artwork sits behind the
|
||||
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
|
||||
* area the illustration reserves on its left. Below the one-column breakpoint
|
||||
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
|
||||
* behind it.
|
||||
*
|
||||
* A single file under `object-fit: cover` centre-crops, and at the band's
|
||||
* extreme that slices a strip through the middle of the scene and loses the
|
||||
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
|
||||
* <source media> switches crop, not just resolution: `band` is pre-cropped
|
||||
* around the school and is already near 2.6:1, so the narrow band barely
|
||||
* crops it further.
|
||||
*
|
||||
* `object-position` is set in CSS per breakpoint and is load-bearing — see
|
||||
* .heroArt in HomeView.module.css.
|
||||
*
|
||||
* The artwork is decorative: the proposition beside it carries the meaning, so
|
||||
* alt is empty and it is hidden from assistive tech rather than described.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
|
||||
* padding (1rem under 768px), so the artwork's box is that minus the padding —
|
||||
* describing it as 100vw over-requests by ~48px worth of candidate at every
|
||||
* width, which on a slow connection is a larger file than needed for the LCP
|
||||
* element.
|
||||
*/
|
||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
||||
|
||||
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||
const WIDE_W = 1672;
|
||||
const WIDE_H = 941;
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
<picture>
|
||||
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
|
||||
rules have to precede the unconstrained desktop ones. */}
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
The hero is the largest thing above the fold, so it is almost certainly
|
||||
the LCP element: fetchPriority high, and never lazy. eslint's
|
||||
no-img-element wants next/image, which cannot art-direct between two
|
||||
different crops — that is the whole point of the <picture> above.
|
||||
*/}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/brand/hero-wide-1200.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={WIDE_W}
|
||||
height={WIDE_H}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
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(--brand-strong);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,10 @@ interface LeafletHeroMapInnerProps {
|
||||
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||
const PIN_HTML = `
|
||||
<div style="position:relative;width:26px;height:26px">
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||||
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="var(--brand)"/>
|
||||
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
|
||||
// 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' };
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||||
}
|
||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||
const yearStr = year ? ` · ${year}` : '';
|
||||
if (school.ofsted_grade) {
|
||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||
const colours: Record<number, string> = {
|
||||
1: 'background:#d4f0ea;color:#2d7d7d',
|
||||
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
|
||||
3: 'background:#fef3cd;color:#7a6800',
|
||||
4: 'background:#fde8e0;color:#e07256',
|
||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||
};
|
||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||
}
|
||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||
@@ -99,7 +99,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: #e07256;
|
||||
background: var(--brand);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
@@ -145,13 +145,13 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const sign = diff >= 0 ? '+' : '';
|
||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||
// vs primary RWM % where ±2 pts is the threshold
|
||||
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
} else if (school.rwm_expected_pct != null) {
|
||||
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
let deltaLine = '';
|
||||
if (nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const text =
|
||||
diff >= 2 ? `+${diff} pts vs national` :
|
||||
diff <= -2 ? `${diff} pts vs national` :
|
||||
@@ -167,8 +167,8 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
}
|
||||
@@ -177,14 +177,14 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
|
||||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary, #f3ede4) 25%,
|
||||
rgba(224, 114, 86, 0.08) 50%,
|
||||
var(--bg-secondary, #f3ede4) 75%
|
||||
var(--bg-secondary) 25%,
|
||||
rgba(var(--brand-rgb), 0.08) 50%,
|
||||
var(--bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
@@ -56,7 +56,7 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@
|
||||
}
|
||||
|
||||
.skeletonListItem {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The schoolcompare mark.
|
||||
*
|
||||
* This is the supplied brand artwork, not a reconstruction of it. Two
|
||||
* colourways were extracted from the logo sheet and live in public/brand:
|
||||
*
|
||||
* mark.png the primary lockup's mark — teal pin, white window and
|
||||
* path, green leaves. Reads correctly on Warm White,
|
||||
* white cards and Sand, and on both dark-theme grounds.
|
||||
* mark-on-dark.png the on-dark colourway — white pin with the counter
|
||||
* knocked through to the ground. Required wherever the
|
||||
* ground is teal, because there the teal pin's silhouette
|
||||
* disappears and only the white window survives.
|
||||
*
|
||||
* Both are written onto one 176×208 canvas with the artwork at the same
|
||||
* height, so they are drop-in swappable — see ASPECT below for why that
|
||||
* matters rather than merely being neat.
|
||||
*
|
||||
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
|
||||
* <picture> source, so this needs no JavaScript and no client boundary.
|
||||
*
|
||||
* The wordmark beside it is live text in Manrope rather than the sheet's
|
||||
* raster: the written style guide specifies Manrope, and live text stays
|
||||
* selectable, scales cleanly and recolours with the theme.
|
||||
*
|
||||
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
|
||||
* which is ample for the header (36px), the favicon and the share card, but
|
||||
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
|
||||
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
|
||||
* consumer goes through this component or public/brand, so it is one swap.
|
||||
*/
|
||||
|
||||
const MARK_LIGHT = '/brand/mark.png';
|
||||
const MARK_ON_DARK = '/brand/mark-on-dark.png';
|
||||
|
||||
/**
|
||||
* Intrinsic size of the artwork files, used to derive width from height.
|
||||
*
|
||||
* Both colourways are deliberately written onto the SAME canvas at the same
|
||||
* artwork height, so this one ratio is correct for either of them. That is not
|
||||
* tidiness — it is required. `variant="auto"` renders a single <img> whose
|
||||
* srcset swaps the file underneath it, so the width/height attributes are
|
||||
* shared by both colourways and cannot be varied per file. When the two were
|
||||
* tightly cropped they had different ratios (0.810 and 0.875) and different
|
||||
* padding, which meant a wrong aspect hint before load — a reflow on load
|
||||
* under `width: auto` — and a visible jump in logo size whenever the OS theme
|
||||
* flipped. Re-crop or replace one file and you must re-normalise both.
|
||||
*/
|
||||
const ART_WIDTH = 176;
|
||||
const ART_HEIGHT = 208;
|
||||
const ASPECT = ART_WIDTH / ART_HEIGHT;
|
||||
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
/** Rendered height in px. Width follows the artwork's aspect ratio. */
|
||||
size?: number;
|
||||
/**
|
||||
* Which colourway to serve.
|
||||
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
|
||||
* - `onDark` always the white pin — for teal grounds, which are teal in
|
||||
* both themes (the footer band)
|
||||
*/
|
||||
variant?: 'auto' | 'onDark';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
|
||||
const height = size;
|
||||
const width = Math.round(size * ASPECT);
|
||||
const alt = title ?? '';
|
||||
|
||||
if (variant === 'onDark') {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_ON_DARK}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_LIGHT}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
import styles from './MetricTooltip.module.css';
|
||||
import { InfoPopover } from './InfoPopover';
|
||||
|
||||
interface MetricTooltipProps {
|
||||
metricKey?: string;
|
||||
@@ -13,51 +12,13 @@ interface MetricTooltipProps {
|
||||
|
||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||
const tooltipLabel = label ?? explanation?.label;
|
||||
const tooltipPlain = plain ?? explanation?.plain;
|
||||
const tooltipDetail = detail ?? explanation?.detail;
|
||||
|
||||
// Tap/click/keyboard toggle so the definition is reachable on touch devices
|
||||
// and by keyboard, not just mouse hover (hover still works on desktop).
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (e: Event) => {
|
||||
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', dismiss);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', dismiss);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!tooltipPlain) return null;
|
||||
|
||||
const resolvedLabel = label ?? explanation?.label;
|
||||
return (
|
||||
<span className={styles.wrapper} ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.icon}
|
||||
aria-expanded={open}
|
||||
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
|
||||
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
||||
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
||||
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<InfoPopover
|
||||
label={resolvedLabel}
|
||||
plain={plain ?? explanation?.plain}
|
||||
detail={detail ?? explanation?.detail}
|
||||
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
background: rgba(var(--shadow-rgb), 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card, white);
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(26, 22, 18, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(var(--shadow-rgb), 0.2);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -62,22 +62,22 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -87,8 +87,8 @@
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -102,16 +102,16 @@
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Header and mobile tab bar, on the schoolcompare brand.
|
||||
*
|
||||
* The header is a white card band closed by a single hairline rule — no
|
||||
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||
* wordmark and the one active nav item, and nothing else.
|
||||
*/
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -16,112 +23,121 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||
sticky section nav docks against it with a hardcoded `top` in
|
||||
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||
places or not at all. */
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||
margin: -0.375rem -0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||
* currentColor, so there is deliberately no `color` here.
|
||||
*/
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-coral, #e07256);
|
||||
flex: 0 0 auto;
|
||||
/* The artwork is taller than it is wide (153:189), so height drives the box
|
||||
and width follows. Constraining both would squash the pin. */
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
width: 100%;
|
||||
.logoIcon img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||
* negative tracking is what makes the two halves read as a single word rather
|
||||
* than as two.
|
||||
*/
|
||||
.logoText {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.logoTextAccent {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.logo:hover .logoTextAccent {
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sliding underline effect */
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||
state never rests on colour alone. */
|
||||
.navLink.active {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.navLink.active::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
@@ -149,25 +165,23 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
@@ -179,7 +193,29 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||
the badge's anchor. */
|
||||
.tabIconWrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px -9px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.tabActive .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tab:active .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -190,19 +226,21 @@
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border: 2px solid var(--bg-card, white);
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border: 2px solid var(--bg-card);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
@@ -219,8 +257,12 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
@@ -230,9 +272,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 0.06);
|
||||
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||
landscape) insets so the tab content never sits under system UI. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Navigation.module.css';
|
||||
|
||||
type IconProps = { className?: string };
|
||||
@@ -78,6 +79,16 @@ export function Navigation() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||
* system that does not exist. Drawing chrome for features we do not have
|
||||
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||
* which the guideline omits — take their place, because they are real.
|
||||
*
|
||||
* If accounts ever ship, this is the list to extend.
|
||||
*/
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
@@ -89,15 +100,19 @@ export function Navigation() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
|
||||
{/*
|
||||
variant="auto": the teal pin on the white header, swapping to the
|
||||
on-dark artwork for dark-theme viewers. The footer has to force
|
||||
onDark instead, because its band is teal in both themes and the
|
||||
teal pin has no silhouette against it.
|
||||
*/}
|
||||
<span className={styles.logoIcon}>
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
<LogoMark size={38} />
|
||||
</span>
|
||||
<span className={styles.logoText}>
|
||||
school<span className={styles.logoTextAccent}>compare</span>
|
||||
</span>
|
||||
<span className={styles.logoText}>SchoolCompare</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Main navigation">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -21,18 +21,18 @@
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navButton:disabled {
|
||||
@@ -51,32 +51,32 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pageButtonActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.pageButtonActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
@@ -84,7 +84,7 @@
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
.trendSummary {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 0 4px 4px 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.covidNote,
|
||||
.chartHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
@@ -87,21 +87,21 @@
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chipActive:active {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.miniLegend {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
@@ -124,9 +125,17 @@ export function PerformanceChart({
|
||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||
const hasGap = gapYears.length > 0;
|
||||
|
||||
// Chart.js paints to a canvas, so it needs resolved colours rather than
|
||||
// var(). Reading them through the token bridge keeps the series on the
|
||||
// brand palette and lets them follow the theme.
|
||||
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
|
||||
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
borderColor: 'rgba(90,80,70,0.35)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [6, 4] as number[],
|
||||
@@ -140,8 +149,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: col('attainment_8_score'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -151,8 +160,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: col('english_maths_standard_pass_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -161,8 +170,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: col('progress_8_score'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -179,8 +188,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: col('rwm_expected_pct'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -190,8 +199,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Exceeding expected level',
|
||||
data: col('rwm_high_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -206,8 +215,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading progress',
|
||||
data: col('reading_progress'),
|
||||
borderColor: 'rgb(59,130,246)',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderColor: c4,
|
||||
backgroundColor: alpha('--chart-4', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -217,8 +226,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Writing progress',
|
||||
data: col('writing_progress'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -228,8 +237,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Maths progress',
|
||||
data: col('maths_progress'),
|
||||
borderColor: 'rgb(236,72,153)',
|
||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||
borderColor: c5,
|
||||
backgroundColor: alpha('--chart-5', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -301,7 +310,9 @@ export function PerformanceChart({
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 12,
|
||||
titleFont: { size: 13 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -321,7 +332,7 @@ export function PerformanceChart({
|
||||
type: 'linear', display: true, position: 'left',
|
||||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||
min: 0, max: isSecondary ? undefined : 100,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
@@ -346,7 +357,9 @@ export function PerformanceChart({
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
@@ -364,7 +377,7 @@ export function PerformanceChart({
|
||||
y: {
|
||||
type: 'linear', display: true, position: 'left',
|
||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||
},
|
||||
x: {
|
||||
@@ -429,9 +442,9 @@ export function PerformanceChart({
|
||||
belong together. */}
|
||||
{isMobile && activeChip === 'progress' && (
|
||||
<div className={styles.miniLegend}>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -47,34 +47,34 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
@@ -88,7 +88,7 @@
|
||||
.filterLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -98,44 +98,44 @@
|
||||
max-width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.filterSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.filterSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Rankings Section */
|
||||
.rankingsSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
@@ -151,15 +151,15 @@
|
||||
}
|
||||
|
||||
.rankingsTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
@@ -206,8 +206,8 @@
|
||||
|
||||
.rankingsTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:last-child td {
|
||||
@@ -216,24 +216,24 @@
|
||||
|
||||
/* Alternating row backgrounds for visual rhythm */
|
||||
.rankingsTable tbody tr:nth-child(even) {
|
||||
background: rgba(243, 237, 228, 0.5);
|
||||
background: rgba(var(--text-inverse-rgb), 0.5);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Top 3 Highlighting with Gold */
|
||||
.rank1 {
|
||||
background: linear-gradient(90deg, rgba(201, 162, 39, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank2 {
|
||||
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank3 {
|
||||
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rankCell {
|
||||
@@ -241,7 +241,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Styled rank badges for top 3 */
|
||||
@@ -254,8 +254,8 @@
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-on);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -265,30 +265,31 @@
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
|
||||
background: linear-gradient(135deg, rgba(var(--text-inverse-rgb), 0.4), transparent) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
.rankBadge1 {
|
||||
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge2 {
|
||||
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge3 {
|
||||
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolCell {
|
||||
@@ -296,18 +297,18 @@
|
||||
}
|
||||
|
||||
.schoolLink {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolLink:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.areaCell,
|
||||
.typeCell {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||
@@ -315,7 +316,7 @@
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.valueCell {
|
||||
@@ -324,7 +325,7 @@
|
||||
}
|
||||
|
||||
.valueCell strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.noResults p {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Per-bar national average marker ──
|
||||
@@ -54,7 +54,7 @@
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
border-radius: 2px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
@@ -69,7 +69,7 @@
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barHeaderRight {
|
||||
@@ -81,7 +81,7 @@
|
||||
.natLabel {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -123,18 +123,18 @@
|
||||
}
|
||||
|
||||
.barExpected {
|
||||
background: var(--accent-teal-light, #3a9e9e);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barExceeding {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barLabel {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
.barLabelSuffix {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -153,7 +153,7 @@
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rulerTick {
|
||||
@@ -161,7 +161,7 @@
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rulerLabel {
|
||||
@@ -169,7 +169,7 @@
|
||||
top: 5px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
transform: translateX(-50%);
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.7;
|
||||
@@ -198,7 +198,7 @@
|
||||
gap: 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
|
||||
@@ -145,15 +145,15 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||
Expected standard
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.card {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
@@ -8,13 +8,13 @@
|
||||
}
|
||||
|
||||
.card.cardInCompare {
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 1px var(--brand);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
border-left-color: var(--brand);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.title a:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.distance {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
background: var(--accent-teal-bg);
|
||||
background: var(--brand-bg);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
@@ -63,9 +63,9 @@
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -88,12 +88,13 @@
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -101,7 +102,7 @@
|
||||
|
||||
.metricValue strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -126,18 +127,18 @@
|
||||
}
|
||||
|
||||
.trendUp {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--status-above);
|
||||
background: var(--status-above-bg);
|
||||
}
|
||||
|
||||
.trendDown {
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--status-below);
|
||||
background: var(--status-below-bg);
|
||||
}
|
||||
|
||||
.trendStable {
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: rgba(138, 132, 122, 0.15);
|
||||
color: var(--text-muted);
|
||||
background: rgba(var(--muted-rgb), 0.15);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
.metricHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-weight: 400;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
background: #dfe6e2;
|
||||
background: var(--bg-secondary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -85,11 +85,11 @@
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .16);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
@@ -118,7 +118,7 @@
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
var(--bg-card, #fff) 100%);
|
||||
var(--bg-card) 100%);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@@ -134,9 +134,9 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fullscreenBtn:hover {
|
||||
background: #f4f4f4;
|
||||
background: var(--bg-secondary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(224, 114, 86, 0.3);
|
||||
border: 3px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
|
||||
.phasePrimary { border-left-color: var(--phase-primary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Phase label pill */
|
||||
@@ -96,8 +96,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: stats */
|
||||
@@ -115,10 +115,11 @@
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
@@ -126,7 +127,7 @@
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -137,9 +138,9 @@
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.trendUp { color: var(--accent-teal, #2d7d7d); }
|
||||
.trendDown { color: var(--accent-coral, #e07256); }
|
||||
.trendStable { color: var(--text-muted, #8a847a); }
|
||||
.trendUp { color: var(--status-above); }
|
||||
.trendDown { color: var(--status-below); }
|
||||
.trendStable { color: var(--text-muted); }
|
||||
|
||||
/* Line 4: location */
|
||||
.line4 {
|
||||
@@ -148,13 +149,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -162,8 +163,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -200,23 +201,23 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
/* ofsted1–4 already defined above; these cover the two new framework states */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
|
||||
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
|
||||
.vsNational { font-size: 0.7rem; color: var(--status-above); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--brand); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
@@ -257,7 +258,7 @@
|
||||
|
||||
/* GIAS "Open, but proposed to close" marker */
|
||||
.attrClosing {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--accent-gold-bg);
|
||||
border: 1px solid var(--accent-gold, #c9a227);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid var(--status-below);
|
||||
color: var(--text-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -21,21 +21,21 @@
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
@@ -45,9 +45,9 @@
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid rgba(224, 114, 86, 0.3);
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@@ -71,17 +71,17 @@
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
@@ -90,16 +90,16 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.resultItem:hover {
|
||||
background: var(--bg-card, white);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
background: var(--bg-card);
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.schoolInfo {
|
||||
@@ -110,7 +110,7 @@
|
||||
.schoolName {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -125,7 +125,7 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolMeta span {
|
||||
@@ -138,14 +138,14 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,930 +0,0 @@
|
||||
/**
|
||||
* SecondarySchoolDetailView Component
|
||||
* Dedicated detail view for secondary schools with scroll-to-section navigation.
|
||||
* All sections render at once; the sticky nav scrolls to each.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||
};
|
||||
|
||||
const RC_LABELS: Record<number, string> = {
|
||||
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
||||
};
|
||||
|
||||
const RC_CATEGORIES = [
|
||||
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
||||
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
||||
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
||||
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
||||
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
||||
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
||||
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||
];
|
||||
|
||||
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
||||
if (val == null) return '';
|
||||
if (val > 0) return modStyles.progressPositive;
|
||||
if (val < 0) return modStyles.progressNegative;
|
||||
return '';
|
||||
}
|
||||
|
||||
function deprivationDesc(decile: number): string {
|
||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||
}
|
||||
|
||||
interface SecondarySchoolDetailViewProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
senDetail: SenDetail | null;
|
||||
phonics: Phonics | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
}
|
||||
|
||||
export function SecondarySchoolDetailView({
|
||||
schoolInfo, yearlyData,
|
||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/national-averages')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setNationalAvg(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||||
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
|
||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||
const hasResults = latestResults?.attainment_8_score != null;
|
||||
|
||||
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||
// so their headline attainment is far below the mainstream average by design.
|
||||
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||
// mainstream school still shows its real value and comparison.
|
||||
const isSpecial = isSpecialSchool(schoolInfo);
|
||||
const suppressComparison = isSpecial;
|
||||
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
const denom = schoolInfo.religious_denomination ?? '';
|
||||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||
return null;
|
||||
})();
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// Back returns wherever the user came from; deep-links fall back to search
|
||||
// so the button never dead-ends or leaves the site.
|
||||
const handleBack = () => {
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
track('school_viewed', {
|
||||
urn: schoolInfo.urn,
|
||||
phase: schoolInfo.phase || 'secondary',
|
||||
local_authority: schoolInfo.local_authority || 'unknown',
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schoolInfo.urn]);
|
||||
|
||||
// Build nav items dynamically based on available data.
|
||||
// Engagement-led order (matches the primary page): recognised Ofsted badge,
|
||||
// then the most-sought sections — results, admissions, history — with the
|
||||
// experience and context sections following.
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
|
||||
// Track active section as user scrolls
|
||||
useEffect(() => {
|
||||
const ids = navItems.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
const observers: IntersectionObserver[] = [];
|
||||
const ratioMap: Record<string, number> = {};
|
||||
const pickActive = () => {
|
||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||
};
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
ratioMap[id] = 0;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
|
||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
observers.push(obs);
|
||||
});
|
||||
return () => observers.forEach(o => o.disconnect());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navItems.map(n => n.id).join(',')]);
|
||||
|
||||
// A report card is identified by the presence of report-card area
|
||||
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||
const isReportCard = !!(
|
||||
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||
);
|
||||
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||
const ofstedInspectedDate = isReportCard
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
|
||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Standalone back link, above the header — returns wherever the user
|
||||
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
|
||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* ── Header — the location map band blends into the school title ── */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.badges}>
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
{schoolInfo.age_range && (
|
||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||
)}
|
||||
{hasSixthForm && (
|
||||
<span className={styles.badge}>Sixth form</span>
|
||||
)}
|
||||
{admissionsTag && (
|
||||
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
||||
{admissionsTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isProposedToClose(schoolInfo) && (
|
||||
<div className={styles.closingStrip} role="note">
|
||||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||
check with the local authority before applying.
|
||||
</div>
|
||||
)}
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mapLink}
|
||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||
>
|
||||
View on map ↗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.headerDetails}>
|
||||
{schoolInfo.headteacher_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a
|
||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="school_website"
|
||||
>
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
|
||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.trust_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||
>
|
||||
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||
<span className={styles.btnCompareLabel}>
|
||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||
</span>
|
||||
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||
{isInComparison ? '✓' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Sticky section navigation ─────────────────────── */}
|
||||
<nav className={styles.tabNav} aria-label="Page sections">
|
||||
<div className={styles.tabNavInner}>
|
||||
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top">↑ Top</button>
|
||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||
onClick={() => track('section_nav_used', { section: id })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Ofsted ─────────────────────────────────────── */}
|
||||
{ofsted && (
|
||||
<section id="ofsted" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofstedInspectedDate && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${schoolInfo.urn}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ofstedReportLink}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Ofsted reports ↗
|
||||
</a>
|
||||
</h2>
|
||||
{isReportCard ? (
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
|
||||
const value = ofsted[key] as number | null;
|
||||
return value != null ? (
|
||||
<div key={key} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
||||
{RC_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : ofsted.overall_effectiveness ? (
|
||||
<>
|
||||
<div className={styles.ofstedHeader}>
|
||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||
{OFSTED_LABELS[ofsted.overall_effectiveness]}
|
||||
</span>
|
||||
{ofsted.previous_overall != null &&
|
||||
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
||||
<span className={styles.ofstedPrevious}>
|
||||
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
||||
</p>
|
||||
{oeifAllSameGrade ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
</p>
|
||||
) : (
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
From September 2024, Ofsted no longer gives a single overall grade.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{[
|
||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||
{ label: 'Personal Development', value: ofsted.personal_development },
|
||||
{ label: 'Leadership & Management', value: ofsted.leadership_management },
|
||||
].filter(({ value }) => value != null).map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value!]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── GCSE Results ───────────────────────────────── */}
|
||||
{hasResults && latestResults && (
|
||||
<section id="gcse" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{p8Suspended && (
|
||||
<div className={styles.p8Banner}>
|
||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||
(COVID), so DfE has no starting point to measure their progress from.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero stat cards — top GCSE metrics */}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Attainment 8 score
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{latestResults.attainment_8_score.toFixed(1)}
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.attainment_8_score}
|
||||
baseline={secondaryAvg.attainment_8_score}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Progress 8 score
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
||||
<div className={styles.heroStatHint}>
|
||||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_strong_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_standard_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||
"school vs national", so it's dropped for special schools where
|
||||
that comparison isn't meaningful. */}
|
||||
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||
<div className={styles.att8Viz}>
|
||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||
<div className={styles.att8VizTrack}>
|
||||
<div
|
||||
className={styles.att8VizFill}
|
||||
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
||||
/>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div
|
||||
className={styles.att8VizNatLine}
|
||||
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
||||
>
|
||||
<div className={styles.att8VizNatPill}>
|
||||
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.att8VizTicks}>
|
||||
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 number line with CI */}
|
||||
{latestResults.progress_8_score != null && !p8Suspended && (
|
||||
<div className={styles.p8Viz}>
|
||||
<div className={styles.p8VizLabel}>Progress 8 — relative to national baseline (0)</div>
|
||||
{(() => {
|
||||
const p8 = latestResults.progress_8_score!;
|
||||
const lo = latestResults.progress_8_lower_ci ?? p8;
|
||||
const hi = latestResults.progress_8_upper_ci ?? p8;
|
||||
const range = 6; // −3 to +3
|
||||
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
||||
return (
|
||||
<div className={styles.p8VizTrack}>
|
||||
{/* CI band */}
|
||||
<div
|
||||
className={styles.p8VizCi}
|
||||
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
||||
/>
|
||||
{/* Zero line */}
|
||||
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
||||
{/* Score dot */}
|
||||
<div
|
||||
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
||||
style={{ left: toX(p8) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className={styles.p8VizTicks}>
|
||||
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 component breakdown */}
|
||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{[
|
||||
{ label: 'English', val: latestResults.progress_8_english },
|
||||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||||
].filter(r => r.val != null).map(({ label, val }) => (
|
||||
<div key={label} className={styles.metricRow}>
|
||||
<span className={styles.metricName}>{label}</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
|
||||
{formatProgress(val!)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_avg_score != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc average point score</span>
|
||||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Admissions ─────────────────────────────────── */}
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Admissions</h2>
|
||||
|
||||
{admissionsTag && (
|
||||
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
||||
<strong>{admissionsTag}</strong>{' '}
|
||||
{admissionsTag === 'Selective'
|
||||
? '— Entry to this school is by selective examination (e.g. 11+).'
|
||||
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total applications</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>1st preference applications</div>
|
||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Applications exceeded places last year'
|
||||
: '✓ Places were available last year'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||
</p>
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── History table ──────────────────────────────── */}
|
||||
{yearlyData.length > 1 && (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
||||
{yearlyData.length > 0 && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>Eng & Maths 4+</th>
|
||||
<th>EBacc entry %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
||||
{hasWellbeing && (
|
||||
<section id="wellbeing" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Wellbeing & Context</h2>
|
||||
|
||||
{/* SEN */}
|
||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Without an EHCP</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_ehcp_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils with EHCP
|
||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
const female = census?.female_pupils ?? null;
|
||||
const male = census?.male_pupils ?? null;
|
||||
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||
const hasSplit = isMixed && female != null && male != null && female + male > 0;
|
||||
const sum = hasSplit ? female! + male! : 0;
|
||||
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
|
||||
const boysPct = hasSplit ? 100 - girlsPct : 0;
|
||||
return (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Total pupils</div>
|
||||
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
|
||||
{hasSplit && (
|
||||
<>
|
||||
<div
|
||||
className={styles.genderBar}
|
||||
role="img"
|
||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||
>
|
||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||
</div>
|
||||
<div className={styles.genderSplitHint}>
|
||||
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
|
||||
<span className={styles.genderSplitSep}> · </span>
|
||||
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{schoolInfo.capacity != null && !hasSplit && (
|
||||
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deprivation */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h3>
|
||||
<div className={styles.deprivationDots}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
||||
title={`Decile ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.deprivationScaleLabel}>
|
||||
<span>Most deprived</span>
|
||||
<span>Least deprived</span>
|
||||
</div>
|
||||
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Finances ───────────────────────────────────── */}
|
||||
{hasFinance && finance && (
|
||||
<section id="finances" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Per-pupil spending shows how much the school has to spend on each child's education.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total spend per pupil per year</div>
|
||||
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
|
||||
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
||||
</div>
|
||||
{finance.teacher_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
|
||||
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{finance.staff_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
|
||||
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{finance.premises_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
||||
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary, #9b6bb0); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16, #c4915e); }
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Line 2: context tags */
|
||||
@@ -81,8 +81,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: KS4 stats */
|
||||
@@ -100,22 +100,24 @@
|
||||
}
|
||||
|
||||
.statValueLarge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -125,8 +127,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deltaPositive { color: #2f7a2f; }
|
||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||
.deltaPositive { color: var(--status-above); }
|
||||
.deltaNegative { color: var(--status-below); }
|
||||
|
||||
/* Line 4: location + distance */
|
||||
.line4 {
|
||||
@@ -135,13 +137,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -149,8 +151,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -175,15 +177,15 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectiveTag {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
color: #8a6200;
|
||||
background: rgba(var(--status-below-rgb), 0.1);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
/* ── Ofsted badge ────────────────────────────────────── */
|
||||
@@ -202,17 +204,17 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── Right actions column ────────────────────────────── */
|
||||
.rowActions {
|
||||
@@ -268,7 +270,7 @@
|
||||
}
|
||||
|
||||
.closingTag {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
@@ -1,15 +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);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--brand);
|
||||
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);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.note strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.moreMeasures {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.stripNote {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function CompareAtAGlance({
|
||||
)}
|
||||
{display.kind === 'transitional' && (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>Sub-judgements only</span>
|
||||
|
||||
@@ -54,7 +54,7 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
|
||||
if (display.kind === 'transitional') {
|
||||
return (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
.explore summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.inner {
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--bg-card);
|
||||
@@ -43,7 +43,7 @@
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--accent-coral-dark);
|
||||
border-left: 3px solid var(--brand-strong);
|
||||
}
|
||||
|
||||
.how {
|
||||
@@ -36,7 +36,7 @@
|
||||
back into the shared grid. */
|
||||
.measure {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.75rem 0.85rem;
|
||||
@@ -59,7 +59,7 @@
|
||||
gap: 0.35rem 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -113,18 +113,18 @@
|
||||
}
|
||||
|
||||
.chipGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
background: rgba(var(--status-above-rgb), 0.14);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipNeutral {
|
||||
@@ -132,20 +132,6 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help {
|
||||
display: inline-flex;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
@@ -155,18 +141,18 @@
|
||||
}
|
||||
|
||||
.badgeGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
background: rgba(var(--status-above-rgb), 0.14);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.badgeBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.rcList {
|
||||
@@ -214,7 +200,7 @@
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.25rem 1.5rem;
|
||||
@@ -222,7 +208,7 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
@@ -244,14 +230,14 @@
|
||||
.rowLabel {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.85rem 0.5rem 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: block;
|
||||
padding: 0.85rem 0.25rem;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
import styles from './compareSections.module.css';
|
||||
|
||||
export function Section({
|
||||
@@ -52,11 +53,7 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
|
||||
return (
|
||||
<div className={styles.rowLabel}>
|
||||
{children}
|
||||
{tip && (
|
||||
<span className={styles.help} title={tip} aria-label={tip}>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
{tip && <InfoPopover plain={tip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AdmissionsSection — primary detail pages.
|
||||
*
|
||||
* Not shared with the secondary page: the two versions were only 14% similar
|
||||
* (this one carries the year/trend toggle and the offer-rate chart; the
|
||||
* secondary one is a much simpler panel). See SecondaryAdmissionsSection.
|
||||
*
|
||||
* Server component. The year/trend toggle is delegated to the small
|
||||
* AdmissionsViewToggle client island, which receives both views as
|
||||
* server-rendered children. When there is only one year of offer data no
|
||||
* toggle renders at all, so such pages ship zero admissions JavaScript.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||
import { AdmissionsTrendChart } from './charts';
|
||||
|
||||
|
||||
export function AdmissionsSection({
|
||||
admissions,
|
||||
admissionsHistory,
|
||||
isAllThrough,
|
||||
}: {
|
||||
admissions: SchoolAdmissions;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
isAllThrough: boolean;
|
||||
}) {
|
||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||
// first choices missed out" etc.) — the same banding the compare screen
|
||||
// uses, so a low offer rate reads as how severe it actually is.
|
||||
const admissionsSummary = summariseAdmissions(admissions);
|
||||
|
||||
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
|
||||
|
||||
{/* All-through admissions data covers a single entry point (usually the
|
||||
Year 7 secondary intake), not reception — say so, or a parent could
|
||||
read these as the whole-school figures. */}
|
||||
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
|
||||
<p className={styles.sectionSubtitle}>
|
||||
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const yearView = (
|
||||
<>
|
||||
<dl className={styles.admissionsTiles}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||||
<dd className={styles.admissionsTileNum}>
|
||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||
<>
|
||||
{admissions.first_preference_offers}
|
||||
<span className={styles.admissionsTileSub}>
|
||||
of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatPercentage(admissions.first_preference_offer_pct)
|
||||
)}
|
||||
</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{admissionsSummary.chip && (
|
||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const trendView = (
|
||||
<>
|
||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||
<AdmissionsTrendChart history={admissionsHistory} />
|
||||
<p className={styles.admissionsTrendSummary}>
|
||||
This year ({formatAcademicYear(admissions.year)}),{' '}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
|
||||
)}
|
||||
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
|
||||
{admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section id="admissions">
|
||||
{showAdmissionsTrend ? (
|
||||
<AdmissionsViewToggle
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
trendLabel={`${admissionsHistory.length}-year trend`}
|
||||
yearView={yearView}
|
||||
trendView={trendView}
|
||||
/>
|
||||
) : (
|
||||
/* No trend data — render statically, with no client component at all. */
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear}>{yearView}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
/**
|
||||
* The only interactive part of the primary admissions section, and the only
|
||||
* client component in components/school/.
|
||||
*
|
||||
* Both views are always present in the DOM and visibility is toggled with the
|
||||
* `hidden` attribute — matching the previous behaviour exactly — so the
|
||||
* server-rendered markup passed in as yearView/trendView never ships as client
|
||||
* JavaScript.
|
||||
*
|
||||
* It spans the header and the viewport because the segmented control sits
|
||||
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
||||
* below it; wrapping only one would change the DOM the CSS depends on.
|
||||
*/
|
||||
export function AdmissionsViewToggle({
|
||||
title,
|
||||
subtitle,
|
||||
trendLabel,
|
||||
yearView,
|
||||
trendView,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
|
||||
{yearView}
|
||||
</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
|
||||
{trendView}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* FinancesSection — shared between the primary and secondary detail pages
|
||||
* (the two versions were 91% identical).
|
||||
*
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { SchoolFinance } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
export function FinancesSection({
|
||||
finance,
|
||||
showPremises = false,
|
||||
}: {
|
||||
finance: SchoolFinance;
|
||||
/**
|
||||
* The secondary page shows a premises-cost card the primary page never had.
|
||||
* Gated rather than enabled everywhere so this refactor makes no visible
|
||||
* change; enabling it for primary is a one-line follow-up.
|
||||
*/
|
||||
showPremises?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Section id="finances">
|
||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Per-pupil spending shows how much the school has to spend on each child's education.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total spend per pupil per year</div>
|
||||
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
|
||||
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
||||
</div>
|
||||
{finance.teacher_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
|
||||
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{finance.staff_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
|
||||
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{showPremises && finance.premises_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
||||
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* GcseSection — KS4 headline results. Secondary pages. Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolResult } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { DeltaChip } from '../DeltaChip';
|
||||
import { SpecialSchoolNote } from '../SpecialSchoolNote';
|
||||
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
|
||||
|
||||
export function GcseSection({
|
||||
latestResults, schoolInfo, secondaryAvg, p8Suspended, suppressComparison,
|
||||
}: {
|
||||
latestResults: SchoolResult;
|
||||
schoolInfo: School;
|
||||
secondaryAvg: Record<string, number>;
|
||||
p8Suspended: boolean;
|
||||
suppressComparison: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section id="gcse" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{p8Suspended && (
|
||||
<div className={styles.p8Banner}>
|
||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||
(COVID), so DfE has no starting point to measure their progress from.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero stat cards — top GCSE metrics */}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Attainment 8 score
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{latestResults.attainment_8_score.toFixed(1)}
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.attainment_8_score}
|
||||
baseline={secondaryAvg.attainment_8_score}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Progress 8 score
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
||||
<div className={styles.heroStatHint}>
|
||||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_strong_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_standard_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||
"school vs national", so it's dropped for special schools where
|
||||
that comparison isn't meaningful. */}
|
||||
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||
<div className={styles.att8Viz}>
|
||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||
<div className={styles.att8VizTrack}>
|
||||
<div
|
||||
className={styles.att8VizFill}
|
||||
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
||||
/>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div
|
||||
className={styles.att8VizNatLine}
|
||||
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
||||
>
|
||||
<div className={styles.att8VizNatPill}>
|
||||
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.att8VizTicks}>
|
||||
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 number line with CI */}
|
||||
{latestResults.progress_8_score != null && !p8Suspended && (
|
||||
<div className={styles.p8Viz}>
|
||||
<div className={styles.p8VizLabel}>Progress 8 — relative to national baseline (0)</div>
|
||||
{(() => {
|
||||
const p8 = latestResults.progress_8_score!;
|
||||
const lo = latestResults.progress_8_lower_ci ?? p8;
|
||||
const hi = latestResults.progress_8_upper_ci ?? p8;
|
||||
const range = 6; // −3 to +3
|
||||
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
||||
return (
|
||||
<div className={styles.p8VizTrack}>
|
||||
{/* CI band */}
|
||||
<div
|
||||
className={styles.p8VizCi}
|
||||
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
||||
/>
|
||||
{/* Zero line */}
|
||||
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
||||
{/* Score dot */}
|
||||
<div
|
||||
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
||||
style={{ left: toX(p8) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className={styles.p8VizTicks}>
|
||||
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 component breakdown */}
|
||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{[
|
||||
{ label: 'English', val: latestResults.progress_8_english },
|
||||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||||
].filter(r => r.val != null).map(({ label, val }) => (
|
||||
<div key={label} className={styles.metricRow}>
|
||||
<span className={styles.metricName}>{label}</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(val)}`}>
|
||||
{formatProgress(val!)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_avg_score != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc average point score</span>
|
||||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* HistorySection — results over time (chart plus historical table).
|
||||
* Primary pages; the secondary equivalent is SecondaryHistorySection (the two
|
||||
* were only 40% similar). Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { PerformanceChart, SatsChart } from './charts';
|
||||
|
||||
export function HistorySection({
|
||||
yearlyData, schoolInfo, nationalAvg, primaryAvg, secondaryAvg,
|
||||
isAllThrough, isPrimary, isSecondary, hasKS2Results, hasKS4Results,
|
||||
suppressKs2Comparison, suppressKs4Comparison,
|
||||
}: {
|
||||
yearlyData: SchoolResult[];
|
||||
schoolInfo: School;
|
||||
nationalAvg: NationalAverages | null;
|
||||
primaryAvg: Record<string, number>;
|
||||
secondaryAvg: Record<string, number>;
|
||||
isAllThrough: boolean;
|
||||
isPrimary: boolean;
|
||||
isSecondary: boolean;
|
||||
hasKS2Results: boolean;
|
||||
hasKS4Results: boolean;
|
||||
suppressKs2Comparison: boolean;
|
||||
suppressKs4Comparison: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||
{isAllThrough ? (
|
||||
// All-through: KS2 and KS4 trends are on different scales and have
|
||||
// different gap stories, so render them as two stacked charts
|
||||
// rather than crowding 8+ series onto one axis.
|
||||
<>
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={false}
|
||||
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||||
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasKS4Results && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||||
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={isSecondary}
|
||||
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{yearlyData.length > 1 && (
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{isAllThrough ? (
|
||||
<>
|
||||
<th>RWM (expected %)</th>
|
||||
<th>Exceeding (%)</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>English & Maths Grade 4+</th>
|
||||
</>
|
||||
) : isSecondary ? (
|
||||
<>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>English & Maths Grade 4+</th>
|
||||
<th>English & Maths Grade 5+</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>Reading, Writing & Maths (expected %)</th>
|
||||
<th>Exceeding expected (%)</th>
|
||||
<th>Reading Progress</th>
|
||||
<th>Writing Progress</th>
|
||||
<th>Maths Progress</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
{isAllThrough ? (
|
||||
<>
|
||||
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
</>
|
||||
) : isSecondary ? (
|
||||
<>
|
||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
|
||||
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
|
||||
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* InclusionSection — pupil characteristics and gender split. Primary pages.
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { SchoolCensus, SchoolResult } from '@/lib/types';
|
||||
import { formatPercentage } from '@/lib/utils';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { DeltaChip } from '../DeltaChip';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
export function InclusionSection({
|
||||
latestResults, census, hasGenderSplit, primaryAvg,
|
||||
}: {
|
||||
latestResults: SchoolResult | null;
|
||||
census: SchoolCensus | null;
|
||||
hasGenderSplit: boolean;
|
||||
primaryAvg: Record<string, number>;
|
||||
}) {
|
||||
return (
|
||||
<section id="inclusion" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.disadvantaged_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.disadvantaged_pct)}
|
||||
{primaryAvg.disadvantaged_pct != null && (
|
||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.eal_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English as an additional language
|
||||
<MetricTooltip metricKey="eal_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.eal_pct)}
|
||||
{primaryAvg.eal_pct != null && (
|
||||
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.eal_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils receiving SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.sen_support_pct)}
|
||||
{primaryAvg.sen_support_pct != null && (
|
||||
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.sen_support_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasGenderSplit && (() => {
|
||||
const female = census!.female_pupils!;
|
||||
const male = census!.male_pupils!;
|
||||
const girlsPct = Math.round((female / (female + male)) * 100);
|
||||
const boysPct = 100 - girlsPct;
|
||||
return (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Boys and girls</div>
|
||||
<div className={styles.genderSplitValue}>
|
||||
<span className={styles.genderSplitGirls}>{girlsPct}%</span>
|
||||
<span className={styles.genderSplitLabel}>girls</span>
|
||||
<span className={styles.genderSplitSep}>·</span>
|
||||
<span className={styles.genderSplitBoys}>{boysPct}%</span>
|
||||
<span className={styles.genderSplitLabel}>boys</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.genderBar}
|
||||
role="img"
|
||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||
>
|
||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||
</div>
|
||||
<div className={styles.heroStatHint}>
|
||||
{female.toLocaleString()} girls, {male.toLocaleString()} boys
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* LocalAreaSection — IDACI deprivation decile. Primary pages only.
|
||||
*
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { SchoolDeprivation } from '@/lib/types';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
// Moved with this section from SchoolDetailView, its only consumer.
|
||||
function deprivationDesc(decile: number) {
|
||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||
}
|
||||
|
||||
export function LocalAreaSection({ deprivation }: { deprivation: SchoolDeprivation }) {
|
||||
return (
|
||||
<Section id="local-area">
|
||||
<h2 className={styles.sectionTitle}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h2>
|
||||
<div className={styles.deprivationDots}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
||||
title={`Decile ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.deprivationScaleLabel}>
|
||||
<span>Most deprived</span>
|
||||
<span>Least deprived</span>
|
||||
</div>
|
||||
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* OfstedSection — shared between the primary and secondary detail pages.
|
||||
*
|
||||
* The two versions were ~80% identical, but that figure masked a real fork in
|
||||
* the no-overall-grade case: the primary page shows a "Not rated" badge, while
|
||||
* the secondary page shows a four-area OEIF panel. The disclaimer copy also
|
||||
* differs slightly. Both are preserved exactly via the `variant` prop rather
|
||||
* than reconciled, because this refactor must not change either page. Merging
|
||||
* them is a follow-up decision for a human, not a side effect of a move.
|
||||
*
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { OfstedInspection } from '@/lib/types';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||
};
|
||||
|
||||
const RC_LABELS: Record<number, string> = {
|
||||
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
||||
};
|
||||
|
||||
const RC_CATEGORIES = [
|
||||
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
||||
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
||||
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
||||
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
||||
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
||||
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
||||
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||
];
|
||||
|
||||
export interface OfstedSectionProps {
|
||||
ofsted: OfstedInspection;
|
||||
urn: number;
|
||||
isReportCard: boolean;
|
||||
ofstedInspectedDate: string | null;
|
||||
oeifAllSameGrade: boolean;
|
||||
oeifAreas: { label: string; value: number }[];
|
||||
variant?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export function OfstedSection({
|
||||
ofsted, urn, isReportCard, ofstedInspectedDate,
|
||||
oeifAllSameGrade, oeifAreas, variant = 'primary',
|
||||
}: OfstedSectionProps) {
|
||||
const isSecondary = variant === 'secondary';
|
||||
|
||||
return (
|
||||
<Section id="ofsted">
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofstedInspectedDate && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${urn}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ofstedReportLink}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Ofsted reports ↗
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{isReportCard ? (
|
||||
/* ── New Report Card layout ── */
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{RC_CATEGORIES.map(({ key, label }) => {
|
||||
const value = ofsted[key] as number | null;
|
||||
return value != null ? (
|
||||
<div key={key} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
||||
{RC_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (!isSecondary || ofsted.overall_effectiveness) ? (
|
||||
/* ── Old OEIF layout ── */
|
||||
<>
|
||||
<div className={styles.ofstedHeader}>
|
||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
|
||||
</span>
|
||||
{ofsted.previous_overall != null &&
|
||||
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
||||
<span className={styles.ofstedPrevious}>
|
||||
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||
: isSecondary
|
||||
? 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'
|
||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
|
||||
</p>
|
||||
{oeifAllSameGrade ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
</p>
|
||||
) : (
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ── Secondary only: inspected since Sept 2024, no overall grade ── */
|
||||
<>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
From September 2024, Ofsted no longer gives a single overall grade.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{[
|
||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||
{ label: 'Personal Development', value: ofsted.personal_development },
|
||||
{ label: 'Leadership & Management', value: ofsted.leadership_management },
|
||||
].filter(({ value }) => value != null).map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value!]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* PrimarySchoolSections — the section sequence for primary and all-through
|
||||
* detail pages. Server component.
|
||||
*
|
||||
* All-through schools route here rather than to SecondarySchoolSections,
|
||||
* because this list renders BOTH the KS2 and KS4 blocks (ResultsSection and
|
||||
* HistorySection branch on isAllThrough); the secondary list is KS4-only and
|
||||
* would silently drop their SATs data.
|
||||
*
|
||||
* The render conditions here MUST match buildNavItems in lib/schoolSections,
|
||||
* or the sticky nav will link to sections that do not exist.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||
import { OfstedSection } from './OfstedSection';
|
||||
import { ResultsSection } from './ResultsSection';
|
||||
import { AdmissionsSection } from './AdmissionsSection';
|
||||
import { InclusionSection } from './InclusionSection';
|
||||
import { HistorySection } from './HistorySection';
|
||||
import { SchoolLifeSection } from './SchoolLifeSection';
|
||||
import { LocalAreaSection } from './LocalAreaSection';
|
||||
import { FinancesSection } from './FinancesSection';
|
||||
|
||||
export interface PrimarySchoolSectionsProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
flags: SchoolFlags;
|
||||
}
|
||||
|
||||
export function PrimarySchoolSections({
|
||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
||||
}: PrimarySchoolSectionsProps) {
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
||||
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||
const ofstedInspectedDate = isReportCard
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ofsted && (
|
||||
<OfstedSection
|
||||
ofsted={ofsted}
|
||||
urn={schoolInfo.urn}
|
||||
isReportCard={isReportCard}
|
||||
ofstedInspectedDate={ofstedInspectedDate}
|
||||
oeifAllSameGrade={oeifAllSameGrade}
|
||||
oeifAreas={oeifAreas}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasAnyResults && flags.latestResults && (
|
||||
<ResultsSection
|
||||
latestResults={flags.latestResults}
|
||||
schoolInfo={schoolInfo}
|
||||
primaryAvg={primaryAvg}
|
||||
secondaryAvg={secondaryAvg}
|
||||
isAllThrough={flags.isAllThrough}
|
||||
isSecondary={flags.isSecondary}
|
||||
isSpecial={flags.isSpecial}
|
||||
hasKS2Results={flags.hasKS2Results}
|
||||
hasKS4Results={flags.hasKS4Results}
|
||||
ks2Placeholder={flags.ks2Placeholder}
|
||||
suppressKs2Comparison={flags.suppressKs2Comparison}
|
||||
suppressKs4Comparison={flags.suppressKs4Comparison}
|
||||
/>
|
||||
)}
|
||||
|
||||
{admissions && (
|
||||
<AdmissionsSection
|
||||
admissions={admissions}
|
||||
admissionsHistory={admissionsHistory}
|
||||
isAllThrough={flags.isAllThrough}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasInclusionData && (
|
||||
<InclusionSection
|
||||
latestResults={flags.latestResults}
|
||||
census={census}
|
||||
hasGenderSplit={flags.hasGenderSplit}
|
||||
primaryAvg={primaryAvg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yearlyData.length > 0 && (
|
||||
<HistorySection
|
||||
yearlyData={yearlyData}
|
||||
schoolInfo={schoolInfo}
|
||||
nationalAvg={nationalAvg}
|
||||
primaryAvg={primaryAvg}
|
||||
secondaryAvg={secondaryAvg}
|
||||
isAllThrough={flags.isAllThrough}
|
||||
isPrimary={flags.isPrimary}
|
||||
isSecondary={flags.isSecondary}
|
||||
hasKS2Results={flags.hasKS2Results}
|
||||
hasKS4Results={flags.hasKS4Results}
|
||||
suppressKs2Comparison={flags.suppressKs2Comparison}
|
||||
suppressKs4Comparison={flags.suppressKs4Comparison}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasSchoolLife && (
|
||||
<SchoolLifeSection absenceData={absenceData} primaryAvg={primaryAvg} />
|
||||
)}
|
||||
|
||||
{flags.hasDeprivation && deprivation && (
|
||||
<LocalAreaSection deprivation={deprivation} />
|
||||
)}
|
||||
|
||||
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* ResultsSection — KS2 SATs (and, for all-through schools, the KS4 block).
|
||||
* Primary and all-through pages. Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolResult } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { DeltaChip } from '../DeltaChip';
|
||||
import { SpecialSchoolNote } from '../SpecialSchoolNote';
|
||||
import { Section, sectionStyles as styles, progressClass } from './sectionShared';
|
||||
import { SatsChart } from './charts';
|
||||
|
||||
|
||||
export function ResultsSection({
|
||||
latestResults, schoolInfo, primaryAvg, secondaryAvg,
|
||||
isAllThrough, isSecondary, isSpecial, hasKS2Results, hasKS4Results,
|
||||
ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
|
||||
}: {
|
||||
latestResults: SchoolResult;
|
||||
schoolInfo: School;
|
||||
primaryAvg: Record<string, number>;
|
||||
secondaryAvg: Record<string, number>;
|
||||
isAllThrough: boolean;
|
||||
isSecondary: boolean;
|
||||
isSpecial: boolean;
|
||||
hasKS2Results: boolean;
|
||||
hasKS4Results: boolean;
|
||||
ks2Placeholder: boolean;
|
||||
suppressKs2Comparison: boolean;
|
||||
suppressKs4Comparison: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section id="results" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
{isSpecial
|
||||
? (isSecondary
|
||||
? 'GCSE results for Year 11 pupils.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||||
: isAllThrough
|
||||
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||
: isSecondary
|
||||
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
{/* Explains up front why the England comparison is dropped below, so
|
||||
a 0% headline never reads as a failing grade against a benchmark
|
||||
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{/* ── Primary / KS2 content ── */}
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
{isAllThrough && (
|
||||
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||||
)}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.rwm_expected_pct !== null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Reading, Writing & Maths combined
|
||||
<MetricTooltip metricKey="rwm_expected_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.rwm_expected_pct}
|
||||
baseline={primaryAvg.rwm_expected_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.rwm_high_pct !== null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Exceeding expected level (Reading, Writing & Maths)
|
||||
<MetricTooltip metricKey="rwm_high_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_high_pct)}
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.rwm_high_pct}
|
||||
baseline={primaryAvg.rwm_high_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!suppressKs2Comparison &&
|
||||
latestResults.rwm_expected_pct != null &&
|
||||
latestResults.reading_expected_pct != null &&
|
||||
latestResults.writing_expected_pct != null &&
|
||||
latestResults.maths_expected_pct != null && (
|
||||
<div className={styles.rwmBridge}>
|
||||
<span className={styles.rwmBridgeIcon} aria-hidden="true">?</span>
|
||||
<div className={styles.rwmBridgeBody}>
|
||||
<div className={styles.rwmBridgeText}>
|
||||
Why is combined lower? A pupil is only counted if they met the bar in{' '}
|
||||
<strong>all three</strong> subjects. Some passed reading but not writing; some passed writing but not maths.
|
||||
</div>
|
||||
<div className={styles.rwmBridgeMath}>
|
||||
<span>Reading <strong>{latestResults.reading_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||
<span>Writing <strong>{latestResults.writing_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||
<span>Maths <strong>{latestResults.maths_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>→</span>
|
||||
<span>All three <strong>{latestResults.rwm_expected_pct.toFixed(0)}%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All-zero placeholder rows (special / suppressed) would render as
|
||||
three empty bars against the national markers — misleading, so
|
||||
skip the chart. For a special school with some non-zero
|
||||
subjects, keep the bars but drop the national markers. */}
|
||||
{!ks2Placeholder && (
|
||||
<SatsChart
|
||||
subjects={[
|
||||
{
|
||||
name: 'Reading',
|
||||
expectedPct: latestResults.reading_expected_pct,
|
||||
exceedingPct: latestResults.reading_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||||
},
|
||||
{
|
||||
name: 'Writing',
|
||||
expectedPct: latestResults.writing_expected_pct,
|
||||
exceedingPct: latestResults.writing_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||||
// Writing's higher level is teacher-assessed "greater depth".
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
|
||||
},
|
||||
{
|
||||
name: 'Maths',
|
||||
expectedPct: latestResults.maths_expected_pct,
|
||||
exceedingPct: latestResults.maths_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress scores row */}
|
||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||
<div className={styles.progressScoresRow}>
|
||||
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
|
||||
<div className={styles.progressScoresGrid}>
|
||||
{latestResults.reading_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Reading</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||
{formatProgress(latestResults.reading_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.writing_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Writing</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||
{formatProgress(latestResults.writing_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.maths_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Maths</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||
{formatProgress(latestResults.maths_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Secondary / KS4 content ── */}
|
||||
{hasKS4Results && (
|
||||
<>
|
||||
{isAllThrough && (
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||||
)}
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults.attainment_8_score !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Attainment 8
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Progress 8
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
<div className={styles.metricHint}>0 = national average</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 4+
|
||||
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 5+
|
||||
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
/* Styles for SchoolDetailShell — the interactive chrome of a detail page.
|
||||
Derived from the classes the shell's JSX references; the section styles
|
||||
live in components/school/schoolSections.module.css. Classes used by both
|
||||
appear in both files, which is correct: CSS Modules hash them per-file. */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* Standalone back link, sits above the header card on the page background. */
|
||||
.topBack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-strong);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.topBack:hover {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* Header Section */
|
||||
.header {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* With a map band above, slide the title up under the fade so map and title
|
||||
read as one object; the Compare button floats glassy over the map. */
|
||||
.headerHasMap .headerContent {
|
||||
padding-top: 0;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* The title (not the whole content row) rises above the map fade. Keeping
|
||||
.headerContent unpositioned matters: .actions must anchor to .header so it
|
||||
floats over the map band, not over the title. */
|
||||
.headerHasMap .titleSection {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 6;
|
||||
margin: 0;
|
||||
/* Beat the mobile `.actions { width: 100% }` rule — a floating button
|
||||
must never stretch across the title. */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd {
|
||||
background: var(--bg-card);
|
||||
color: var(--brand-strong);
|
||||
border-color: transparent;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), 0.16);
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* Full label by default; phones over the map get an icon-only button
|
||||
(same compact treatment as the section-nav compare icon). */
|
||||
.btnCompareGlyph {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Inline "View on map ↗" trigger next to the address. */
|
||||
.mapLink {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--brand-strong);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mapLink:hover {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.titleSection {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.schoolName {
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
|
||||
.address {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
/* Expanded header details (headteacher, website, trust, pupils) */
|
||||
.headerDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.headerDetail strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
/* "Show all details" reveal — only rendered on mobile/tablet, where the
|
||||
header details block is collapsed below the fold. Hidden on desktop. */
|
||||
.detailsToggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
|
||||
.btnAdd,
|
||||
.btnRemove {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.08));
|
||||
}
|
||||
|
||||
|
||||
.btnAdd {
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
.btnAdd:hover {
|
||||
background: var(--brand-stronger);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
.btnRemove {
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
.btnRemove:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
/* ── Sticky Section Navigation ──────────────────────── */
|
||||
/* Docks directly under the global header; Back and "All" stay pinned while
|
||||
only the section links scroll. */
|
||||
.sectionNav {
|
||||
position: sticky;
|
||||
top: 64px; /* global header height on desktop */
|
||||
z-index: 10;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavBack {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-strong);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavBack:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
/* The scrolling middle: section links only. */
|
||||
.sectionNavLinks {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinks::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLink:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive {
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive:hover {
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
/* ── Mobile: the scrolling links collapse into one "section" menu button ──
|
||||
(hidden on desktop, where the links fit). */
|
||||
.sectionNavMenu {
|
||||
display: none; /* shown only ≤640px */
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-height: 38px;
|
||||
padding: 0.34rem 0.7rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuCur {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuEyebrow {
|
||||
flex: none;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuNow {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavMenuChev {
|
||||
flex: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
/* Compact icon version of the Compare CTA, used on mobile. */
|
||||
.sectionNavCompareIcon {
|
||||
display: none; /* shown only ≤640px */
|
||||
position: relative;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--brand-strong);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIcon svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareBadge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
color: var(--brand-strong);
|
||||
border: 1.5px solid var(--brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIconIn {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
/* Compare CTA carried into the bar once the hero's button scrolls away. */
|
||||
.sectionNavCompare {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.34rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand-strong);
|
||||
border: 1px solid var(--brand-strong);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompare:hover {
|
||||
background: var(--brand-stronger);
|
||||
border-color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn {
|
||||
background: var(--bg-card);
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
/* "All ▾" jump menu (desktop). */
|
||||
.sectionNavAll {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.34rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavAll:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
.sectionsBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
background: rgba(var(--shadow-rgb), 0.28);
|
||||
}
|
||||
|
||||
|
||||
.sectionsPanel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1600;
|
||||
width: 230px;
|
||||
max-height: min(70vh, 460px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 44px rgba(var(--shadow-rgb), 0.2);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionsPanelHead {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
padding: 0.4rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionsItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionsItem:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.sectionsItemActive {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionsTick {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
|
||||
/* GIAS "Open, but proposed to close" notice strip */
|
||||
.closingStrip {
|
||||
background: var(--status-below-bg);
|
||||
border-left: 4px solid var(--status-below-bg);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--status-below);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.closingStrip strong {
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.headerHasMap .actions .btnCompareLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnCompareGlyph {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd,
|
||||
.headerHasMap .actions .btnRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNav {
|
||||
top: 56px; /* global header is shorter on mobile */
|
||||
padding: 0.4rem 0.6rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavLink,
|
||||
.sectionNavBack {
|
||||
min-height: 36px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavCompare {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
.sectionNavAll {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btnAdd,
|
||||
.btnRemove {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.25rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Pills wrap horizontally instead of stacking — short tokens like
|
||||
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
||||
rows of empty horizontal space. */
|
||||
.meta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Secondary header info (headteacher, website, pupil count, trust,
|
||||
contact, area) isn't needed above the fold on phones/tablets, so it's
|
||||
collapsed by default and revealed on demand via the "Show all details"
|
||||
link — reclaiming the vertical space so the metrics surface sooner. */
|
||||
.detailsToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.headerDetails {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerDetailsOpen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sectionNavLinks {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
/* When scrolled to the end, drop the fade so the last item isn't dimmed. */
|
||||
.sectionNavLinks.atEnd {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.sectionsPanel {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
max-height: 74vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0));
|
||||
box-shadow: 0 -10px 40px rgba(var(--shadow-rgb), 0.25);
|
||||
}
|
||||
|
||||
.sectionsItem {
|
||||
padding: 0.7rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Swap which controls show at the mobile breakpoint. Declared last so these
|
||||
display rules win over the base (equal-specificity) declarations above. */
|
||||
@media (max-width: 640px) {
|
||||
.sectionNavLinks,
|
||||
.sectionNavCompare,
|
||||
.sectionNavAll,
|
||||
.sectionNavBackLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sectionNavMenu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sectionNavCompareIcon {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* SchoolDetailShell — the interactive chrome of a school detail page.
|
||||
*
|
||||
* The ONLY large client component on the route. Everything below the sticky
|
||||
* nav is server-rendered and arrives as `children`, composed in
|
||||
* app/school/[slug]/page.tsx. That indirection is required: a server component
|
||||
* imported by a client component becomes a client component, so the sections
|
||||
* cannot be imported here.
|
||||
*
|
||||
* What stays client-side is genuinely interactive: router.back(), the header
|
||||
* details reveal, the hero map, the compare CTA, the nav overflow fade, the
|
||||
* Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds
|
||||
* sections with document.getElementById, so it works unchanged against
|
||||
* server-rendered children.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
||||
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
|
||||
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
|
||||
import type { NavItem } from '@/lib/schoolSections';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SchoolDetailShell.module.css';
|
||||
|
||||
|
||||
/**
|
||||
* Only what the chrome itself renders. Everything the sections need — Ofsted,
|
||||
* admissions, deprivation, finance, national averages — goes straight to the
|
||||
* section composers in page.tsx and never reaches the client.
|
||||
*/
|
||||
export interface SchoolDetailShellProps {
|
||||
schoolInfo: School;
|
||||
/** Only for the header's pupil-count fallback. */
|
||||
yearlyData: SchoolResult[];
|
||||
census: SchoolCensus | null;
|
||||
/** Section list for the sticky nav, computed on the server. */
|
||||
navItems: NavItem[];
|
||||
/** The server-rendered sections. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SchoolDetailShell({
|
||||
schoolInfo, yearlyData, census, navItems, children,
|
||||
}: SchoolDetailShellProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
// Admissions view state moved to AdmissionsViewToggle, the client island
|
||||
// inside the (server-rendered) admissions section.
|
||||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
|
||||
const heroActionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
// "All ▾" jump menu listing every section.
|
||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||
// Header details (headteacher, contact, trust, area) collapse behind a
|
||||
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||||
// fall back to search so the button never dead-ends or leaves the site.
|
||||
const handleBack = () => {
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionLinksRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const overflow = el.scrollWidth - el.clientWidth;
|
||||
// No overflow → treat as "at end" so the fade is hidden.
|
||||
if (overflow <= 1) {
|
||||
setSectionNavAtEnd(true);
|
||||
return;
|
||||
}
|
||||
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||||
};
|
||||
update();
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track whether the hero's "Add to Compare" button is still on screen.
|
||||
useEffect(() => {
|
||||
const el = heroActionsRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => setHeroCtaVisible(entry.isIntersecting),
|
||||
{ rootMargin: '-64px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Close the "All ▾" menu on Escape.
|
||||
useEffect(() => {
|
||||
if (!sectionsOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [sectionsOpen]);
|
||||
|
||||
// The chrome needs only these four. The section-shape flags are computed
|
||||
// once on the server (lib/schoolSections) and consumed by the section
|
||||
// composers; recomputing them here would duplicate that work for values
|
||||
// this component never renders.
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
const phase = schoolInfo.phase ?? '';
|
||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// Page-view event with funnel attribution. Fires once per mount.
|
||||
useEffect(() => {
|
||||
track('school_viewed', {
|
||||
urn: schoolInfo.urn,
|
||||
phase: phase || 'unknown',
|
||||
local_authority: schoolInfo.local_authority || 'unknown',
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schoolInfo.urn]);
|
||||
|
||||
// Track active section as user scrolls
|
||||
useEffect(() => {
|
||||
const ids = navItems.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
const observers: IntersectionObserver[] = [];
|
||||
const ratioMap: Record<string, number> = {};
|
||||
|
||||
const pickActive = () => {
|
||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||
};
|
||||
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
ratioMap[id] = 0;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratioMap[id] = entry.intersectionRatio;
|
||||
pickActive();
|
||||
},
|
||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
observers.push(obs);
|
||||
});
|
||||
|
||||
return () => observers.forEach(o => o.disconnect());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navItems.map(n => n.id).join(',')]);
|
||||
|
||||
// Label shown in the mobile "section" menu button — the section in view.
|
||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Standalone back link, above the header — returns to wherever the
|
||||
user came from. Scrolls away with the page (the sticky bar keeps a
|
||||
"back to top" control in its place). */}
|
||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* Header — the location map band blends down into the school title. */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.meta}>
|
||||
{schoolInfo.local_authority && (
|
||||
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
||||
)}
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{isAllThrough && (
|
||||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
{schoolInfo.age_range && (
|
||||
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||
)}
|
||||
{schoolInfo.nursery_provision && (
|
||||
<span className={styles.metaItem}>Nursery</span>
|
||||
)}
|
||||
{schoolInfo.has_sixth_form && (
|
||||
<span className={styles.metaItem}>Sixth form</span>
|
||||
)}
|
||||
</div>
|
||||
{isProposedToClose(schoolInfo) && (
|
||||
<div className={styles.closingStrip} role="note">
|
||||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||
check with the local authority before applying.
|
||||
</div>
|
||||
)}
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mapLink}
|
||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||
>
|
||||
View on map ↗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailsToggle}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-controls="school-header-details"
|
||||
onClick={() => setDetailsOpen((o) => !o)}
|
||||
>
|
||||
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||||
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
<div
|
||||
id="school-header-details"
|
||||
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||||
>
|
||||
{schoolInfo.headteacher_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a
|
||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="school_website"
|
||||
>
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
return (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Pupils:</strong> {total.toLocaleString()}
|
||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{schoolInfo.trust_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.telephone && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Phone:</strong>{' '}
|
||||
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||
{schoolInfo.telephone}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.religious_denomination && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Religious character:</strong>{' '}
|
||||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||
? 'None'
|
||||
: schoolInfo.religious_denomination}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.county && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>County:</strong> {schoolInfo.county}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.parliamentary_constituency && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions} ref={heroActionsRef}>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||
>
|
||||
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||
<span className={styles.btnCompareLabel}>
|
||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||
</span>
|
||||
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||
{isInComparison ? '✓' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sticky Section Navigation — docks under the global header */}
|
||||
<nav className={styles.sectionNav} aria-label="Page sections">
|
||||
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span className={styles.sectionNavBackLabel}>Top</span>
|
||||
</button>
|
||||
|
||||
{/* Desktop: scrolling section links */}
|
||||
<div
|
||||
ref={sectionLinksRef}
|
||||
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||
>
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||
onClick={() => track('section_nav_used', { section: id })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: a single "section" menu button that opens the jump sheet */}
|
||||
{navItems.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sectionNavMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sectionsOpen}
|
||||
onClick={() => setSectionsOpen((o) => !o)}
|
||||
>
|
||||
<span className={styles.sectionNavMenuCur}>
|
||||
<span className={styles.sectionNavMenuEyebrow}>Section</span>
|
||||
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
|
||||
</span>
|
||||
<span className={styles.sectionNavMenuChev} aria-hidden="true">▾</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The hero's Compare CTA, carried in once it scrolls out of view.
|
||||
Desktop shows a labelled pill; mobile a compact icon. */}
|
||||
{!heroCtaVisible && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
|
||||
>
|
||||
{isInComparison ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
|
||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||
title={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||
>
|
||||
{isInComparison ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m5 12 5 5 9-11" />
|
||||
</svg>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M4 7h13l-3-3" />
|
||||
<path d="M20 17H7l3 3" />
|
||||
</svg>
|
||||
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
|
||||
{navItems.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sectionNavAll}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sectionsOpen}
|
||||
onClick={() => setSectionsOpen((o) => !o)}
|
||||
>
|
||||
All <span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
|
||||
{sectionsOpen && (
|
||||
<>
|
||||
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
|
||||
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
|
||||
<div className={styles.sectionsPanelHead}>Jump to section</div>
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
role="menuitem"
|
||||
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
|
||||
onClick={() => {
|
||||
setSectionsOpen(false);
|
||||
track('section_nav_used', { section: id, via: 'all_menu' });
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true">✓</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* SchoolLifeSection — absence figures. Primary pages only; the secondary page
|
||||
* carries the equivalent content inside WellbeingSection.
|
||||
*
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { AbsenceData } from '@/lib/types';
|
||||
import { formatPercentage } from '@/lib/utils';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
export function SchoolLifeSection({
|
||||
absenceData,
|
||||
primaryAvg,
|
||||
}: {
|
||||
absenceData: AbsenceData | null;
|
||||
primaryAvg: Record<string, number>;
|
||||
}) {
|
||||
return (
|
||||
<Section id="school-life">
|
||||
<h2 className={styles.sectionTitle}>School Life</h2>
|
||||
<div className={styles.metricsGrid}>
|
||||
{absenceData?.overall_absence_rate != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Days missed (overall absence)
|
||||
<MetricTooltip metricKey="overall_absence_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
||||
{primaryAvg.overall_absence_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{absenceData?.persistent_absence_rate != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Regularly missing school
|
||||
<MetricTooltip metricKey="persistent_absence_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
||||
{primaryAvg.persistent_absence_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* SecondaryAdmissionsSection — secondary pages.
|
||||
*
|
||||
* Not shared with the primary AdmissionsSection: the two were only 14%
|
||||
* similar. This one has no year/trend toggle, so it ships no client
|
||||
* JavaScript at all. Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolAdmissions } from '@/lib/types';
|
||||
import { formatPercentage } from '@/lib/utils';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
export function SecondaryAdmissionsSection({
|
||||
admissions, schoolInfo, hasSixthForm,
|
||||
}: {
|
||||
admissions: SchoolAdmissions;
|
||||
schoolInfo: School;
|
||||
hasSixthForm: boolean;
|
||||
}) {
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
const denom = schoolInfo.religious_denomination ?? '';
|
||||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Admissions</h2>
|
||||
|
||||
{admissionsTag && (
|
||||
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
||||
<strong>{admissionsTag}</strong>{' '}
|
||||
{admissionsTag === 'Selective'
|
||||
? '— Entry to this school is by selective examination (e.g. 11+).'
|
||||
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total applications</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>1st preference applications</div>
|
||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Applications exceeded places last year'
|
||||
: '✓ Places were available last year'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||
</p>
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* SecondaryHistorySection — results over time. Secondary pages.
|
||||
* Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolResult, NationalAverages } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { PerformanceChart } from './charts';
|
||||
|
||||
|
||||
export function SecondaryHistorySection({
|
||||
yearlyData, schoolInfo, nationalAvg, secondaryAvg, suppressComparison,
|
||||
}: {
|
||||
yearlyData: SchoolResult[];
|
||||
schoolInfo: School;
|
||||
nationalAvg: NationalAverages | null;
|
||||
secondaryAvg: Record<string, number>;
|
||||
suppressComparison: boolean;
|
||||
}) {
|
||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
|
||||
return (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
||||
{yearlyData.length > 0 && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>Eng & Maths 4+</th>
|
||||
<th>EBacc entry %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SecondarySchoolSections — the section sequence for secondary detail pages.
|
||||
* Server component.
|
||||
*
|
||||
* Wrapped in `.secondaryScope`, which activates the secondary-only style
|
||||
* overrides in schoolSections.module.css. Those rules target class names the
|
||||
* primary page also uses (.card, .sectionTitle, .metricCard …), so scoping is
|
||||
* what keeps them from restyling primary pages.
|
||||
*
|
||||
* The render conditions here MUST match buildSecondaryNavItems in
|
||||
* lib/schoolSections, or the sticky nav will link to sections that do not exist.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||
import { OfstedSection } from './OfstedSection';
|
||||
import { GcseSection } from './GcseSection';
|
||||
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||
import { WellbeingSection } from './WellbeingSection';
|
||||
import { FinancesSection } from './FinancesSection';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
export interface SecondarySchoolSectionsProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
flags: SecondaryFlags;
|
||||
}
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, deprivation, finance, nationalAvg, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
|
||||
const ofstedInspectedDate = isReportCard
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
return (
|
||||
<div className={styles.secondaryScope}>
|
||||
{ofsted && (
|
||||
<OfstedSection
|
||||
ofsted={ofsted}
|
||||
urn={schoolInfo.urn}
|
||||
isReportCard={isReportCard}
|
||||
ofstedInspectedDate={ofstedInspectedDate}
|
||||
oeifAllSameGrade={oeifAllSameGrade}
|
||||
oeifAreas={oeifAreas}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasResults && flags.latestResults && (
|
||||
<GcseSection
|
||||
latestResults={flags.latestResults}
|
||||
schoolInfo={schoolInfo}
|
||||
secondaryAvg={secondaryAvg}
|
||||
p8Suspended={flags.p8Suspended}
|
||||
suppressComparison={flags.suppressComparison}
|
||||
/>
|
||||
)}
|
||||
|
||||
{admissions && (
|
||||
<SecondaryAdmissionsSection
|
||||
admissions={admissions}
|
||||
schoolInfo={schoolInfo}
|
||||
hasSixthForm={flags.hasSixthForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yearlyData.length > 1 && (
|
||||
<SecondaryHistorySection
|
||||
yearlyData={yearlyData}
|
||||
schoolInfo={schoolInfo}
|
||||
nationalAvg={nationalAvg}
|
||||
secondaryAvg={secondaryAvg}
|
||||
suppressComparison={flags.suppressComparison}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasWellbeing && (
|
||||
<WellbeingSection
|
||||
latestResults={flags.latestResults}
|
||||
census={census}
|
||||
schoolInfo={schoolInfo}
|
||||
deprivation={deprivation}
|
||||
hasDeprivation={flags.hasDeprivation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.hasFinance && finance && (
|
||||
<FinancesSection finance={finance} showPremises />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* WellbeingSection — SEN, gender split and local-area deprivation.
|
||||
* Secondary pages. Server component.
|
||||
*/
|
||||
|
||||
import type { School, SchoolCensus, SchoolResult, SchoolDeprivation } from '@/lib/types';
|
||||
import { formatPercentage } from '@/lib/utils';
|
||||
import { MetricTooltip } from '../MetricTooltip';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
function deprivationDesc(decile: number) {
|
||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||
}
|
||||
|
||||
export function WellbeingSection({
|
||||
latestResults, census, schoolInfo, deprivation, hasDeprivation,
|
||||
}: {
|
||||
latestResults: SchoolResult | null;
|
||||
census: SchoolCensus | null;
|
||||
schoolInfo: School;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
hasDeprivation: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section id="wellbeing" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Wellbeing & Context</h2>
|
||||
|
||||
{/* SEN */}
|
||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Without an EHCP</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_ehcp_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils with EHCP
|
||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
const female = census?.female_pupils ?? null;
|
||||
const male = census?.male_pupils ?? null;
|
||||
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||
const hasSplit = isMixed && female != null && male != null && female + male > 0;
|
||||
const sum = hasSplit ? female! + male! : 0;
|
||||
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
|
||||
const boysPct = hasSplit ? 100 - girlsPct : 0;
|
||||
return (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Total pupils</div>
|
||||
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
|
||||
{hasSplit && (
|
||||
<>
|
||||
<div
|
||||
className={styles.genderBar}
|
||||
role="img"
|
||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||
>
|
||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||
</div>
|
||||
<div className={styles.genderSplitHint}>
|
||||
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
|
||||
<span className={styles.genderSplitSep}> · </span>
|
||||
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{schoolInfo.capacity != null && !hasSplit && (
|
||||
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deprivation */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h3>
|
||||
<div className={styles.deprivationDots}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
||||
title={`Decile ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.deprivationScaleLabel}>
|
||||
<span>Most deprived</span>
|
||||
<span>Least deprived</span>
|
||||
</div>
|
||||
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Client wrappers for the lazily-loaded charts.
|
||||
*
|
||||
* `next/dynamic` with `ssr: false` is only legal inside a Client Component,
|
||||
* and the sections that render charts are Server Components. These one-line
|
||||
* wrappers are the client boundary, so the charts stay browser-only and
|
||||
* code-split while the section markup around them stays on the server.
|
||||
*
|
||||
* Chart.js is ~64 KB gzipped, so keeping it lazy matters.
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export const PerformanceChart = dynamic(
|
||||
() => import('../PerformanceChart').then((m) => m.PerformanceChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const SatsChart = dynamic(() => import('../SatsChart'), { ssr: false });
|
||||
|
||||
export const AdmissionsTrendChart = dynamic(
|
||||
() => import('../AdmissionsTrendChart'),
|
||||
{ ssr: false },
|
||||
);
|
||||
+564
-209
File diff suppressed because it is too large.
Load diff
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user