PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
Code review found the disclosure the whole design was meant to prevent.
R1 was written as a rendering rule and implemented as one: canRenderBar
stopped the bar being drawn, but GET /api/schools/{urn} still carried the
cohort and every published category. cohort - sum(published) returned
Whitley Bay's withheld further-education figure exactly — 18 pupils — to
any caller, and the RSC payload put it in the browser too.
app.py already stated the principle for admission_distance: this endpoint
is public and unauthenticated, so a field left in the payload is a
published field. The same reasoning applies here and did not get applied.
_mask_for_disclosure now closes both identities before serialisation —
categories sum to the cohort, and disadvantaged + other = all — by adding
secondary suppression until every row and column hides none or at least
two. My first attempt picked the smallest published cell as the companion
and a new test caught it choosing a zero, which protects nothing: the
residual still resolved to 18. The companion must carry pupils.
DfE's own aggregates are no longer served. Nothing rendered them, and one
spanning a single suppressed component names it.
Cost, measured over 262 mainstream secondaries: the all-pupils bar
survives on 94% rather than 100%. Zero lone-suppressed groups remain.
The e2e helper now tells a missing feature apart from missing data: it
fails if the API serves no destinations key at all, and skips if the key
is served but the annual DAG has not populated the marts. Failing on the
second would redden the staging gate for unrelated commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
232 lines
8.6 KiB
TypeScript
232 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* DestinationsView — the interactive body of both destination sections.
|
|
*
|
|
* Three question cards over one bar, with the cards acting as a lens on the
|
|
* bar rather than a summary beside it: focusing a card dims everything the
|
|
* card is not made of, so the grouping we chose is inspectable rather than
|
|
* asserted.
|
|
*
|
|
* Everything here defers to lib/destinations.ts for what may be shown. In
|
|
* particular the bar is rendered only when canRenderBar() allows it: the
|
|
* destination categories sum to the cohort, so a bar drawn from the published
|
|
* segments leaves a gap whose width IS the withheld figure.
|
|
*
|
|
* The one client component in this directory besides AdmissionsViewToggle.
|
|
* The all-pupils view is what the server renders into the HTML; the switch and
|
|
* the hover linkage are the only parts that need the browser.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import type { DestinationPhase, DestinationGroupPayload } from '@/lib/types';
|
|
import {
|
|
CARD_GROUPS, CARD_QUESTIONS, CATEGORY_LABELS, CATEGORY_ORDER,
|
|
aggregateCells, canRenderBar, toBarSegments, cardGroupFor,
|
|
type CardGroup, type DestinationCell, type DestinationGroup, type PupilGroup,
|
|
} from '@/lib/destinations';
|
|
import styles from './destinations.module.css';
|
|
|
|
const GROUP_LABELS: Record<PupilGroup, string> = {
|
|
all: 'All pupils',
|
|
disadvantaged: 'Disadvantaged',
|
|
other: 'All other pupils',
|
|
};
|
|
|
|
const GROUP_ORDER: PupilGroup[] = ['all', 'disadvantaged', 'other'];
|
|
|
|
function toGroup(payload: DestinationGroupPayload): DestinationGroup {
|
|
return {
|
|
cohort: payload.cohort ?? 0,
|
|
cells: payload.categories,
|
|
};
|
|
}
|
|
|
|
function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
|
|
const wanted = new Set(CARD_GROUPS[card]);
|
|
return group.cells.filter(c => wanted.has(c.category));
|
|
}
|
|
|
|
export function DestinationsView({
|
|
destinations, phase,
|
|
}: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) {
|
|
const available = GROUP_ORDER.filter(
|
|
g => (destinations.groups[g]?.categories?.length ?? 0) > 0,
|
|
);
|
|
const [selected, setSelected] = useState<PupilGroup>(available[0] ?? 'all');
|
|
const [focused, setFocused] = useState<CardGroup | null>(null);
|
|
|
|
const payload = destinations.groups[selected];
|
|
if (!payload) return null;
|
|
const group = toGroup(payload);
|
|
|
|
const barDrawable = canRenderBar(group);
|
|
const segments = barDrawable ? toBarSegments(group) : [];
|
|
const withheld = group.cells.filter(c => c.status === 'suppressed');
|
|
|
|
const dimmed = (card: CardGroup | null) => focused !== null && focused !== card;
|
|
|
|
return (
|
|
<div className={styles.view}>
|
|
{available.length > 1 && (
|
|
<div className={styles.switchRow}>
|
|
<span className={styles.switchLabel} id={`${phase}-cohort-label`}>Show</span>
|
|
<div
|
|
className={styles.switchButtons}
|
|
role="radiogroup"
|
|
aria-labelledby={`${phase}-cohort-label`}
|
|
>
|
|
{available.map(g => (
|
|
<button
|
|
key={g}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected === g}
|
|
className={styles.switchButton}
|
|
onClick={() => { setSelected(g); setFocused(null); }}
|
|
>
|
|
{GROUP_LABELS[g]}
|
|
<span className={styles.switchCount}>
|
|
{destinations.groups[g]?.cohort ?? ''}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.cards}>
|
|
{(Object.keys(CARD_GROUPS) as CardGroup[]).map(card => {
|
|
const cells = cellsFor(group, card);
|
|
if (cells.length === 0) return null;
|
|
const total = aggregateCells(cells, group.cohort);
|
|
const { question, hint } = CARD_QUESTIONS[card];
|
|
const keys = cells.map(c => (
|
|
<span key={c.category} className={`${styles.swatch} ${styles[c.category]}`} />
|
|
));
|
|
|
|
if (total === null) {
|
|
return (
|
|
<div key={card} className={`${styles.card} ${styles.cardWithheld}`}>
|
|
<span className={styles.cardQuestion}>{question}</span>
|
|
<span className={styles.cardWithheldValue}>Not published</span>
|
|
<span className={styles.cardHint}>
|
|
Too few pupils went to {hint} for the Department for Education
|
|
to release a figure.
|
|
</span>
|
|
<span className={styles.cardKeys}>{keys}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={card}
|
|
type="button"
|
|
className={`${styles.card} ${dimmed(card) ? styles.dim : ''}`}
|
|
data-group={card}
|
|
onMouseEnter={() => setFocused(card)}
|
|
onMouseLeave={() => setFocused(null)}
|
|
onFocus={() => setFocused(card)}
|
|
onBlur={() => setFocused(null)}
|
|
>
|
|
<span className={styles.cardQuestion}>{question}</span>
|
|
<span className={styles.cardValue}>{Math.round(total.percentage)}%</span>
|
|
<span className={styles.cardHint}>went to {hint}.</span>
|
|
<span className={styles.cardKeys}>{keys}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{barDrawable ? (
|
|
<div className={styles.barBlock}>
|
|
<div className={styles.bar}>
|
|
{segments.map(seg => {
|
|
const card = cardGroupFor(seg.category);
|
|
return (
|
|
<div
|
|
key={seg.category}
|
|
data-destination-segment={seg.category}
|
|
data-group={card ?? 'none'}
|
|
className={`${styles.segment} ${styles[seg.category]} ${dimmed(card) ? styles.dim : ''}`}
|
|
style={{ width: `${seg.widthPct}%` }}
|
|
title={`${CATEGORY_LABELS[seg.category]} — ${seg.labelPct}% (${seg.pupils} pupils)`}
|
|
>
|
|
{seg.widthPct >= 9 ? `${seg.labelPct}%` : ''}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className={styles.barScale}>
|
|
<span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={styles.withheldPanel}>
|
|
<strong className={styles.withheldTitle}>
|
|
No breakdown chart for this group
|
|
</strong>
|
|
<p className={styles.withheldBody}>
|
|
{withheld.length === 1
|
|
? 'One of the destinations is withheld'
|
|
: `${withheld.length} of the destinations are withheld`}
|
|
{' '}because too few pupils went there. These destinations add up to
|
|
the whole year group, so drawing the rest as a chart would give the
|
|
withheld figures away. The table below shows what was published,
|
|
and nothing more.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Destination</th>
|
|
<th scope="col">Pupils</th>
|
|
<th scope="col">Share</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{CATEGORY_ORDER.flatMap(category => {
|
|
const cell = group.cells.find(c => c.category === category);
|
|
if (!cell) return [];
|
|
const card = cardGroupFor(category);
|
|
const isWithheld = cell.status === 'suppressed';
|
|
return [(
|
|
<tr
|
|
key={category}
|
|
data-group={card ?? 'none'}
|
|
className={dimmed(card) ? styles.dim : ''}
|
|
>
|
|
<th scope="row" className={styles.rowName}>
|
|
<span className={`${styles.swatch} ${styles[category]}`} />
|
|
{CATEGORY_LABELS[category]}
|
|
</th>
|
|
<td>
|
|
{isWithheld
|
|
? <span className={styles.withheldMark}>withheld</span>
|
|
: cell.pupils}
|
|
</td>
|
|
<td>
|
|
{isWithheld || cell.percentage === null
|
|
? <span className={styles.withheldMark}>withheld</span>
|
|
: `${Math.round(cell.percentage)}%`}
|
|
</td>
|
|
</tr>
|
|
)];
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p className={styles.footnote}>
|
|
Shares are rounded and may not add up to 100%. A pupil counted under a
|
|
school sixth form may have moved to a different school's sixth
|
|
form — the published data records the type of place, not which one.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|