2026-08-28 16:14:13 +01:00
|
|
|
'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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 21:48:39 +01:00
|
|
|
/**
|
|
|
|
|
* One cell of the detail table.
|
|
|
|
|
*
|
|
|
|
|
* The three statuses are three different statements and the table has to keep
|
|
|
|
|
* them apart, because the whole pipeline does — the mart, the SQLAlchemy model
|
|
|
|
|
* and the serialiser all preserve the difference deliberately:
|
|
|
|
|
*
|
|
|
|
|
* published the figure
|
|
|
|
|
* suppressed DfE withheld it to protect a small number of pupils
|
|
|
|
|
* not_applicable this destination does not apply to this school at all
|
|
|
|
|
*
|
|
|
|
|
* An earlier version keyed the share column off `percentage === null`, which is
|
|
|
|
|
* also true for not_applicable, so a category that simply does not apply was
|
|
|
|
|
* labelled "withheld" — while the pupils column beside it rendered blank. Both
|
|
|
|
|
* columns now derive from `status`, so they cannot disagree.
|
|
|
|
|
*/
|
|
|
|
|
function cellValue(
|
|
|
|
|
cell: DestinationCell, cohort: number, kind: 'pupils' | 'share',
|
|
|
|
|
) {
|
|
|
|
|
if (cell.status === 'suppressed') {
|
|
|
|
|
return <span className={styles.withheldMark}>withheld</span>;
|
|
|
|
|
}
|
|
|
|
|
const notApplicable = (
|
|
|
|
|
<span className={styles.notApplicable} title="Does not apply to this school">
|
|
|
|
|
—
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cell.status !== 'published' || cell.pupils === null) return notApplicable;
|
|
|
|
|
if (kind === 'pupils') return cell.pupils;
|
|
|
|
|
|
|
|
|
|
// Percentages come from the mart, but a published count with no published
|
|
|
|
|
// percentage is recoverable from the cohort — both halves are published, so
|
|
|
|
|
// nothing withheld is involved. Same derivation the bar widths use.
|
|
|
|
|
const share = cell.percentage ?? (cohort > 0 ? (cell.pupils / cohort) * 100 : null);
|
|
|
|
|
return share === null ? notApplicable : `${Math.round(share)}%`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 16:14:13 +01:00
|
|
|
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);
|
|
|
|
|
return [(
|
|
|
|
|
<tr
|
|
|
|
|
key={category}
|
|
|
|
|
data-group={card ?? 'none'}
|
2026-08-30 21:48:39 +01:00
|
|
|
data-status={cell.status}
|
2026-08-28 16:14:13 +01:00
|
|
|
className={dimmed(card) ? styles.dim : ''}
|
|
|
|
|
>
|
|
|
|
|
<th scope="row" className={styles.rowName}>
|
|
|
|
|
<span className={`${styles.swatch} ${styles[category]}`} />
|
|
|
|
|
{CATEGORY_LABELS[category]}
|
|
|
|
|
</th>
|
2026-08-30 21:48:39 +01:00
|
|
|
<td>{cellValue(cell, group.cohort, 'pupils')}</td>
|
|
|
|
|
<td>{cellValue(cell, group.cohort, 'share')}</td>
|
2026-08-28 16:14:13 +01:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|