diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx new file mode 100644 index 0000000..fa83056 --- /dev/null +++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import { DestinationsSection } from '@/components/school/DestinationsSection'; +import type { DestinationPhase } from '@/lib/types'; +import type { DestinationCategory, DestinationStatus } from '@/lib/destinations'; + +const cell = ( + category: DestinationCategory, + pupils: number | null, + status: DestinationStatus = 'published', +) => ({ + category, pupils, + percentage: pupils === null ? null : (pupils / 180) * 100, + status, +}); + +const ALL_PUBLISHED = [ + cell('school_sixth_form', 75), cell('sixth_form_college', 21), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), +]; + +const fullPhase: DestinationPhase = { + cohort_year: '2022/23', + groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } }, +}; + +const suppressedPhase: DestinationPhase = { + cohort_year: '2022/23', + groups: { + all: { + cohort: 180, + categories: [ + cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'), + cell('further_education', 55), cell('other_education', 6), + cell('apprenticeship', 8), cell('employment', 6), + cell('not_sustained', 5), cell('not_captured', 4), + ], + aggregates: {}, + }, + }, +}; + +describe('DestinationsSection', () => { + it('dates its own cohort so it is not read as stale next to the GCSE section', () => { + render(); + expect(screen.getByText(/2022\/23/)).toBeInTheDocument(); + }); + + it('renders one bar segment per published category', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8); + }); + + it('renders NO bar at all when a category is withheld', () => { + const { container } = render(); + // R1: a bar with a gap in it publishes the withheld figure by its width. + expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0); + expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0); + }); + + it('never states the remainder for a partially suppressed group', () => { + const { container } = render(); + // 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere. + expect(container.textContent).not.toMatch(/\b21\b/); + }); + + it('shows a card value for a group whose components are all published', () => { + render(); + // academic route = 75 + 21 = 96 of 180 = 53% + expect(screen.getByText('53%')).toBeInTheDocument(); + }); + + it('refuses a card value when one of its components is withheld', () => { + render(); + // academic route needs sixth_form_college, which is suppressed. + expect(screen.getByText(/not published/i)).toBeInTheDocument(); + expect(screen.queryByText('53%')).not.toBeInTheDocument(); + }); + + it('never claims a pupil stayed at this school', () => { + const { container } = render(); + // The published file reports destination TYPE, never destination institution. + expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i); + }); + + it('renders nothing when no group carries categories', () => { + const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 4e9f6aa..6320aa4 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { notFound(); } - const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data; + const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data; // Redirect bare URN to canonical slug URL const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -171,6 +171,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { schoolInfo: school_info, yearlyData: yearly_data, absenceData: absence_data, census: census ?? null, deprivation: deprivation ?? null, finance: finance ?? null, + destinations: destinations ?? null, }; const primaryFlags = computeSchoolFlags(sectionInput); const secondaryFlags = computeSecondaryFlags(sectionInput); @@ -236,6 +237,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { deprivation={deprivation ?? null} finance={finance ?? null} nationalAvg={nationalAvg} + destinations={destinations ?? null} flags={secondaryFlags} /> diff --git a/nextjs-app/components/school/DestinationsSection.tsx b/nextjs-app/components/school/DestinationsSection.tsx new file mode 100644 index 0000000..ef8c4f4 --- /dev/null +++ b/nextjs-app/components/school/DestinationsSection.tsx @@ -0,0 +1,38 @@ +/** + * DestinationsSection — where a school's Year 11 leavers went. Server component. + * + * The headline is deliberately NOT the sustained-destination rate. That figure + * sits between 92% and 97% for nearly every school in England, so leading with + * it would say nothing; the mix is what actually varies between schools. + * + * The section dates its own cohort because destination measures are published + * about two GCSE years behind the results in the section above — undated, the + * figures read as stale rather than as a different question. + */ + +import type { DestinationPhase } from '@/lib/types'; +import { Section, sectionStyles } from './sectionShared'; +import { DestinationsView } from './DestinationsView'; + +export function DestinationsSection({ destinations }: { destinations: DestinationPhase }) { + const all = destinations.groups.all; + const hasContent = Object.values(destinations.groups) + .some(group => (group?.categories?.length ?? 0) > 0); + if (!hasContent) return null; + + const cohort = all?.cohort ?? null; + const year = destinations.cohort_year; + + return ( +
+

After Year 11

+

+ Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11 + {year ? ` in ${year}` : ''} were during the following year. The Department + for Education tracks leavers for two terms, so these figures cover an + earlier year group than the GCSE results above. +

+ +
+ ); +} diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx new file mode 100644 index 0000000..9677e7a --- /dev/null +++ b/nextjs-app/components/school/DestinationsView.tsx @@ -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 = { + 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(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); + const isWithheld = cell.status === 'suppressed'; + return [( + + + + + + )]; + })} + +
DestinationPupilsShare
+ + {CATEGORY_LABELS[category]} + + {isWithheld + ? withheld + : cell.pupils} + + {isWithheld || cell.percentage === null + ? withheld + : `${Math.round(cell.percentage)}%`} +
+
+ +

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

+
+ ); +} diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index 1ba3d7a..8530f47 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -14,11 +14,13 @@ import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages, + SchoolDestinations, } from '@/lib/types'; import { ofstedLegacyAreas } from '@/lib/utils'; import type { SecondaryFlags } from '@/lib/schoolSections'; import { OfstedSection } from './OfstedSection'; import { GcseSection } from './GcseSection'; +import { DestinationsSection } from './DestinationsSection'; import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection'; import { DistanceSection } from './DistanceSection'; import { SecondaryHistorySection } from './SecondaryHistorySection'; @@ -40,13 +42,14 @@ export interface SecondarySchoolSectionsProps { deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; nationalAvg: NationalAverages | null; + destinations: SchoolDestinations | null; flags: SecondaryFlags; } export function SecondarySchoolSections({ schoolInfo, yearlyData, ofsted, census, admissions, admissionsHistory, admissionDistance, - deprivation, finance, nationalAvg, flags, + deprivation, finance, nationalAvg, destinations, flags, }: SecondarySchoolSectionsProps) { const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -85,6 +88,12 @@ export function SecondarySchoolSections({ /> )} + {/* Destinations follow attainment: they answer "and then what happened", + which only lands once the results are in view. */} + {flags.hasKs4Destinations && destinations?.ks4 && ( + + )} + {/* See PrimarySchoolSections: distance and EES admissions are independent sources, so either one warrants the section. */} {(admissions || admissionDistance) && ( diff --git a/nextjs-app/components/school/destinations.module.css b/nextjs-app/components/school/destinations.module.css new file mode 100644 index 0000000..0985196 --- /dev/null +++ b/nextjs-app/components/school/destinations.module.css @@ -0,0 +1,299 @@ +/* + * Destination sections. + * + * Every colour comes from the --dest-* tokens in globals.css, which are + * defined in both themes. Nothing here is a literal colour — see + * __tests__/components/darkThemeSafety.test.ts for why. + */ + +.view { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ── Cohort switch ─────────────────────────────────────────────────────── */ + +.switchRow { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.switchLabel { + font-size: var(--step--2); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.switchButtons { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + padding: 2px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + align-self: flex-start; +} + +.switchButton { + appearance: none; + border: none; + background: transparent; + font: inherit; + font-size: var(--step--1); + font-weight: 600; + color: var(--text-secondary); + padding: 0.5rem 0.9rem; + border-radius: calc(var(--radius-md) - 2px); + cursor: pointer; + white-space: nowrap; + transition: background var(--transition), color var(--transition); +} + +.switchButton:hover { color: var(--text-primary); } +.switchButton:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } + +.switchButton[aria-checked='true'] { + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-soft); +} + +.switchCount { + margin-left: 0.4rem; + font-weight: 500; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Question cards ────────────────────────────────────────────────────── */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem; +} + +.card { + display: flex; + flex-direction: column; + gap: 0.35rem; + text-align: left; + font: inherit; + background: var(--brand-bg); + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 1.15rem 1.25rem; + cursor: pointer; + transition: border-color var(--transition), opacity var(--transition); +} + +.card:hover { border-color: var(--brand); } +.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + +.cardWithheld { + background: var(--bg-secondary); + border-style: dashed; + border-color: var(--border-strong); + cursor: default; +} + +.cardQuestion { + font-family: var(--font-display); + font-size: var(--step--1); + font-weight: 700; + color: var(--text-primary); + text-wrap: balance; +} + +.cardValue { + font-family: var(--font-display); + font-size: var(--step-3); + font-weight: 800; + line-height: 1.05; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--text-primary); +} + +.cardWithheldValue { + font-family: var(--font-display); + font-size: var(--step-1); + font-weight: 700; + color: var(--text-muted); +} + +.cardHint { + font-size: var(--step--2); + color: var(--text-secondary); +} + +.cardKeys { + display: flex; + gap: 0.3rem; + margin-top: 0.2rem; +} + +.cardKeys .swatch { + width: 1.5rem; + height: 0.35rem; + border-radius: 2px; +} + +/* ── Bar ───────────────────────────────────────────────────────────────── */ + +.barBlock { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* 2px surface gaps between segments so adjacent fills stay distinguishable + without a border darkening the palette. */ +.bar { + display: flex; + gap: 2px; + height: 3rem; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-card); +} + +.segment { + display: grid; + place-items: center; + min-width: 2px; + overflow: hidden; + color: var(--text-inverse); + font-size: var(--step--2); + font-weight: 700; + font-variant-numeric: tabular-nums; + transition: opacity var(--transition); +} + +.barScale { + display: flex; + justify-content: space-between; + font-size: var(--step--2); + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Category colours ──────────────────────────────────────────────────── */ + +.higher_education, +.school_sixth_form { background: var(--dest-sixthform); } +.sixth_form_college { background: var(--dest-sfcollege); } +.further_education, +.other_education { background: var(--dest-fecollege); color: var(--text-primary); } +.apprenticeship { background: var(--dest-apprentice); } +.employment { background: var(--dest-employment); } + +/* The absence is hatched neutral, never a colour: "activity not captured" + covers independent schools, moving abroad and training the department holds + no data on, so a red segment would state something false. The hatch is also + the secondary encoding that separates it from the employment blue. */ +.not_sustained, +.not_captured { + background-color: var(--bg-card); + background-image: repeating-linear-gradient( + 45deg, + var(--dest-none-hatch) 0 3px, + transparent 3px 7px + ); + box-shadow: inset 0 0 0 1px var(--dest-none); + color: var(--text-secondary); +} + +.swatch { + display: inline-block; + width: 0.9rem; + height: 0.9rem; + border-radius: 3px; + flex: none; +} + +/* ── Dimming, for the card-to-bar linkage ──────────────────────────────── */ + +.dim { opacity: 0.3; } + +/* ── Withheld panel ────────────────────────────────────────────────────── */ + +.withheldPanel { + background: var(--bg-secondary); + border-radius: var(--radius-md); + padding: 1.15rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.withheldTitle { + font-family: var(--font-display); + font-size: var(--step--1); + font-weight: 700; + color: var(--text-primary); +} + +.withheldBody { + margin: 0; + font-size: var(--step--1); + color: var(--text-secondary); +} + +.withheldMark { + display: inline-block; + font-size: var(--step--2); + font-weight: 700; + color: var(--text-muted); + background: var(--bg-secondary); + border: 1px dashed var(--border-strong); + border-radius: 999px; + padding: 0.1rem 0.5rem; +} + +/* ── Table ─────────────────────────────────────────────────────────────── */ + +.tableWrap { overflow-x: auto; } + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--step--1); +} + +.table th, +.table td { + padding: 0.55rem 0; + border-bottom: 1px solid var(--border); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.table thead th { + font-size: var(--step--2); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.table th:first-child { text-align: left; } + +.rowName { + display: flex; + align-items: center; + gap: 0.6rem; + font-weight: 500; + color: var(--text-primary); +} + +.footnote { + margin: 0; + font-size: var(--step--2); + color: var(--text-muted); +} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index ded1b99..17688dc 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -369,6 +369,12 @@ export interface SchoolDetailsResponse { admission_distance?: SchoolAdmissionDistance | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; + /** + * Optional so an older backend, which does not send the key at all, still + * typechecks. Null means the school has no published destination data; + * either way the sections simply do not render. + */ + destinations?: SchoolDestinations | null; } export interface ComparisonData {