feat(destinations): the After Year 11 section
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 it is not made of, so the grouping we chose is inspectable rather than asserted. The bar renders only when canRenderBar allows it. Where a category is withheld the section says so and shows the table instead: the categories sum to the cohort, so a bar drawn from the published segments leaves a gap whose width is the withheld figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
1 parent
68b6417149
commit
cef2f77149
7 files changed
+680
-2
No files matched your search
@@ -0,0 +1,232 @@
|
||||
'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,
|
||||
aggregates: payload.aggregates,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user