Ordered so the disclosure guards land first and everything downstream consumes them: lib/destinations.ts, tokens, tap, staging, marts, API, then the two sections and the journeys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
70 KiB
Destination Measures Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Show what happened to a school's leavers after Year 11 and after the sixth form, on secondary school detail pages, without ever republishing a figure DfE withheld.
Architecture: A new Meltano tap pulls the EES destinations query API into raw; dbt staging preserves the c suppression sentinel as a status column rather than nulling it; long-format marts carry one row per school × year × pupil group × destination category; the API serialises a destinations block; a server component renders all-pupils into the HTML with one client component for the cohort switch. All three disclosure rules live as executable guards in lib/destinations.ts.
Tech Stack: Python 3.12 / Singer SDK / Meltano · dbt + PostgreSQL · FastAPI + SQLAlchemy · Next.js (App Router) + TypeScript + CSS Modules · Jest · Playwright
Spec: docs/superpowers/specs/2026-08-28-destination-measures-design.md
Global Constraints
- R1 — Never render a derived remainder. Not as a number, not as a bar segment. Where any category in a pupil group is suppressed, no bar is drawn for that group.
- R2 — Never aggregate across a suppression boundary. Compute an aggregate from components only when every component is published. Render a DfE-published aggregate only when the count of suppressed components within it is 0 or ≥ 2.
- R3 — Where a category is suppressed for the disadvantaged group, it is also withheld for the other-pupils group. The all-pupils view keeps it. Enforced in the mart.
safe_numericmust never be applied to a destination count or percentage. It coercesctoNULL, destroying the distinction between withheld and no data.- Destination categories, verbatim from the EES filter:
School sixth form,Sixth form college,Further education,Other education destination,Sustained apprenticeships,Sustained employment destination,Not recorded as a sustained destination,Activity not captured. Aggregates:Sustained education destination,Sustained education, employment & apprenticeships. - Card grouping (ours, not DfE's): academic = school sixth form + sixth-form college; college = further education + other education; work = apprenticeship + employment.
- Copy must never imply a pupil "stayed on here" — the file reports destination type, never destination institution.
- Every new colour is a token in
nextjs-app/app/globals.css, defined in:rootand in both dark blocks. Never style a component from inside a theme block. - Percentages for display are rounded; bar widths derive from unrounded pupil counts.
- EES API:
https://api.education.gov.uk/statistics/v1. KS4 dataset019d4f41-22d1-71b2-a1a7-f3b91026815b; 16-18 dataset019d4e73-6440-7523-b60c-bfab1ad4a30d. Time periods use the2022/2023form, not2022/23.
Pipeline reality: dbt and meltano do not run locally. Tasks 3–5 are verified by unit tests and by SQL review; the models only produce data once Tudor triggers the Airflow DAG on staging. Do not claim mart data exists until that has run.
Task 1: Destination domain logic
The disclosure rules are here, in pure functions, so they can be tested without a database, a network, or a browser. Every later task consumes this module.
Files:
- Create:
nextjs-app/lib/destinations.ts - Test:
nextjs-app/__tests__/lib/destinations.test.ts
Interfaces:
-
Consumes: nothing
-
Produces:
type DestinationCategory— the eight category slugstype PupilGroup = 'all' | 'disadvantaged' | 'other'type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'interface DestinationCell { category; pupils: number | null; percentage: number | null; status }interface DestinationGroup { cohort: number; cells: DestinationCell[]; aggregates: Record<string, DestinationCell> }CARD_GROUPS: Record<CardGroup, DestinationCategory[]>canAggregate(cells: DestinationCell[]): booleanaggregateCells(cells: DestinationCell[], cohort: number): { pupils: number; percentage: number } | nullcanRenderPublishedAggregate(components: DestinationCell[]): booleancanRenderBar(group: DestinationGroup): booleantoBarSegments(group: DestinationGroup): { category; pupils; widthPct; labelPct }[]suppressedCount(cells: DestinationCell[]): number
-
Step 1: Write the failing test
Create nextjs-app/__tests__/lib/destinations.test.ts:
import {
canAggregate, aggregateCells, canRenderPublishedAggregate,
canRenderBar, toBarSegments, CARD_GROUPS,
type DestinationCell, type DestinationGroup,
} from '@/lib/destinations';
const pub = (category: any, pupils: number, cohort: number): DestinationCell => ({
category, pupils, percentage: (pupils / cohort) * 100, status: 'published',
});
const sup = (category: any): DestinationCell => ({
category, pupils: null, percentage: null, status: 'suppressed',
});
const fullGroup = (): DestinationGroup => ({
cohort: 180,
cells: [
pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180),
pub('further_education', 55, 180), pub('other_education', 6, 180),
pub('apprenticeship', 8, 180), pub('employment', 6, 180),
pub('not_sustained', 5, 180), pub('not_captured', 4, 180),
],
aggregates: {},
});
describe('canAggregate — R2, computing from components', () => {
it('allows a sum when every component is published', () => {
expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true);
});
it('refuses a sum when any component is suppressed', () => {
expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false);
});
it('refuses a sum when every component is suppressed', () => {
expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false);
});
});
describe('aggregateCells', () => {
it('sums published cells and derives a percentage from the cohort', () => {
expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180))
.toEqual({ pupils: 14, percentage: (14 / 180) * 100 });
});
it('returns null rather than a partial sum when a component is suppressed', () => {
expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull();
});
});
describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => {
it('allows it when no component is suppressed', () => {
expect(canRenderPublishedAggregate([pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180)])).toBe(true);
});
it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => {
expect(canRenderPublishedAggregate([pub('school_sixth_form', 75, 180), sup('sixth_form_college')])).toBe(false);
});
it('allows it when two or more components are suppressed', () => {
expect(canRenderPublishedAggregate([sup('school_sixth_form'), sup('sixth_form_college')])).toBe(true);
});
});
describe('canRenderBar — R1', () => {
it('allows a bar when the whole group is published', () => {
expect(canRenderBar(fullGroup())).toBe(true);
});
it('refuses a bar when a single category is suppressed', () => {
const g = fullGroup();
g.cells[1] = sup('sixth_form_college');
expect(canRenderBar(g)).toBe(false);
});
});
describe('toBarSegments', () => {
it('derives widths from counts, not from rounded percentages', () => {
const segs = toBarSegments(fullGroup());
expect(segs).toHaveLength(8);
expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10);
expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6);
});
it('throws rather than silently leaving a gap when the group is suppressed', () => {
const g = fullGroup();
g.cells[1] = sup('sixth_form_college');
expect(() => toBarSegments(g)).toThrow(/suppressed/i);
});
});
describe('CARD_GROUPS', () => {
it('partitions every destination category exactly once, plus the absence', () => {
const grouped = Object.values(CARD_GROUPS).flat();
expect(new Set(grouped).size).toBe(grouped.length);
expect(grouped).toEqual(expect.arrayContaining([
'school_sixth_form', 'sixth_form_college', 'further_education',
'other_education', 'apprenticeship', 'employment',
]));
expect(grouped).not.toContain('not_sustained');
expect(grouped).not.toContain('not_captured');
});
});
- Step 2: Run test to verify it fails
Run: cd nextjs-app && npx jest __tests__/lib/destinations.test.ts
Expected: FAIL — Cannot find module '@/lib/destinations'
- Step 3: Write the implementation
Create nextjs-app/lib/destinations.ts:
/**
* Destination measures — categories, the card grouping, and the disclosure
* guards.
*
* DfE suppresses individual cells with `c`, and the categories sum to the
* cohort. So subtracting the published cells from the cohort total recovers a
* lone suppressed cell exactly — on 22% of mainstream secondaries. The guards
* below are what stop this module's consumers doing that by accident, and they
* are the reason percentages are never reconstructed from a partial sum.
*
* See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
*/
export type DestinationCategory =
| 'school_sixth_form'
| 'sixth_form_college'
| 'further_education'
| 'other_education'
| 'apprenticeship'
| 'employment'
| 'not_sustained'
| 'not_captured';
export type PupilGroup = 'all' | 'disadvantaged' | 'other';
export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable';
export type CardGroup = 'academic' | 'college' | 'work';
export interface DestinationCell {
category: DestinationCategory;
pupils: number | null;
percentage: number | null;
status: DestinationStatus;
}
export interface DestinationGroup {
cohort: number;
cells: DestinationCell[];
/** Aggregates DfE published itself, keyed by slug. */
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
}
/** Display order, which is also bar order: education, then work, then absence. */
export const CATEGORY_ORDER: DestinationCategory[] = [
'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education',
'apprenticeship', 'employment', 'not_sustained', 'not_captured',
];
/**
* Our grouping, not DfE's — the single most arguable thing on the page, which
* is why it lives in exactly one place. `not_sustained` and `not_captured` are
* deliberately absent: they are an absence of destination, not a route.
*/
export const CARD_GROUPS: Record<CardGroup, DestinationCategory[]> = {
academic: ['school_sixth_form', 'sixth_form_college'],
college: ['further_education', 'other_education'],
work: ['apprenticeship', 'employment'],
};
export function suppressedCount(cells: DestinationCell[]): number {
return cells.filter(c => c.status === 'suppressed').length;
}
/** R2: a sum computed from components is safe only if every component is published. */
export function canAggregate(cells: DestinationCell[]): boolean {
return cells.length > 0 && cells.every(c => c.status === 'published');
}
export function aggregateCells(
cells: DestinationCell[], cohort: number,
): { pupils: number; percentage: number } | null {
if (!canAggregate(cells) || cohort <= 0) return null;
const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0);
return { pupils, percentage: (pupils / cohort) * 100 };
}
/**
* R2, the other direction: DfE published this total itself. Showing it beside
* the components is safe only when it spans no suppressed component, or two or
* more. Exactly one and the total names the withheld figure.
*/
export function canRenderPublishedAggregate(components: DestinationCell[]): boolean {
return suppressedCount(components) !== 1;
}
/** R1: a bar is drawable only when nothing in the group is withheld. */
export function canRenderBar(group: DestinationGroup): boolean {
return group.cohort > 0 && group.cells.every(c => c.status === 'published');
}
export interface BarSegment {
category: DestinationCategory;
pupils: number;
/** Exact width from the count — never the rounded percentage. */
widthPct: number;
/** Rounded value for the segment label. */
labelPct: number;
}
export function toBarSegments(group: DestinationGroup): BarSegment[] {
if (!canRenderBar(group)) {
throw new Error(
'toBarSegments: refusing to draw a bar for a group with suppressed categories — '
+ 'the gap would disclose the withheld figure (R1).',
);
}
const byCategory = new Map(group.cells.map(c => [c.category, c]));
return CATEGORY_ORDER.flatMap(category => {
const cell = byCategory.get(category);
if (!cell || cell.pupils === null) return [];
const widthPct = (cell.pupils / group.cohort) * 100;
return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }];
});
}
export const CATEGORY_LABELS: Record<DestinationCategory, string> = {
school_sixth_form: 'State-funded school sixth form',
sixth_form_college: 'Sixth-form college',
further_education: 'FE and other colleges',
other_education: 'Other education destination',
apprenticeship: 'Apprenticeship',
employment: 'Employment',
not_sustained: 'Not recorded as a sustained destination',
not_captured: 'Activity not captured',
};
export const CARD_QUESTIONS: Record<CardGroup, { question: string; hint: string }> = {
academic: { question: 'Do leavers stay on an academic route?', hint: 'a school sixth form or a sixth-form college' },
college: { question: 'Or move to a college?', hint: 'an FE or other college' },
work: { question: 'Or straight into work?', hint: 'an apprenticeship or a job' },
};
- Step 4: Run test to verify it passes
Run: cd nextjs-app && npx jest __tests__/lib/destinations.test.ts
Expected: PASS, 12 tests
- Step 5: Typecheck and commit
cd nextjs-app && npm run typecheck
cd .. && git add nextjs-app/lib/destinations.ts nextjs-app/__tests__/lib/destinations.test.ts
git commit -m "feat(destinations): the disclosure rules, as executable guards"
Task 2: Destination colour tokens
Six tokens, both themes. The absence is neutral plus a hatch rather than a colour, which is both a factual point (it isn't a bad outcome) and the secondary encoding that rescues a failing CVD pair.
Files:
- Modify:
nextjs-app/app/globals.css(:root, theprefers-color-schemeblock, and the[data-theme="dark"]block if one exists) - Test:
nextjs-app/__tests__/components/darkThemeSafety.test.ts
Interfaces:
-
Consumes: nothing
-
Produces: CSS custom properties
--dest-sixthform,--dest-sfcollege,--dest-fecollege,--dest-apprentice,--dest-employment,--dest-none,--dest-none-hatch -
Step 1: Read the existing token blocks
Run: grep -n "\-\-series-1\|prefers-color-scheme" nextjs-app/app/globals.css
Add the new tokens immediately after the --series-* group in each block so the palette stays in one place.
- Step 2: Write the failing test
Append to nextjs-app/__tests__/components/darkThemeSafety.test.ts:
describe('destination tokens', () => {
const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
const tokens = [
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
'--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch',
];
it('defines every destination token in the light palette', () => {
const root = css.slice(css.indexOf(':root {'), css.indexOf('@media (prefers-color-scheme: dark)'));
tokens.forEach(t => expect(root).toContain(t + ':'));
});
it('redefines every destination token for dark', () => {
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
tokens.forEach(t => expect(dark).toContain(t + ':'));
});
});
- Step 3: Run test to verify it fails
Run: cd nextjs-app && npx jest __tests__/components/darkThemeSafety.test.ts
Expected: FAIL — light palette missing --dest-sixthform:
- Step 4: Add the tokens
In the :root block:
/* ── Destination measures ───────────────────────────────────────────
Education is one hue in three steps (school-like → college-like) so the
three education destinations read as one family; apprenticeship and
employment are separate hues. The absence is neutral and hatched, never
a colour — "activity not captured" includes independent schools and
moving abroad, so rendering it as a bad outcome would be wrong. The
hatch is also what rescues the neutral/blue pair, which fails CVD
separation at ΔE 7.6 as flat fills. Every other adjacent pair clears
ΔE 10.9 under protanopia. */
--dest-sixthform: #0F766E;
--dest-sfcollege: #4A9E96;
--dest-fecollege: #7CBFB8;
--dest-apprentice: #806200;
--dest-employment: #2F6F8F;
--dest-none: #6B7580;
--dest-none-hatch: rgba(107, 117, 128, 0.34);
In the @media (prefers-color-scheme: dark) block (and the [data-theme="dark"] block if present):
--dest-sixthform: #5FC7BB;
--dest-sfcollege: #3E9B92;
--dest-fecollege: #2A716B;
--dest-apprentice: #EFC658;
--dest-employment: #8FB4D9;
--dest-none: #8B9AA1;
--dest-none-hatch: rgba(139, 154, 161, 0.34);
- Step 5: Run test to verify it passes
Run: cd nextjs-app && npx jest __tests__/components/darkThemeSafety.test.ts
Expected: PASS
- Step 6: Commit
git add nextjs-app/app/globals.css nextjs-app/__tests__/components/darkThemeSafety.test.ts
git commit -m "feat(destinations): colour tokens, with the absence hatched not coloured"
Task 3: The destinations tap
A separate extractor from tap-uk-ees. That tap downloads a release ZIP and reads a CSV inside it; the destinations files carry every breakdown we don't want, so this one POSTs to the query API and pages.
Files:
- Create:
pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml - Create:
pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py - Create:
pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py - Modify:
pipeline/meltano.yml - Modify:
pipeline/dags/school_data_pipeline.py:160-196 - Test:
pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py
Interfaces:
-
Consumes: nothing
-
Produces: raw tables
ees_ks4_destinationsandees_ks5_destinations, columnsurn,time_period,pupil_group,destination_measure,cohort_pupils,pupils_raw,percentage_raw— the last two as text, sentinel preserved. -
Step 1: Write the failing test
Create pipeline/plugins/extractors/tap-uk-ees-destinations/tests/test_tap.py:
"""The tap's only job that can be tested without the network: mapping an API
row to a Singer record without destroying the suppression sentinel."""
from tap_uk_ees_destinations.tap import row_to_record, DESTINATION_SLUGS, PUPIL_GROUP_SLUGS
def test_published_row_keeps_its_numbers_as_text():
row = {
"timePeriod": {"period": "2022/2023"},
"geographicLevel": "SCH",
"locations": {"SCH": "IXn5B"},
"filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"},
"values": {"Poghe": "264", "1roqi": "182", "dPjk0": "68.9"},
}
rec = row_to_record(row, urn_by_location={"IXn5B": "137083"})
assert rec["urn"] == "137083"
assert rec["time_period"] == "202223"
assert rec["destination_measure"] == "school_sixth_form"
assert rec["pupil_group"] == "all"
assert rec["cohort_pupils"] == "264"
assert rec["pupils_raw"] == "182"
assert rec["percentage_raw"] == "68.9"
def test_suppressed_row_preserves_the_c_sentinel():
row = {
"timePeriod": {"period": "2022/2023"},
"geographicLevel": "SCH",
"locations": {"SCH": "IXn5B"},
"filters": {"wYXbx": "eLsdu", "9ss4v": "OvPnC"},
"values": {"Poghe": "41", "1roqi": "c", "dPjk0": "c"},
}
rec = row_to_record(row, urn_by_location={"IXn5B": "137083"})
assert rec["pupils_raw"] == "c", "the sentinel must survive extraction"
assert rec["percentage_raw"] == "c"
assert rec["pupil_group"] == "disadvantaged"
def test_national_rows_are_kept_with_a_null_urn():
"""The England reference lives in the same response. It is kept, with urn
None, so fact_destination_national has something to read."""
row = {
"timePeriod": {"period": "2022/2023"},
"geographicLevel": "NAT",
"locations": {"NAT": "dP0Zw"},
"filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"},
"values": {"Poghe": "500000", "1roqi": "190000", "dPjk0": "38.0"},
}
rec = row_to_record(row, urn_by_location={})
assert rec is not None
assert rec["urn"] is None
assert rec["percentage_raw"] == "38.0"
def test_other_geographic_levels_are_dropped():
"""Local authority, district, region and constituency rows are noise here."""
row = {
"timePeriod": {"period": "2022/2023"},
"geographicLevel": "LA",
"locations": {"LA": "u9Oo4", "NAT": "dP0Zw"},
"filters": {"wYXbx": "DCz1Q", "9ss4v": "p9WRS"},
"values": {"Poghe": "1", "1roqi": "1", "dPjk0": "1"},
}
assert row_to_record(row, urn_by_location={}) is None
def test_every_slug_maps_to_one_filter_id():
assert len(set(DESTINATION_SLUGS.values())) == len(DESTINATION_SLUGS)
assert set(PUPIL_GROUP_SLUGS.values()) == {"all", "disadvantaged", "other"}
- Step 2: Run test to verify it fails
Run: cd pipeline/plugins/extractors/tap-uk-ees-destinations && uv run --with singer-sdk --with requests pytest tests/ -v
Expected: FAIL — ModuleNotFoundError: tap_uk_ees_destinations
- Step 3: Write the tap
Create pipeline/plugins/extractors/tap-uk-ees-destinations/pyproject.toml:
[project]
name = "tap-uk-ees-destinations"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["singer-sdk>=0.40", "requests>=2.31"]
[project.scripts]
tap-uk-ees-destinations = "tap_uk_ees_destinations.tap:TapUKEESDestinations.cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Create pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/__init__.py (empty file).
Create pipeline/plugins/extractors/tap-uk-ees-destinations/tap_uk_ees_destinations/tap.py:
"""EES destinations tap — KS4 and 16-18 destination measures, school level.
Separate from tap-uk-ees on purpose. That tap pulls a release ZIP and reads a
CSV inside it; the destinations files carry sex, ethnicity, FSM status, prior
attainment and SEN in the same table, so the whole-file route would download
millions of rows to keep a few hundred thousand. The query API filters server
side.
The one thing this tap must not do is tidy up the data. EES writes `c` where a
figure is withheld, and the categories sum to the cohort — so turning `c` into
NULL here would let a downstream sum reconstruct exactly what DfE suppressed.
Counts and percentages are emitted as TEXT, sentinel intact.
"""
from __future__ import annotations
import requests
from singer_sdk import Stream, Tap
from singer_sdk import typing as th
API_BASE = "https://api.education.gov.uk/statistics/v1"
TIMEOUT = 180
PAGE_SIZE = 10000
KS4_DATASET = "019d4f41-22d1-71b2-a1a7-f3b91026815b"
KS5_DATASET = "019d4e73-6440-7523-b60c-bfab1ad4a30d"
# Filter option ids, read from each dataset's /meta. KS4 and 16-18 use
# different ids for the same concepts, so they are declared separately.
KS4_DESTINATION_SLUGS = {
"DCz1Q": "school_sixth_form",
"eLsdu": "sixth_form_college",
"o2MJm": "further_education",
"b7v6t": "other_education",
"mlKo9": "apprenticeship",
"QIJEw": "employment",
"RZrek": "not_sustained",
"1j7Ui": "not_captured",
"WiEl2": "agg_sustained_education",
"EfSAq": "agg_sustained_all",
}
KS4_PUPIL_GROUP_SLUGS = {"p9WRS": "all", "OvPnC": "disadvantaged", "7VmdX": "other"}
# Sex=Total and characteristic topic=Total. Leaving these unpinned returns
# every breakdown crossed with every other — 45 rows where 9 are wanted.
KS4_PINNED = ["X542f", "jHdaA"]
KS5_DESTINATION_SLUGS = {
"dkyu0": "higher_education",
"QIB6w": "school_sixth_form",
"9c3u4": "sixth_form_college",
"S4UgV": "further_education",
"EfYDq": "other_education",
"PaLTe": "apprenticeship",
"9c8k4": "employment",
"Wi6R2": "not_sustained",
"o2l7m": "not_captured",
"wBXtb": "agg_sustained_education",
"o2c4m": "agg_sustained_all",
}
KS5_PUPIL_GROUP_SLUGS = {"Y0PuH": "all", "HTeez": "disadvantaged", "CnSVI": "other"}
KS5_PINNED: list[str] = []
# Indicator ids are shared across both datasets.
IND_COHORT = "Poghe"
IND_PUPILS = "1roqi"
IND_PERCENT = "dPjk0"
DESTINATION_SLUGS = KS4_DESTINATION_SLUGS
PUPIL_GROUP_SLUGS = KS4_PUPIL_GROUP_SLUGS
def _period_to_time_period(period: str) -> str:
"""'2022/2023' -> '202223', matching the convention the other marts use."""
start, end = period.split("/")
return start + end[-2:]
def row_to_record(
row: dict,
urn_by_location: dict[str, str],
destination_slugs: dict[str, str] | None = None,
pupil_group_slugs: dict[str, str] | None = None,
) -> dict | None:
"""Map one API row to a Singer record, or None if it is not school level."""
destination_slugs = destination_slugs or KS4_DESTINATION_SLUGS
pupil_group_slugs = pupil_group_slugs or KS4_PUPIL_GROUP_SLUGS
# Read geographicLevel, not the locations keys: a school row also carries
# NAT, LA and REG entries for its parents, so "NAT in locations" is true
# for every row in the file and would let LA rows through as national ones.
level = row.get("geographicLevel")
if level == "SCH":
urn = urn_by_location.get(row.get("locations", {}).get("SCH"))
if not urn:
return None
elif level == "NAT":
urn = None
else:
return None
filters = row.get("filters", {})
destination = next(
(slug for fid, slug in destination_slugs.items() if fid in filters.values()), None
)
group = next(
(slug for fid, slug in pupil_group_slugs.items() if fid in filters.values()), None
)
if destination is None or group is None:
return None
values = row.get("values", {})
return {
"urn": urn,
"time_period": _period_to_time_period(row["timePeriod"]["period"]),
"pupil_group": group,
"destination_measure": destination,
"cohort_pupils": values.get(IND_COHORT),
"pupils_raw": values.get(IND_PUPILS),
"percentage_raw": values.get(IND_PERCENT),
}
def fetch_urn_by_location(dataset_id: str) -> dict[str, str]:
"""Location id -> URN, from the dataset's meta."""
resp = requests.get(f"{API_BASE}/data-sets/{dataset_id}/meta", timeout=TIMEOUT)
resp.raise_for_status()
for group in resp.json().get("locations", []):
if group.get("level", {}).get("code") == "SCH":
return {o["id"]: o["urn"] for o in group.get("options", []) if o.get("urn")}
return {}
def fetch_time_periods(dataset_id: str) -> list[str]:
resp = requests.get(f"{API_BASE}/data-sets/{dataset_id}/meta", timeout=TIMEOUT)
resp.raise_for_status()
return [t["period"] for t in resp.json().get("timePeriods", [])]
class DestinationsStream(Stream):
"""One stream per dataset. Pages the query API, one time period at a time."""
_dataset_id: str
_destination_slugs: dict[str, str]
_pupil_group_slugs: dict[str, str]
_pinned: list[str]
schema = th.PropertiesList(
th.Property("urn", th.StringType),
th.Property("time_period", th.StringType),
th.Property("pupil_group", th.StringType),
th.Property("destination_measure", th.StringType),
th.Property("cohort_pupils", th.StringType),
th.Property("pupils_raw", th.StringType),
th.Property("percentage_raw", th.StringType),
).to_dict()
primary_keys = ["urn", "time_period", "pupil_group", "destination_measure"]
replication_key = None
def get_records(self, context):
urn_by_location = fetch_urn_by_location(self._dataset_id)
self.logger.info("%s: %d school locations", self.name, len(urn_by_location))
criteria = [{"filters": {"in": list(self._destination_slugs)}},
{"filters": {"in": list(self._pupil_group_slugs)}}]
for pinned in self._pinned:
criteria.append({"filters": {"in": [pinned]}})
for period in fetch_time_periods(self._dataset_id):
page = 1
while True:
body = {
"criteria": {"and": criteria + [
{"timePeriods": {"in": [{"period": period, "code": "AY"}]}},
]},
"indicators": [IND_COHORT, IND_PUPILS, IND_PERCENT],
"page": page,
"pageSize": PAGE_SIZE,
}
resp = requests.post(
f"{API_BASE}/data-sets/{self._dataset_id}/query",
json=body, timeout=TIMEOUT,
)
resp.raise_for_status()
payload = resp.json()
for row in payload.get("results", []):
record = row_to_record(
row, urn_by_location,
self._destination_slugs, self._pupil_group_slugs,
)
if record is not None:
yield record
paging = payload.get("paging", {})
if page >= paging.get("totalPages", 1):
break
page += 1
class KS4DestinationsStream(DestinationsStream):
name = "ees_ks4_destinations"
_dataset_id = KS4_DATASET
_destination_slugs = KS4_DESTINATION_SLUGS
_pupil_group_slugs = KS4_PUPIL_GROUP_SLUGS
_pinned = KS4_PINNED
class KS5DestinationsStream(DestinationsStream):
name = "ees_ks5_destinations"
_dataset_id = KS5_DATASET
_destination_slugs = KS5_DESTINATION_SLUGS
_pupil_group_slugs = KS5_PUPIL_GROUP_SLUGS
_pinned = KS5_PINNED
class TapUKEESDestinations(Tap):
name = "tap-uk-ees-destinations"
config_jsonschema = th.PropertiesList().to_dict()
def discover_streams(self):
return [KS4DestinationsStream(self), KS5DestinationsStream(self)]
if __name__ == "__main__":
TapUKEESDestinations.cli()
- Step 4: Run test to verify it passes
Run: cd pipeline/plugins/extractors/tap-uk-ees-destinations && uv run --with singer-sdk --with requests --with pytest pytest tests/ -v
Expected: PASS, 4 tests
- Step 5: Register the tap with Meltano
In pipeline/meltano.yml, after the tap-uk-ees block in extractors::
- name: tap-uk-ees-destinations
namespace: uk_ees_destinations
pip_url: ./plugins/extractors/tap-uk-ees-destinations
executable: tap-uk-ees-destinations
settings: []
- Step 6: Wire it into the annual DAG
In pipeline/dags/school_data_pipeline.py, inside the extract_ees TaskGroup (around line 174), add a second operator and make the dbt selector cover the new models:
extract_ees_destinations = BashOperator(
task_id="extract_ees_destinations",
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-ees-destinations target-postgres",
)
extract_ees >> extract_ees_destinations
And extend the dbt_build_ees selector with stg_ees_ks4_destinations+ stg_ees_ks5_destinations+.
- Step 7: Commit
git add pipeline/plugins/extractors/tap-uk-ees-destinations pipeline/meltano.yml pipeline/dags/school_data_pipeline.py
git commit -m "feat(destinations): a tap that preserves the suppression sentinel"
Task 4: Staging models
Files:
- Create:
pipeline/transform/models/staging/stg_ees_ks4_destinations.sql - Create:
pipeline/transform/models/staging/stg_ees_ks5_destinations.sql - Modify:
pipeline/transform/models/staging/_stg_sources.yml
Interfaces:
-
Consumes: raw tables from Task 3
-
Produces:
stg_ees_ks4_destinations/stg_ees_ks5_destinationswith columnsurn(int),year(int),pupil_group(text),destination_measure(text),cohort_pupils(int),pupils(int, null when withheld),percentage(numeric, null when withheld),status(text:published|suppressed|not_applicable) -
Step 1: Declare the sources
In pipeline/transform/models/staging/_stg_sources.yml, under tables::
- name: ees_ks4_destinations
description: >
KS4 leavers destinations, school level, long format — one row per
URN × year × pupil group × destination measure. pupils_raw and
percentage_raw are TEXT and may hold the 'c' suppression sentinel;
they must never be passed through safe_numeric.
- name: ees_ks5_destinations
description: >
16-18 study leavers destinations, same grain and same suppression
caveat as ees_ks4_destinations.
- Step 2: Write the KS4 staging model
Create pipeline/transform/models/staging/stg_ees_ks4_destinations.sql:
{{ config(materialized='table') }}
-- Staging model: KS4 leavers destinations, school level.
--
-- DELIBERATELY DOES NOT USE safe_numeric. That macro maps every EES sentinel
-- (z, c, x, q, u) to NULL, which is right for attainment — there, "suppressed"
-- and "not applicable" are equally unrenderable. Here they are different
-- claims: one prints "withheld", the other prints nothing. Collapsing them
-- would also let a downstream sum reconstruct a withheld figure, because the
-- destination categories add up to the cohort.
--
-- See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
with source as (
select * from {{ source('raw', 'ees_ks4_destinations') }}
-- National rows carry a null urn and feed fact_destination_national.
where (urn is null or urn ~ '^[0-9]+$')
and time_period ~ '^[0-9]+$'
)
select
case when urn ~ '^[0-9]+$' then cast(trim(urn) as integer) end as urn,
cast(trim(time_period) as integer) as year,
trim(pupil_group) as pupil_group,
trim(destination_measure) as destination_measure,
case when cohort_pupils ~ '^[0-9]+$'
then cast(cohort_pupils as integer) end as cohort_pupils,
case when pupils_raw ~ '^[0-9]+$'
then cast(pupils_raw as integer) end as pupils,
case when percentage_raw ~ '^-?[0-9]+(\.[0-9]+)?$'
then cast(percentage_raw as numeric) end as percentage,
case
when pupils_raw ~ '^[0-9]+$' then 'published'
when lower(trim(pupils_raw)) = 'c' then 'suppressed'
else 'not_applicable'
end as status
from source
- Step 3: Write the 16-18 staging model
Create pipeline/transform/models/staging/stg_ees_ks5_destinations.sql — identical body, reading source('raw', 'ees_ks5_destinations'), with the header comment naming 16-18 study leavers. Repeat the full SQL rather than abstracting it; the two sources drift independently and a shared macro would couple their refresh cadences.
- Step 4: Verify the SQL compiles by eye against the sibling models
Run: diff <(sed -n '1,12p' pipeline/transform/models/staging/stg_ees_ks4.sql) <(sed -n '1,12p' pipeline/transform/models/staging/stg_ees_ks4_destinations.sql)
Confirm the header comment style matches, and confirm by inspection that safe_numeric appears nowhere:
Run: grep -c safe_numeric pipeline/transform/models/staging/stg_ees_ks*_destinations.sql
Expected: 0 for both files
- Step 5: Commit
git add pipeline/transform/models/staging/stg_ees_ks4_destinations.sql \
pipeline/transform/models/staging/stg_ees_ks5_destinations.sql \
pipeline/transform/models/staging/_stg_sources.yml
git commit -m "feat(destinations): staging models that keep 'withheld' distinct from 'absent'"
Task 5: Marts and the disclosure tests
Files:
- Create:
pipeline/transform/models/marts/fact_ks4_destinations.sql - Create:
pipeline/transform/models/marts/fact_ks5_destinations.sql - Create:
pipeline/transform/models/marts/fact_destination_national.sql - Create:
pipeline/transform/tests/assert_destinations_no_derived_remainder.sql - Create:
pipeline/transform/tests/assert_destinations_group_masking.sql - Create:
pipeline/transform/tests/assert_destination_status_null_agreement.sql - Modify:
pipeline/transform/models/marts/_marts_schema.yml
Interfaces:
-
Consumes:
stg_ees_ks4_destinations,stg_ees_ks5_destinations,dim_school -
Produces:
fact_ks4_destinations/fact_ks5_destinations(urn,year,pupil_group,destination_measure,cohort_pupils,pupils,percentage,status) andfact_destination_national(same, withouturn, plusphase) -
Step 1: Write the KS4 mart with R3 masking
Create pipeline/transform/models/marts/fact_ks4_destinations.sql:
{{ config(materialized='table') }}
-- Mart: KS4 leavers destinations — one row per URN × year × pupil group ×
-- destination measure.
--
-- Long format, unlike the wide fact_ks4_performance next door. pupil_group is
-- a real third dimension, so going wide would need three sets of every column,
-- and the disclosure tests below are far easier to write over rows.
--
-- R3 is applied HERE rather than in the API: where a category is suppressed
-- for the disadvantaged group it is masked for the other-pupils group too,
-- because the two partition the whole and the all-pupils figure is published.
-- DfE already does this in 493 of 498 cases; this closes the remainder so no
-- consumer can reach an unmasked combination.
with staged as (
select s.*
from {{ ref('stg_ees_ks4_destinations') }} s
inner join {{ ref('dim_school') }} d on d.urn = s.urn
),
-- Categories withheld for disadvantaged pupils at this school and year.
masked as (
select distinct urn, year, destination_measure
from staged
where pupil_group = 'disadvantaged' and status = 'suppressed'
)
select
s.urn,
s.year,
s.pupil_group,
s.destination_measure,
s.cohort_pupils,
case when m.urn is not null and s.pupil_group = 'other'
then null else s.pupils end as pupils,
case when m.urn is not null and s.pupil_group = 'other'
then null else s.percentage end as percentage,
case when m.urn is not null and s.pupil_group = 'other'
then 'suppressed' else s.status end as status
from staged s
left join masked m
on m.urn = s.urn
and m.year = s.year
and m.destination_measure = s.destination_measure
- Step 2: Write the 16-18 mart
Create pipeline/transform/models/marts/fact_ks5_destinations.sql — the same body reading stg_ees_ks5_destinations, with a header naming 16-18 study leavers.
- Step 3: Write the national reference mart
Create pipeline/transform/models/marts/fact_destination_national.sql:
{{ config(materialized='table') }}
-- Mart: England destination measures by pupil group, for the page's national
-- reference. Kept separate from the school facts so the section's England bar
-- can repoint with the cohort switch — comparing a school's disadvantaged
-- pupils against the national all-pupils figure would flatter or damn the
-- school for its intake rather than its work.
select 'ks4' as phase, year, pupil_group, destination_measure,
cohort_pupils, pupils, percentage, status
from {{ ref('stg_ees_ks4_destinations') }}
where urn is null
union all
select 'ks5' as phase, year, pupil_group, destination_measure,
cohort_pupils, pupils, percentage, status
from {{ ref('stg_ees_ks5_destinations') }}
where urn is null
- Step 4: Write the R1 disclosure test
Create pipeline/transform/tests/assert_destinations_no_derived_remainder.sql:
-- R1 GUARD. Fails if a school/year/group has exactly one suppressed category
-- while also publishing the cohort total — the combination that lets the
-- withheld figure be recovered by subtraction.
--
-- This does not mean the mart is wrong: DfE publishes exactly this, and the
-- mart's job is to carry it faithfully. The test exists so that the condition
-- is visible and counted, and so that any consumer added later has to
-- acknowledge it. The API and the frontend are what must refuse to render the
-- remainder; this test is the tripwire that says how often the situation
-- arises. It is configured to warn, not error.
{{ config(severity='warn') }}
select
urn, year, pupil_group,
count(*) filter (where status = 'suppressed') as suppressed_categories
from {{ ref('fact_ks4_destinations') }}
where destination_measure not like 'agg_%'
group by urn, year, pupil_group
having count(*) filter (where status = 'suppressed') = 1
- Step 5: Write the R3 masking test
Create pipeline/transform/tests/assert_destinations_group_masking.sql:
-- R3 GUARD. Fails if a category is suppressed for disadvantaged pupils but
-- still published for the other-pupils group — the two partition the whole, so
-- publishing both alongside the all-pupils figure recovers the withheld cell.
select d.urn, d.year, d.destination_measure
from {{ ref('fact_ks4_destinations') }} d
inner join {{ ref('fact_ks4_destinations') }} o
on o.urn = d.urn
and o.year = d.year
and o.destination_measure = d.destination_measure
and o.pupil_group = 'other'
where d.pupil_group = 'disadvantaged'
and d.status = 'suppressed'
and o.status = 'published'
- Step 6: Write the status agreement test
Create pipeline/transform/tests/assert_destination_status_null_agreement.sql:
-- pupils must be null wherever status is not 'published', and never null where
-- it is. This is what stops a later coalesce or a wide-format refactor turning
-- "withheld" into a zero.
select urn, year, pupil_group, destination_measure, status, pupils
from {{ ref('fact_ks4_destinations') }}
where (status <> 'published' and pupils is not null)
or (status = 'published' and pupils is null)
- Step 7: Document the marts
In pipeline/transform/models/marts/_marts_schema.yml, add entries for the three new models with a description for each and tests: [not_null] on urn, year, pupil_group, destination_measure, status, following the existing entries' shape.
- Step 8: Confirm no test references safe_numeric and commit
Run: grep -rn safe_numeric pipeline/transform/models/marts/fact_ks*_destinations.sql pipeline/transform/models/marts/fact_destination_national.sql
Expected: no output
git add pipeline/transform/models/marts/fact_ks4_destinations.sql \
pipeline/transform/models/marts/fact_ks5_destinations.sql \
pipeline/transform/models/marts/fact_destination_national.sql \
pipeline/transform/tests/assert_destinations_*.sql \
pipeline/transform/tests/assert_destination_status_null_agreement.sql \
pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(destinations): marts, with R3 masking applied at the boundary"
Task 6: Backend models, loader and API
Files:
- Modify:
backend/models.py(append afterFactFinance, around line 265) - Modify:
backend/data_loader.py:819-830(_empty_supplementary) and:833-958(get_supplementary_data_batch) - Modify:
backend/app.py:905-920(theget_school_detailsreturn block) - Test:
backend/tests/test_destinations_api.py
Interfaces:
-
Consumes:
fact_ks4_destinations,fact_ks5_destinations,fact_destination_national -
Produces:
destinationskey onGET /api/schools/{urn}, shaped{ ks4: {...} | null, ks5: {...} | null }; each phase{ cohort_year, groups: { all, disadvantaged, other } }; each group{ cohort, categories: [{ category, pupils, percentage, status }], aggregates: {...} } -
Step 1: Write the failing test
Create backend/tests/test_destinations_api.py:
"""The serialiser's contract: it carries suppression through, and never emits a
total that closes a gap left by a suppressed category."""
import pytest
from backend.data_loader import _destinations_block
def _row(group, measure, pupils, status, cohort=180, percentage=None):
return {
"pupil_group": group, "destination_measure": measure,
"pupils": pupils, "percentage": percentage,
"status": status, "cohort_pupils": cohort, "year": 202223,
}
def test_suppressed_category_serialises_as_suppressed_with_null_pupils():
rows = [
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
_row("all", "sixth_form_college", None, "suppressed"),
]
block = _destinations_block(rows)
cats = {c["category"]: c for c in block["groups"]["all"]["categories"]}
assert cats["sixth_form_college"]["status"] == "suppressed"
assert cats["sixth_form_college"]["pupils"] is None
assert cats["sixth_form_college"]["percentage"] is None
def test_no_closing_total_is_emitted_for_a_partially_suppressed_group():
rows = [
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
_row("all", "sixth_form_college", None, "suppressed"),
_row("all", "further_education", 61, "published", percentage=33.9),
_row("all", "apprenticeship", 8, "published", percentage=4.4),
_row("all", "employment", 6, "published", percentage=3.3),
_row("all", "not_sustained", 5, "published", percentage=2.8),
_row("all", "not_captured", 4, "published", percentage=2.2),
]
block = _destinations_block(rows)
group = block["groups"]["all"]
published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None)
for value in group["aggregates"].values():
if value is None or value.get("pupils") is None:
continue
assert value["pupils"] != group["cohort"] - published, (
"an aggregate that equals the residual identifies the suppressed cell"
)
def test_cohort_year_is_reported_so_the_page_can_date_itself():
block = _destinations_block([_row("all", "school_sixth_form", 75, "published")])
assert block["cohort_year"] == "2022/23"
def test_empty_rows_yield_none_not_an_empty_shell():
assert _destinations_block([]) is None
- Step 2: Run test to verify it fails
Run: cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/test_destinations_api.py -v
Expected: FAIL — ImportError: cannot import name '_destinations_block'
- Step 3: Add the SQLAlchemy models
Append to backend/models.py after FactFinance:
class FactKs4Destinations(Base):
"""KS4 leavers destinations — one row per URN, year, pupil group, measure."""
__tablename__ = "fact_ks4_destinations"
__table_args__ = (
Index("ix_ks4_dest_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
pupil_group = Column(String(20), primary_key=True)
destination_measure = Column(String(40), primary_key=True)
cohort_pupils = Column(Integer)
pupils = Column(Integer)
percentage = Column(Float)
# 'published' | 'suppressed' | 'not_applicable'. Never collapse this to a
# null check: a suppressed cell prints "withheld", an absent one prints
# nothing, and the difference is what keeps the disclosure rules workable.
status = Column(String(20))
class FactKs5Destinations(Base):
"""16-18 study leavers destinations — same grain as FactKs4Destinations."""
__tablename__ = "fact_ks5_destinations"
__table_args__ = (
Index("ix_ks5_dest_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
pupil_group = Column(String(20), primary_key=True)
destination_measure = Column(String(40), primary_key=True)
cohort_pupils = Column(Integer)
pupils = Column(Integer)
percentage = Column(Float)
status = Column(String(20))
- Step 4: Write the serialiser
Add to backend/data_loader.py, above _empty_supplementary:
_AGGREGATE_MEASURES = {"agg_sustained_education", "agg_sustained_all"}
def _format_cohort_year(year: int | None) -> str | None:
"""202223 -> '2022/23'. The page must date its own cohort: destinations run
two GCSE years behind the results shown above them."""
if not year:
return None
text = str(year)
return f"{text[:4]}/{text[6:8]}" if len(text) == 8 else f"{text[:4]}/{text[4:6]}"
def _destinations_block(rows: list[dict]) -> dict | None:
"""Shape destination rows for one phase into the API's block.
Carries `status` through untouched and emits no computed totals. The only
aggregates present are ones DfE published itself; the frontend decides
whether they are safe to show (see lib/destinations.ts, R2).
"""
if not rows:
return None
latest_year = max(r["year"] for r in rows if r.get("year") is not None)
rows = [r for r in rows if r.get("year") == latest_year]
groups: dict[str, dict] = {}
for row in rows:
group = groups.setdefault(
row["pupil_group"],
{"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}},
)
measure = row["destination_measure"]
cell = {
"category": measure,
"pupils": row.get("pupils"),
"percentage": row.get("percentage"),
"status": row.get("status"),
}
if measure in _AGGREGATE_MEASURES:
group["aggregates"][measure.removeprefix("agg_")] = cell
else:
group["categories"].append(cell)
if not groups:
return None
return {"cohort_year": _format_cohort_year(latest_year), "groups": groups}
- Step 5: Run test to verify it passes
Run: cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/test_destinations_api.py -v
Expected: PASS, 4 tests
- Step 6: Wire it into the batch loader
In backend/data_loader.py, add "destinations": None to the dict _empty_supplementary returns. Then add two query functions inside get_supplementary_data_batch, following the _ofsted / _census pattern exactly, each wrapped in _safe:
# Destinations — KS4 and 16-18, all years; _destinations_block picks the
# latest and shapes the groups.
def _destinations():
from collections import defaultdict
per_urn_ks4 = defaultdict(list)
for r in (db.query(FactKs4Destinations)
.filter(FactKs4Destinations.urn.in_(urns)).all()):
per_urn_ks4[r.urn].append({
"year": r.year, "pupil_group": r.pupil_group,
"destination_measure": r.destination_measure,
"cohort_pupils": r.cohort_pupils, "pupils": r.pupils,
"percentage": r.percentage, "status": r.status,
})
per_urn_ks5 = defaultdict(list)
for r in (db.query(FactKs5Destinations)
.filter(FactKs5Destinations.urn.in_(urns)).all()):
per_urn_ks5[r.urn].append({
"year": r.year, "pupil_group": r.pupil_group,
"destination_measure": r.destination_measure,
"cohort_pupils": r.cohort_pupils, "pupils": r.pupils,
"percentage": r.percentage, "status": r.status,
})
for urn in urns:
ks4 = _destinations_block(per_urn_ks4.get(urn, []))
ks5 = _destinations_block(per_urn_ks5.get(urn, []))
result[urn]["destinations"] = (
{"ks4": ks4, "ks5": ks5} if (ks4 or ks5) else None
)
_safe(_destinations)
Import FactKs4Destinations and FactKs5Destinations alongside the other mart models at the top of the file.
- Step 7: Expose it on the endpoint
In backend/app.py, in the get_school_details return dict, after "finance": supplementary.get("finance"),:
"destinations": supplementary.get("destinations"),
- Step 8: Run the full backend suite and commit
Run: cd /Users/tudor/projects/school_compare && uv run --with fastapi --with 'httpx==0.27.0' --with sqlalchemy --with pandas --with pytest --with pydantic-settings python -m pytest backend/tests/ -q
Expected: all pass
git add backend/models.py backend/data_loader.py backend/app.py backend/tests/test_destinations_api.py
git commit -m "feat(destinations): serve destinations without closing the gaps"
Task 7: Frontend types and section flags
Files:
- Modify:
nextjs-app/lib/types.ts - Modify:
nextjs-app/lib/schoolSections.ts:12-46(the interfaces) and:48-95(computeSchoolFlags) - Test:
nextjs-app/__tests__/lib/schoolSections.destinations.test.ts
Interfaces:
-
Consumes:
lib/destinations.tstypes from Task 1 -
Produces:
SchoolDestinationstype;SchoolFlags.hasKs4Destinationsand.hasKs5Destinations -
Step 1: Write the failing test
Create nextjs-app/__tests__/lib/schoolSections.destinations.test.ts:
import { computeSchoolFlags } from '@/lib/schoolSections';
const base = {
schoolInfo: { urn: 1, school_name: 'X', phase: 'Secondary', has_sixth_form: true } as any,
yearlyData: [], absenceData: null, census: null, deprivation: null, finance: null,
};
const ks4Only = {
ks4: { cohort_year: '2022/23', groups: { all: { cohort: 180, categories: [], aggregates: {} } } },
ks5: null,
} as any;
it('flags KS4 destinations when the block is present', () => {
const flags = computeSchoolFlags({ ...base, destinations: ks4Only });
expect(flags.hasKs4Destinations).toBe(true);
expect(flags.hasKs5Destinations).toBe(false);
});
it('flags neither when the block is absent', () => {
const flags = computeSchoolFlags({ ...base, destinations: null });
expect(flags.hasKs4Destinations).toBe(false);
expect(flags.hasKs5Destinations).toBe(false);
});
it('does not flag a group with no categories as renderable', () => {
const empty = { ks4: { cohort_year: '2022/23', groups: {} }, ks5: null } as any;
expect(computeSchoolFlags({ ...base, destinations: empty }).hasKs4Destinations).toBe(false);
});
- Step 2: Run test to verify it fails
Run: cd nextjs-app && npx jest __tests__/lib/schoolSections.destinations.test.ts
Expected: FAIL — hasKs4Destinations is undefined
- Step 3: Add the types
In nextjs-app/lib/types.ts:
import type { DestinationCell, PupilGroup } from './destinations';
export interface DestinationGroupPayload {
cohort: number | null;
categories: DestinationCell[];
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
}
export interface DestinationPhase {
cohort_year: string | null;
groups: Partial<Record<PupilGroup, DestinationGroupPayload>>;
}
export interface SchoolDestinations {
ks4: DestinationPhase | null;
ks5: DestinationPhase | null;
}
- Step 4: Extend the flags
In nextjs-app/lib/schoolSections.ts, add destinations: SchoolDestinations | null to SchoolFlagsInput, add hasKs4Destinations: boolean and hasKs5Destinations: boolean to SchoolFlags, destructure destinations in computeSchoolFlags, and compute:
// A phase counts as present only if some group actually carries categories —
// a block with an empty groups map is a pipeline artefact, not a section.
const phaseHasContent = (phase: DestinationPhase | null | undefined) =>
!!phase && Object.values(phase.groups ?? {}).some(g => (g?.categories?.length ?? 0) > 0);
const hasKs4Destinations = phaseHasContent(destinations?.ks4);
const hasKs5Destinations = phaseHasContent(destinations?.ks5);
Return both from computeSchoolFlags. Add nav items { id: 'destinations', label: 'After Year 11' } and { id: 'post16-destinations', label: 'After the sixth form' } in buildNavItems, gated on the two flags, positioned after the GCSE entry.
- Step 5: Run test and typecheck
Run: cd nextjs-app && npx jest __tests__/lib/schoolSections.destinations.test.ts && npm run typecheck
Expected: PASS
- Step 6: Commit
git add nextjs-app/lib/types.ts nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.destinations.test.ts
git commit -m "feat(destinations): types and section flags"
Task 8: The After Year 11 section
Files:
- Create:
nextjs-app/components/school/DestinationsSection.tsx - Create:
nextjs-app/components/school/DestinationsView.tsx - Create:
nextjs-app/components/school/destinations.module.css - Modify:
nextjs-app/components/school/SecondarySchoolSections.tsx - Test:
nextjs-app/__tests__/components/DestinationsSection.test.tsx
Interfaces:
-
Consumes:
lib/destinations.ts(Task 1),SchoolDestinations(Task 7), tokens (Task 2) -
Produces:
<DestinationsSection destinations={phase} schoolName={string} /> -
Step 1: Write the failing test
Create nextjs-app/__tests__/components/DestinationsSection.test.tsx:
import { render, screen } from '@testing-library/react';
import { DestinationsSection } from '@/components/school/DestinationsSection';
const cell = (category: string, pupils: number | null, status = 'published') => ({
category, pupils, percentage: pupils === null ? null : (pupils / 180) * 100, status,
});
const fullPhase: any = {
cohort_year: '2022/23',
groups: {
all: {
cohort: 180,
categories: [
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),
],
aggregates: {},
},
},
};
const suppressedPhase: any = {
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: {},
},
},
};
it('dates its own cohort so it is not read as stale', () => {
render(<DestinationsSection destinations={fullPhase} schoolName="Northbrook Academy" />);
expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
});
it('renders the bar when the group is fully published', () => {
const { container } = render(<DestinationsSection destinations={fullPhase} schoolName="X" />);
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
});
it('renders NO bar when a category is withheld', () => {
const { container } = render(<DestinationsSection destinations={suppressedPhase} schoolName="X" />);
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0);
expect(screen.getByText(/withheld/i)).toBeInTheDocument();
});
it('never states a remainder for a partially suppressed group', () => {
const { container } = render(<DestinationsSection destinations={suppressedPhase} schoolName="X" />);
// 180 cohort - 155 published = 25, the withheld figure. It must appear nowhere.
expect(container.textContent).not.toMatch(/\b25\b/);
});
it('never claims a pupil stayed at this school', () => {
const { container } = render(<DestinationsSection destinations={fullPhase} schoolName="Northbrook Academy" />);
expect(container.textContent).not.toMatch(/stayed on (here|at)/i);
});
- Step 2: Run test to verify it fails
Run: cd nextjs-app && npx jest __tests__/components/DestinationsSection.test.tsx
Expected: FAIL — cannot find module
- Step 3: Write the server section
Create nextjs-app/components/school/DestinationsSection.tsx. It renders the Section shell, the title, a subtitle naming the cohort year and the publication lag, and delegates the interactive body to DestinationsView with all as the server-rendered default. Mark each bar segment with data-destination-segment so the tests and the E2E journeys can assert on its absence.
Key structure:
/**
* 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 varies.
*/
import type { DestinationPhase } from '@/lib/types';
import { Section, sectionStyles } from './sectionShared';
import { DestinationsView } from './DestinationsView';
export function DestinationsSection({
destinations, schoolName,
}: { destinations: DestinationPhase; schoolName: string }) {
const cohort = destinations.groups.all?.cohort ?? null;
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 in{' '}
{destinations.cohort_year ?? 'the most recent year published'} went next.
Destination measures are published about two years after the exams above.
</p>
<DestinationsView destinations={destinations} schoolName={schoolName} />
</Section>
);
}
- Step 4: Write the client view
Create nextjs-app/components/school/DestinationsView.tsx with 'use client'. It owns the cohort switch (role="radiogroup", arrow-key navigation), the card↔bar hover linkage, and the render decisions:
- Cards from
CARD_GROUPS—aggregateCellsfor the value, or a "Not published" card when it returnsnull - Bar only when
canRenderBar(group); otherwise a panel explaining that the categories add up to the cohort so the rest cannot be drawn - A published aggregate is shown only when
canRenderPublishedAggregate(components)is true - The full table always, with withheld rows marked
- Segment widths from
toBarSegments, each carryingdata-destination-segment
Wrap the toBarSegments call in the canRenderBar guard rather than a try/catch — the throw is a backstop for programmer error, not control flow.
- Step 5: Write the stylesheet
Create nextjs-app/components/school/destinations.module.css using only the tokens from Task 2 plus the existing section tokens. The absence segment is background-image: repeating-linear-gradient(45deg, var(--dest-none-hatch) 0 3px, transparent 3px 7px) over var(--bg-card) with a 1px inset ring in var(--dest-none). Segments sit in a flex row with gap: 2px.
- Step 6: Mount it on the secondary template
In nextjs-app/components/school/SecondarySchoolSections.tsx, render <DestinationsSection /> after the GCSE section and before admissions, gated on flags.hasKs4Destinations.
- Step 7: Run tests, typecheck, commit
Run: cd nextjs-app && npx jest __tests__/components/DestinationsSection.test.tsx && npm run typecheck
Expected: PASS, 5 tests
git add nextjs-app/components/school/DestinationsSection.tsx \
nextjs-app/components/school/DestinationsView.tsx \
nextjs-app/components/school/destinations.module.css \
nextjs-app/components/school/SecondarySchoolSections.tsx \
nextjs-app/__tests__/components/DestinationsSection.test.tsx
git commit -m "feat(destinations): the After Year 11 section"
Task 9: The post-16 section, and removing the placeholder
Files:
- Create:
nextjs-app/components/school/Post16DestinationsSection.tsx - Modify:
nextjs-app/components/school/SecondaryAdmissionsSection.tsx:110-120 - Modify:
nextjs-app/components/school/SecondarySchoolSections.tsx - Test:
nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx
Interfaces:
-
Consumes: everything from Task 8; reuses
DestinationsView -
Produces:
<Post16DestinationsSection destinations={phase} schoolName={string} /> -
Step 1: Write the failing test
Create nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx:
import { render, screen } from '@testing-library/react';
import { Post16DestinationsSection } from '@/components/school/Post16DestinationsSection';
const phase: any = {
cohort_year: '2022/23',
groups: {
all: {
cohort: 96,
categories: [
{ category: 'higher_education', pupils: 56, percentage: 58.3, status: 'published' },
{ category: 'further_education', pupils: 12, percentage: 12.5, status: 'published' },
{ category: 'apprenticeship', pupils: 9, percentage: 9.4, status: 'published' },
{ category: 'employment', pupils: 13, percentage: 13.5, status: 'published' },
{ category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' },
],
aggregates: {},
},
},
};
it('names the Year 13 cohort, not Year 11', () => {
render(<Post16DestinationsSection destinations={phase} schoolName="X" />);
expect(screen.getByText(/Year 13/)).toBeInTheDocument();
});
it('reports university destinations', () => {
render(<Post16DestinationsSection destinations={phase} schoolName="X" />);
expect(screen.getByText(/higher education|university/i)).toBeInTheDocument();
});
- Step 2: Run test to verify it fails
Run: cd nextjs-app && npx jest __tests__/components/Post16DestinationsSection.test.tsx
Expected: FAIL — cannot find module
- Step 3: Write the section
Create nextjs-app/components/school/Post16DestinationsSection.tsx mirroring DestinationsSection with id="post16-destinations", the heading "After the sixth form", copy naming Year 13, and the same DestinationsView body.
- Step 4: Remove the placeholder
In nextjs-app/components/school/SecondaryAdmissionsSection.tsx, delete the "Post-16 destination data coming soon" paragraph at line ~117 and its surrounding conditional. The sixth-form badge in the header stays.
- Step 5: Mount it
In SecondarySchoolSections.tsx, render <Post16DestinationsSection /> after <DestinationsSection />, gated on flags.hasKs5Destinations. Where the school has no sixth form the section is simply not rendered — no placeholder, because absence is the correct statement.
- Step 6: Confirm the placeholder is gone, run tests, commit
Run: grep -rn "coming soon" nextjs-app/components/
Expected: no output
Run: cd nextjs-app && npx jest && npm run typecheck
Expected: PASS
git add nextjs-app/components/school/Post16DestinationsSection.tsx \
nextjs-app/components/school/SecondaryAdmissionsSection.tsx \
nextjs-app/components/school/SecondarySchoolSections.tsx \
nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx
git commit -m "feat(destinations): the post-16 section, replacing the placeholder"
Task 10: E2E journeys
Per CLAUDE.md, user-facing behaviour extends e2e/ in the same PR. Note the staging E2E gate runs post-merge — these journeys cannot pass in PR checks until the Airflow DAG has populated the marts on staging.
Files:
- Modify:
e2e/tests/journeys.spec.ts
Interfaces:
-
Consumes: the rendered pages from Tasks 8 and 9
-
Produces: nothing
-
Step 1: Write the journeys
Append to e2e/tests/journeys.spec.ts:
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
await page.goto('/school/abbey-grange-church-of-england-academy-137083');
const section = page.locator('#destinations');
await expect(section).toBeVisible();
// The section must date its own cohort — destinations run two GCSE years
// behind the results above them, and an undated figure reads as stale.
await expect(section).toContainText(/20\d{2}\/\d{2}/);
});
test('the destinations section draws no bar for a group with withheld figures', async ({ page }) => {
await page.goto('/school/abbey-grange-church-of-england-academy-137083');
const section = page.locator('#destinations');
await section.getByRole('radio', { name: /disadvantaged/i }).click();
const withheld = section.getByText(/withheld/i);
if (await withheld.count() > 0) {
// R1: where anything is withheld, the bar must be absent entirely — a bar
// with a gap in it publishes the withheld figure by its width.
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
}
});
test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
await page.goto('/school/abbey-grange-church-of-england-academy-137083');
const hasSixthForm = await page.getByText(/sixth form/i).count() > 0;
if (!hasSixthForm) {
await expect(page.locator('#post16-destinations')).toHaveCount(0);
}
});
test('the destinations section never claims a pupil stayed at this school', async ({ page }) => {
await page.goto('/school/abbey-grange-church-of-england-academy-137083');
const text = await page.locator('#destinations').textContent();
// The published file reports destination TYPE, never destination institution.
expect(text ?? '').not.toMatch(/stayed on (here|at this school)/i);
});
- Step 2: Verify the slug resolves
Run: grep -n "school/" e2e/tests/journeys.spec.ts | head -5
Match the slug format the existing school-page journeys use. If they build slugs from an API call rather than hardcoding, follow that pattern instead of the literal above.
- Step 3: Commit
git add e2e/tests/journeys.spec.ts
git commit -m "test(e2e): destination journeys, including the no-bar rule"
Self-Review
Spec coverage. Every section of the design maps to a task: disclosure rules → Task 1 (guards) and Task 5 (mart tests); availability/extraction → Task 3; staging and the safe_numeric prohibition → Task 4; marts and R3 → Task 5; API → Task 6; display → Tasks 2, 7, 8, 9; edge states → Tasks 8 and 9; testing → every task plus Task 10.
Gap found and closed. A first draft had Task 3 drop every non-school row, which would have left fact_destination_national (Task 5) reading an empty table — and a note telling the executor to go back and amend an earlier task. Task 3 now keeps national rows with urn = None from the start, and its tests cover both that and the LA rows that must still be dropped.
Type consistency. row_to_record reads geographicLevel, not the locations keys, because a school row also carries NAT, LA and REG entries for its parents — keying off "NAT" in locations would admit every LA row as national. status takes the same three values in the tap, the staging models, the marts, the SQLAlchemy models, the API and lib/destinations.ts. pupil_group is all / disadvantaged / other throughout.