'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 = { 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)); } /** * 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 withheld; } const notApplicable = ( — ); 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)}%`; } 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(available[0] ?? 'all'); const [focused, setFocused] = useState(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 (
{available.length > 1 && (
Show
{available.map(g => ( ))}
)}
{(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 => ( )); if (total === null) { return (
{question} Not published Too few pupils went to {hint} for the Department for Education to release a figure. {keys}
); } return ( ); })}
{barDrawable ? (
{segments.map(seg => { const card = cardGroupFor(seg.category); return (
{seg.widthPct >= 9 ? `${seg.labelPct}%` : ''}
); })}
0%25%50%75%100%
) : (
No breakdown chart for this group

{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.

)}
{CATEGORY_ORDER.flatMap(category => { const cell = group.cells.find(c => c.category === category); if (!cell) return []; const card = cardGroupFor(category); return [( )]; })}
Destination Pupils Share
{CATEGORY_LABELS[category]} {cellValue(cell, group.cohort, 'pupils')} {cellValue(cell, group.cohort, 'share')}

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.

); }