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,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(<DestinationsSection destinations={fullPhase} />);
|
||||||
|
expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders one bar segment per published category', () => {
|
||||||
|
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
||||||
|
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders NO bar at all when a category is withheld', () => {
|
||||||
|
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
||||||
|
// 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(<DestinationsSection destinations={suppressedPhase} />);
|
||||||
|
// 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(<DestinationsSection destinations={fullPhase} />);
|
||||||
|
// 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(<DestinationsSection destinations={suppressedPhase} />);
|
||||||
|
// 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(<DestinationsSection destinations={fullPhase} />);
|
||||||
|
// 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(<DestinationsSection destinations={empty} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
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
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
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,
|
schoolInfo: school_info, yearlyData: yearly_data,
|
||||||
absenceData: absence_data, census: census ?? null,
|
absenceData: absence_data, census: census ?? null,
|
||||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||||
|
destinations: destinations ?? null,
|
||||||
};
|
};
|
||||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||||
@@ -236,6 +237,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
|
destinations={destinations ?? null}
|
||||||
flags={secondaryFlags}
|
flags={secondaryFlags}
|
||||||
/>
|
/>
|
||||||
</SchoolDetailShell>
|
</SchoolDetailShell>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Section id="destinations">
|
||||||
|
<h2 className={sectionStyles.sectionTitle}>After Year 11</h2>
|
||||||
|
<p className={sectionStyles.sectionSubtitle}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<DestinationsView destinations={destinations} phase="ks4" />
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
|
SchoolDestinations,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
import { OfstedSection } from './OfstedSection';
|
import { OfstedSection } from './OfstedSection';
|
||||||
import { GcseSection } from './GcseSection';
|
import { GcseSection } from './GcseSection';
|
||||||
|
import { DestinationsSection } from './DestinationsSection';
|
||||||
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||||
import { DistanceSection } from './DistanceSection';
|
import { DistanceSection } from './DistanceSection';
|
||||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||||
@@ -40,13 +42,14 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
|
destinations: SchoolDestinations | null;
|
||||||
flags: SecondaryFlags;
|
flags: SecondaryFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance,
|
admissions, admissionsHistory, admissionDistance,
|
||||||
deprivation, finance, nationalAvg, flags,
|
deprivation, finance, nationalAvg, destinations, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
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 && (
|
||||||
|
<DestinationsSection destinations={destinations.ks4} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
||||||
sources, so either one warrants the section. */}
|
sources, so either one warrants the section. */}
|
||||||
{(admissions || admissionDistance) && (
|
{(admissions || admissionDistance) && (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -369,6 +369,12 @@ export interface SchoolDetailsResponse {
|
|||||||
admission_distance?: SchoolAdmissionDistance | null;
|
admission_distance?: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | 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 {
|
export interface ComparisonData {
|
||||||
|
|||||||
Reference in new issue
Block a user