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