diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx
index 11002e7..d744423 100644
--- a/nextjs-app/__tests__/components/DestinationsSection.test.tsx
+++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx
@@ -89,3 +89,61 @@ describe('DestinationsSection', () => {
expect(container.firstChild).toBeNull();
});
});
+
+describe('the detail table keeps the three statuses apart', () => {
+ // 'suppressed' and 'not_applicable' are different claims, and the mart, the
+ // SQLAlchemy model and the serialiser all preserve the difference. The table
+ // used to key its Share column off `percentage === null`, which is true for
+ // both, so a category that simply does not apply was labelled "withheld" —
+ // while the Pupils column beside it rendered blank.
+ const mixedPhase: DestinationPhase = {
+ cohort_year: '2022/23',
+ groups: {
+ all: {
+ cohort: 180,
+ categories: [
+ cell('school_sixth_form', 75),
+ cell('sixth_form_college', null, 'suppressed'),
+ cell('further_education', null, 'suppressed'),
+ cell('apprenticeship', null, 'not_applicable'),
+ ],
+ },
+ },
+ };
+
+ const rowFor = (container: HTMLElement, category: string) =>
+ Array.from(container.querySelectorAll('tbody tr'))
+ .find(tr => tr.textContent?.includes(category));
+
+ it('never labels a not-applicable category as withheld', () => {
+ const { container } = render();
+ const row = rowFor(container, 'Apprenticeship');
+ expect(row).toBeTruthy();
+ expect(row!.textContent).not.toMatch(/withheld/i);
+ });
+
+ it('labels a genuinely suppressed category as withheld in both columns', () => {
+ const { container } = render();
+ const row = rowFor(container, 'Sixth-form college');
+ expect(row).toBeTruthy();
+ expect(row!.querySelectorAll('td')).toHaveLength(2);
+ Array.from(row!.querySelectorAll('td')).forEach(td =>
+ expect(td.textContent).toMatch(/withheld/i));
+ });
+
+ it('the two columns of a row never disagree about what the row is', () => {
+ const { container } = render();
+ Array.from(container.querySelectorAll('tbody tr')).forEach(tr => {
+ const cells = Array.from(tr.querySelectorAll('td'))
+ .map(td => /withheld/i.test(td.textContent ?? ''));
+ expect(new Set(cells).size).toBe(1);
+ });
+ });
+
+ it('shows a published category its real figures', () => {
+ const { container } = render();
+ const row = rowFor(container, 'State-funded school sixth form');
+ expect(row!.textContent).toMatch(/75/);
+ expect(row!.textContent).toMatch(/42%/);
+ });
+});
diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx
index 2dae294..f538d57 100644
--- a/nextjs-app/components/school/DestinationsView.tsx
+++ b/nextjs-app/components/school/DestinationsView.tsx
@@ -47,6 +47,44 @@ function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
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' }) {
@@ -193,27 +231,19 @@ export function DestinationsView({
const cell = group.cells.find(c => c.category === category);
if (!cell) return [];
const card = cardGroupFor(category);
- const isWithheld = cell.status === 'suppressed';
return [(
|
{CATEGORY_LABELS[category]}
|
-
- {isWithheld
- ? withheld
- : cell.pupils}
- |
-
- {isWithheld || cell.percentage === null
- ? withheld
- : `${Math.round(cell.percentage)}%`}
- |
+ {cellValue(cell, group.cohort, 'pupils')} |
+ {cellValue(cell, group.cohort, 'share')} |
)];
})}
diff --git a/nextjs-app/components/school/destinations.module.css b/nextjs-app/components/school/destinations.module.css
index 0985196..28896f3 100644
--- a/nextjs-app/components/school/destinations.module.css
+++ b/nextjs-app/components/school/destinations.module.css
@@ -297,3 +297,11 @@
font-size: var(--step--2);
color: var(--text-muted);
}
+
+/* A destination that does not apply to this school. Deliberately not the
+ withheld badge: "we are not told" and "there is nothing to tell" are
+ different statements, and the rest of the pipeline keeps them apart. */
+.notApplicable {
+ color: var(--text-muted);
+ cursor: help;
+}