diff --git a/backend/app.py b/backend/app.py
index a513aea..7836ca7 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -919,6 +919,7 @@ async def get_school_details(request: Request, urn: int):
"phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"),
"finance": supplementary.get("finance"),
+ "destinations": supplementary.get("destinations"),
}
diff --git a/backend/data_loader.py b/backend/data_loader.py
index 8aff4df..cad154e 100644
--- a/backend/data_loader.py
+++ b/backend/data_loader.py
@@ -20,6 +20,7 @@ from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactDeprivation, FactFinance, FactPupilCharacteristics,
+ FactKs4Destinations, FactKs5Destinations,
)
from .ofsted_codes import ofsted_page_url, report_card_labels
from .schemas import SCHOOL_TYPE_MAP
@@ -816,6 +817,218 @@ def _finance_dict(f) -> dict:
}
+# Destination measures that are totals DfE published itself, rather than one of
+# the categories that partition the cohort.
+_AGGREGATE_MEASURES = {"agg_sustained_education", "agg_sustained_all"}
+
+
+def _format_cohort_year(year) -> str | None:
+ """202223 -> '2022/23'.
+
+ The section has to date its own cohort. Destination measures run about two
+ GCSE years behind the results shown above them on the same page, so an
+ undated figure reads as stale data rather than as a different question.
+ """
+ if not year:
+ return None
+ text = str(year)
+ if len(text) == 6:
+ return f"{text[:4]}/{text[4:6]}"
+ if len(text) == 8:
+ return f"{text[:4]}/{text[6:8]}"
+ return text
+
+
+_PUPIL_GROUPS = ("disadvantaged", "other", "all")
+
+
+def _lone_hidden_groups(groups: dict) -> list:
+ """Pupil groups hiding exactly one category — solvable by subtraction."""
+ return [
+ key for key, group in groups.items()
+ if sum(1 for c in group["categories"] if c["status"] == "suppressed") == 1
+ ]
+
+
+def _lone_hidden_categories(groups: dict) -> list:
+ """Categories hidden in exactly one of several pupil groups."""
+ lone = []
+ categories = {c["category"] for g in groups.values() for c in g["categories"]}
+ for category in categories:
+ found = [
+ c for g in groups.values() for c in g["categories"]
+ if c["category"] == category
+ ]
+ hidden = [c for c in found if c["status"] == "suppressed"]
+ if len(hidden) == 1 and len(found) > 1:
+ lone.append(category)
+ return lone
+
+
+def disclosure_invariant_holds(groups: dict) -> bool:
+ """Every row and every column hides none, or at least two.
+
+ Public so the tests can assert it directly rather than re-deriving it.
+ """
+ return not _lone_hidden_groups(groups) and not _lone_hidden_categories(groups)
+
+
+def _mask_for_disclosure(groups: dict) -> None:
+ """Withhold further cells until nothing suppressed can be solved for.
+
+ Not rendering a figure is not the same as not publishing it. This endpoint
+ is public and unauthenticated, so anything left in the payload is
+ published, whatever the UI chooses to draw — the same reasoning the
+ admission_distance field carries in app.py.
+
+ Two identities let a caller solve for a withheld cell:
+
+ * within a pupil group, the categories sum to the cohort, so a group with
+ exactly ONE suppressed category gives it away as cohort - sum(rest);
+ * across groups, disadvantaged + other = all for every category, so a
+ category suppressed in exactly ONE of the three gives itself away.
+
+ DfE's own answer is secondary suppression: withhold a second cell so the
+ residual spans two unknowns and identifies neither.
+
+ Where no companion can do that — a sparse cohort whose every other category
+ is `not_applicable`, which is common in special schools and alternative
+ provision — there is nothing left to withhold, so the pupil group is
+ DROPPED entirely. An earlier version simply gave up here and returned with
+ the violation intact and no signal, which is the one outcome this function
+ must never produce: a disclosure-control pass that fails silently is worse
+ than none, because everything downstream trusts it.
+
+ Mutates `groups` in place. Guaranteed to return with
+ disclosure_invariant_holds(groups) true.
+ """
+
+ def suppress(cell):
+ if cell["status"] == "published":
+ cell["status"] = "suppressed"
+ cell["pupils"] = None
+ cell["percentage"] = None
+ return True
+ return False
+
+ def add_companion(candidates) -> bool:
+ """Withhold a second cell so the residual spans two unknowns.
+
+ The companion must carry pupils. Suppressing a zero looks like
+ secondary suppression and protects nothing: the residual still equals
+ the original withheld figure exactly. Returns False when no cell can
+ do the job, which escalates to dropping the group.
+ """
+ published = [c for c in candidates if c["status"] == "published"]
+ useful = sorted(
+ (c for c in published if (c["pupils"] or 0) > 0),
+ key=lambda c: c["pupils"],
+ )
+ if useful:
+ return suppress(useful[0])
+ # Every remaining cell is zero or not applicable: withholding any of
+ # them leaves the residual equal to the original figure.
+ return False
+
+ # Fixpoint: each new suppression can break the other identity. Terminates
+ # because every pass either adds a suppression, drops a group, or stops.
+ while not disclosure_invariant_holds(groups):
+ changed = False
+
+ for category in _lone_hidden_categories(groups):
+ siblings = [
+ c for g in groups.values() for c in g["categories"]
+ if c["category"] == category
+ ]
+ if add_companion(siblings):
+ changed = True
+
+ for key in _lone_hidden_groups(groups):
+ if add_companion(groups[key]["categories"]):
+ changed = True
+
+ if changed:
+ continue
+
+ # Nothing left to withhold. Drop the groups that are still solvable,
+ # and any category still solvable across the groups that remain.
+ for key in _lone_hidden_groups(groups):
+ del groups[key]
+ changed = True
+
+ for category in _lone_hidden_categories(groups):
+ for group in groups.values():
+ for cell in group["categories"]:
+ if cell["category"] == category and suppress(cell):
+ changed = True
+
+ if not changed:
+ # Unreachable given the two escalations above, but a masking pass
+ # must never spin or exit unsafely. Withhold everything.
+ groups.clear()
+ return
+
+
+def _destinations_block(rows: list) -> dict | None:
+ """Shape destination rows for one phase into the API's block.
+
+ Applies secondary suppression before returning, so no caller of this public
+ endpoint can solve for a figure DfE withheld. See _mask_for_disclosure.
+
+ Aggregate measures are dropped entirely. DfE publishes them, and they would
+ be useful for a "what is published for this group" fallback, but nothing
+ renders them today and an aggregate spanning exactly one suppressed
+ component names that component. An unused field that leaks is not a
+ trade-off worth carrying — re-add them with their own guard if the fallback
+ is ever built.
+
+ Deliberately computes no residual, no "remaining pupils" figure, and no
+ total that would close a gap left by a suppressed category.
+ """
+ if not rows:
+ return None
+
+ years = [r["year"] for r in rows if r.get("year") is not None]
+ if not years:
+ return None
+ latest_year = max(years)
+ rows = [r for r in rows if r.get("year") == latest_year]
+
+ groups: dict = {}
+ for row in rows:
+ group = groups.setdefault(
+ row["pupil_group"],
+ {"cohort": row.get("cohort_pupils"), "categories": []},
+ )
+ measure = row["destination_measure"]
+ published = row.get("status") == "published"
+ # Belt and braces: percentage is derived from the same source cell as
+ # pupils, but publishing one without the other would hand back the
+ # cohort (pupils / percentage) and with it the residual.
+ cell = {
+ "category": measure,
+ "pupils": row.get("pupils") if published else None,
+ "percentage": row.get("percentage") if published else None,
+ "status": row.get("status"),
+ }
+ if measure in _AGGREGATE_MEASURES:
+ continue
+ group["categories"].append(cell)
+
+ if not groups:
+ return None
+
+ _mask_for_disclosure(groups)
+
+ # Masking can empty the block entirely — a sparse cohort where no group
+ # could be made safe. Return None so the section is absent rather than
+ # rendering an empty shell.
+ if not groups:
+ return None
+
+ return {"cohort_year": _format_cohort_year(latest_year), "groups": groups}
+
+
def _empty_supplementary() -> dict:
return {
"ofsted": None,
@@ -827,6 +1040,7 @@ def _empty_supplementary() -> dict:
"phonics": None,
"deprivation": None,
"finance": None,
+ "destinations": None,
}
@@ -954,6 +1168,38 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
result[f.urn]["finance"] = _finance_dict(f)
_safe(_finance)
+ # Destinations — KS4 and 16-18. Both marts are long-format, so every row
+ # for a URN is collected and _destinations_block picks the latest year and
+ # shapes the pupil groups. A phase with no rows serialises as null rather
+ # than an empty shell, so the frontend renders nothing rather than an empty
+ # section.
+ def _destinations():
+ from collections import defaultdict
+
+ def _collect(model):
+ per_urn = defaultdict(list)
+ for r in db.query(model).filter(model.urn.in_(urns)).all():
+ per_urn[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,
+ })
+ return per_urn
+
+ ks4_rows = _collect(FactKs4Destinations)
+ ks5_rows = _collect(FactKs5Destinations)
+ for urn in urns:
+ ks4 = _destinations_block(ks4_rows.get(urn, []))
+ ks5 = _destinations_block(ks5_rows.get(urn, []))
+ result[urn]["destinations"] = (
+ {"ks4": ks4, "ks5": ks5} if (ks4 or ks5) else None
+ )
+ _safe(_destinations)
+
return result
diff --git a/backend/models.py b/backend/models.py
index b68b342..d090d8f 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -321,3 +321,48 @@ class Ks2NationalAverage(Base):
gps_high_pct = Column(Float)
gps_avg_score = Column(Float)
science_expected_pct = Column(Float)
+
+
+class FactKs4Destinations(Base):
+ """KS4 leavers destinations — one row per URN, year, pupil group, measure.
+
+ Long format rather than wide because pupil_group is a real third dimension.
+ `status` is load-bearing: 'suppressed' means DfE withheld a figure it
+ considered disclosive and the page must print "withheld"; 'not_applicable'
+ means the measure does not apply and the page must print nothing. `pupils`
+ is null for both, so collapsing status to a null check loses the
+ difference — and the categories sum to the cohort, so a consumer that
+ treats a withheld cell as zero republishes what DfE hid.
+ """
+ __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)
+ 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))
diff --git a/backend/tests/test_destinations_api.py b/backend/tests/test_destinations_api.py
new file mode 100644
index 0000000..462b7db
--- /dev/null
+++ b/backend/tests/test_destinations_api.py
@@ -0,0 +1,269 @@
+"""The destinations serialiser's contract.
+
+Not rendering a figure is not the same as not publishing it. This endpoint is
+public and unauthenticated, so whatever the payload carries is published,
+whatever the UI draws. The categories sum to the cohort and the pupil groups
+sum to each other, so a lone suppressed cell is solvable by subtraction — the
+serialiser adds secondary suppression to prevent it.
+
+See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
+"""
+
+from backend.data_loader import (
+ _destinations_block, _format_cohort_year, disclosure_invariant_holds,
+)
+
+
+def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
+ return {
+ "pupil_group": group,
+ "destination_measure": measure,
+ "pupils": pupils,
+ "percentage": percentage,
+ "status": status,
+ "cohort_pupils": cohort,
+ "year": year,
+ }
+
+
+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_published_category_keeps_its_figures():
+ block = _destinations_block([
+ _row("all", "school_sixth_form", 75, "published", percentage=41.7),
+ ])
+ cat = block["groups"]["all"]["categories"][0]
+ assert cat["pupils"] == 75
+ assert cat["percentage"] == 41.7
+ assert cat["status"] == "published"
+
+
+def test_only_the_latest_year_is_served():
+ rows = [
+ _row("all", "school_sixth_form", 60, "published", year=202122),
+ _row("all", "school_sixth_form", 75, "published", year=202223),
+ ]
+ block = _destinations_block(rows)
+ assert block["cohort_year"] == "2022/23"
+ assert len(block["groups"]["all"]["categories"]) == 1
+ assert block["groups"]["all"]["categories"][0]["pupils"] == 75
+
+
+def test_all_three_pupil_groups_are_carried():
+ rows = [
+ _row("all", "school_sixth_form", 75, "published"),
+ _row("disadvantaged", "school_sixth_form", 17, "published", cohort=62),
+ _row("other", "school_sixth_form", 58, "published", cohort=118),
+ ]
+ block = _destinations_block(rows)
+ assert set(block["groups"]) == {"all", "disadvantaged", "other"}
+ assert block["groups"]["disadvantaged"]["cohort"] == 62
+
+
+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_format_cohort_year_handles_the_six_digit_form():
+ assert _format_cohort_year(202223) == "2022/23"
+ assert _format_cohort_year(None) is None
+
+
+def test_empty_rows_yield_none_not_an_empty_shell():
+ assert _destinations_block([]) is None
+
+
+# ── Disclosure control ──────────────────────────────────────────────────────
+#
+# The rendering guards in lib/destinations.ts stop a withheld figure being
+# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
+# unauthenticated, so whatever the payload carries is published. These tests
+# are the ones that matter.
+
+def _solve_residual(group):
+ """What any caller can work out: cohort minus everything published."""
+ published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
+ hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
+ return group["cohort"] - sum(published), len(hidden)
+
+
+def test_a_lone_suppressed_category_cannot_be_solved_for():
+ """Whitley Bay High School's real 2022/23 disadvantaged group: further
+ education withheld, everything else published, cohort 41. Before secondary
+ suppression the payload gave the answer away as 41 - 23 = 18."""
+ rows = [
+ _row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
+ _row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
+ _row("disadvantaged", "further_education", None, "suppressed", cohort=41),
+ _row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
+ _row("disadvantaged", "employment", 2, "published", cohort=41),
+ _row("disadvantaged", "not_sustained", 3, "published", cohort=41),
+ _row("disadvantaged", "not_captured", 2, "published", cohort=41),
+ ]
+ group = _destinations_block(rows)["groups"]["disadvantaged"]
+ residual, hidden = _solve_residual(group)
+ assert hidden >= 2, "a lone suppressed cell must gain a companion"
+ assert residual != 18, "the withheld figure is recoverable from the payload"
+
+
+def test_every_group_hides_none_or_at_least_two_categories():
+ rows = [
+ _row("all", "school_sixth_form", 75, "published"),
+ _row("all", "sixth_form_college", None, "suppressed"),
+ _row("all", "further_education", 61, "published"),
+ _row("all", "apprenticeship", 8, "published"),
+ _row("all", "employment", 6, "published"),
+ _row("all", "not_sustained", 5, "published"),
+ _row("all", "not_captured", 4, "published"),
+ ]
+ group = _destinations_block(rows)["groups"]["all"]
+ hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
+ assert len(hidden) >= 2
+
+
+def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
+ """disadvantaged + other = all for every category, so a category withheld
+ in exactly one of the three is recoverable from the other two."""
+ rows = []
+ for measure, a, d, o in [
+ ("school_sixth_form", 75, None, 58),
+ ("further_education", 61, 27, 34),
+ ("apprenticeship", 8, 4, 4),
+ ("employment", 6, 1, 5),
+ ("not_sustained", 5, 3, 2),
+ ("not_captured", 4, 2, 2),
+ ]:
+ rows.append(_row("all", measure, a, "published", cohort=159))
+ rows.append(_row("disadvantaged", measure, d,
+ "published" if d is not None else "suppressed", cohort=37))
+ rows.append(_row("other", measure, o, "published", cohort=122))
+
+ groups = _destinations_block(rows)["groups"]
+ measures = {c["category"] for g in groups.values() for c in g["categories"]}
+ assert len(measures) == 6, "the fixture's six measures must all be checked"
+
+ for measure in sorted(measures):
+ hidden = sum(
+ 1 for g in groups.values() for c in g["categories"]
+ if c["category"] == measure and c["status"] == "suppressed"
+ )
+ # The invariant is "none, or at least two" — not "at least two".
+ assert hidden != 1, f"{measure} is solvable across the pupil groups"
+
+
+def test_a_suppressed_cell_never_keeps_its_percentage():
+ """percentage / pupils would hand back the cohort, and with it the residual."""
+ rows = [
+ _row("all", "school_sixth_form", 75, "published", percentage=41.7),
+ _row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
+ _row("all", "further_education", 61, "published", percentage=33.9),
+ ]
+ group = _destinations_block(rows)["groups"]["all"]
+ for cell in group["categories"]:
+ if cell["status"] != "published":
+ assert cell["pupils"] is None
+ assert cell["percentage"] is None
+
+
+def test_aggregates_are_not_served():
+ """An aggregate spanning exactly one suppressed component names it, and
+ nothing renders them today."""
+ rows = [
+ _row("all", "school_sixth_form", 75, "published"),
+ _row("all", "agg_sustained_all", 171, "published"),
+ ]
+ group = _destinations_block(rows)["groups"]["all"]
+ assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
+ assert "aggregates" not in group
+
+
+def test_a_fully_published_group_is_left_alone():
+ """Secondary suppression must not cost anything where nothing is withheld —
+ this is the all-pupils view on every mainstream secondary."""
+ rows = [
+ _row("all", m, p, "published")
+ for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
+ ("further_education", 61), ("apprenticeship", 8),
+ ("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
+ ]
+ group = _destinations_block(rows)["groups"]["all"]
+ assert all(c["status"] == "published" for c in group["categories"])
+ assert len(group["categories"]) == 7
+
+
+def test_the_invariant_is_asserted_directly_not_re_derived():
+ """A group with one suppressed category and nothing else to withhold."""
+ rows = [
+ _row("all", "school_sixth_form", None, "suppressed", cohort=9),
+ _row("all", "sixth_form_college", None, "not_applicable", cohort=9),
+ _row("all", "further_education", None, "not_applicable", cohort=9),
+ ]
+ block = _destinations_block(rows)
+ assert block is None or disclosure_invariant_holds(block["groups"])
+
+
+def test_a_sparse_cohort_with_no_companion_drops_the_group():
+ """Special schools and AP routinely have one suppressed category and every
+ other one not applicable. There is nothing left to withhold, so the group
+ goes — an earlier version returned here with the violation intact."""
+ rows = [
+ _row("all", "school_sixth_form", None, "suppressed", cohort=9),
+ _row("all", "sixth_form_college", None, "not_applicable", cohort=9),
+ _row("all", "further_education", None, "not_applicable", cohort=9),
+ _row("all", "apprenticeship", None, "not_applicable", cohort=9),
+ _row("all", "employment", None, "not_applicable", cohort=9),
+ _row("all", "not_sustained", None, "not_applicable", cohort=9),
+ _row("all", "not_captured", None, "not_applicable", cohort=9),
+ ]
+ block = _destinations_block(rows)
+ assert block is None or "all" not in block["groups"], (
+ "a group that cannot be made safe must not be served"
+ )
+
+
+def test_zeros_are_not_treated_as_a_usable_companion():
+ """Suppressing a zero protects nothing — the residual is unchanged. With
+ only zeros available the group must be dropped, not falsely 'fixed'."""
+ rows = [
+ _row("all", "school_sixth_form", None, "suppressed", cohort=5),
+ _row("all", "sixth_form_college", 0, "published", cohort=5),
+ _row("all", "further_education", 0, "published", cohort=5),
+ ]
+ block = _destinations_block(rows)
+ if block and "all" in block["groups"]:
+ group = block["groups"]["all"]
+ published = sum(c["pupils"] for c in group["categories"]
+ if c["pupils"] is not None)
+ hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
+ assert len(hidden) != 1, "a zero companion leaves the figure solvable"
+ assert group["cohort"] - published != 5
+
+
+def test_masking_always_terminates_in_a_safe_state():
+ """Exhaustive over every suppression pattern of a four-category group."""
+ from itertools import product
+ MEASURES = ["school_sixth_form", "sixth_form_college",
+ "further_education", "apprenticeship"]
+ for statuses in product(["published", "suppressed", "not_applicable"],
+ repeat=len(MEASURES)):
+ rows = [
+ _row("all", m, 3 if st == "published" else None, st, cohort=12)
+ for m, st in zip(MEASURES, statuses)
+ ]
+ block = _destinations_block(rows)
+ if block is None:
+ continue
+ assert disclosure_invariant_holds(block["groups"]), (
+ f"invariant broken for {statuses}"
+ )
diff --git a/backend/tests/test_supplementary_batch.py b/backend/tests/test_supplementary_batch.py
index 5fb6cdd..7813e7f 100644
--- a/backend/tests/test_supplementary_batch.py
+++ b/backend/tests/test_supplementary_batch.py
@@ -132,16 +132,23 @@ def test_one_query_per_table_and_latest_row_per_urn():
"FactPupilCharacteristics": [],
"FactDeprivation": [],
"FactFinance": [],
+ "FactKs4Destinations": [],
+ "FactKs5Destinations": [],
}
session = _FakeSession(rows)
out = get_supplementary_data_batch(session, [1, 2])
- # Exactly one query per table — six total, regardless of two URNs.
+ # Exactly one query per table — eight total, regardless of two URNs.
assert sorted(session.queries) == [
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
- "FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
+ "FactFinance", "FactKs4Destinations", "FactKs5Destinations",
+ "FactOfstedInspection", "FactPupilCharacteristics",
]
+ # A school with no destination rows gets null, not an empty shell — the
+ # frontend renders the section from the block's presence.
+ assert out[1]["destinations"] is None
+
# Latest Ofsted kept per URN
assert out[1]["ofsted"]["overall_effectiveness"] == 2
assert out[2]["ofsted"]["overall_effectiveness"] == 1
diff --git a/docs/superpowers/plans/2026-08-28-destination-measures.md b/docs/superpowers/plans/2026-08-28-destination-measures.md
new file mode 100644
index 0000000..b57dabb
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-destination-measures.md
@@ -0,0 +1,1754 @@
+# 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_numeric` must never be applied to a destination count or percentage.** It coerces `c` to `NULL`, 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 `:root` and 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 dataset `019d4f41-22d1-71b2-a1a7-f3b91026815b`; 16-18 dataset `019d4e73-6440-7523-b60c-bfab1ad4a30d`. Time periods use the `2022/2023` form, not `2022/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 slugs
+ - `type 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 }`
+ - `CARD_GROUPS: Record`
+ - `canAggregate(cells: DestinationCell[]): boolean`
+ - `aggregateCells(cells: DestinationCell[], cohort: number): { pupils: number; percentage: number } | null`
+ - `canRenderPublishedAggregate(components: DestinationCell[]): boolean`
+ - `canRenderBar(group: DestinationGroup): boolean`
+ - `toBarSegments(group: DestinationGroup): { category; pupils; widthPct; labelPct }[]`
+ - `suppressedCount(cells: DestinationCell[]): number`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `nextjs-app/__tests__/lib/destinations.test.ts`:
+
+```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`:
+
+```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>;
+}
+
+/** 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 = {
+ 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 = {
+ 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 = {
+ 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**
+
+```bash
+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`, the `prefers-color-scheme` block, 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`:
+
+```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:
+
+```css
+ /* ── 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):
+
+```css
+ --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**
+
+```bash
+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_destinations` and `ees_ks5_destinations`, columns `urn`, `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`:
+
+```python
+"""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`:
+
+```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`:
+
+```python
+"""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:`:
+
+```yaml
+ - 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:
+
+```python
+ 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**
+
+```bash
+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_destinations` with columns `urn` (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:`:
+
+```yaml
+ - 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`:
+
+```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**
+
+```bash
+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`) and `fact_destination_national` (same, without `urn`, plus `phase`)
+
+- [ ] **Step 1: Write the KS4 mart with R3 masking**
+
+Create `pipeline/transform/models/marts/fact_ks4_destinations.sql`:
+
+```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`:
+
+```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`:
+
+```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`:
+
+```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`:
+
+```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
+
+```bash
+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 after `FactFinance`, 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` (the `get_school_details` return block)
+- Test: `backend/tests/test_destinations_api.py`
+
+**Interfaces:**
+- Consumes: `fact_ks4_destinations`, `fact_ks5_destinations`, `fact_destination_national`
+- Produces: `destinations` key on `GET /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`:
+
+```python
+"""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`:
+
+```python
+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`:
+
+```python
+_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`:
+
+```python
+ # 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"),`:
+
+```python
+ "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
+
+```bash
+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.ts` types from Task 1
+- Produces: `SchoolDestinations` type; `SchoolFlags.hasKs4Destinations` and `.hasKs5Destinations`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `nextjs-app/__tests__/lib/schoolSections.destinations.test.ts`:
+
+```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`:
+
+```ts
+import type { DestinationCell, PupilGroup } from './destinations';
+
+export interface DestinationGroupPayload {
+ cohort: number | null;
+ categories: DestinationCell[];
+ aggregates: Partial>;
+}
+
+export interface DestinationPhase {
+ cohort_year: string | null;
+ groups: Partial>;
+}
+
+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:
+
+```ts
+ // 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**
+
+```bash
+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: ``
+
+- [ ] **Step 1: Write the failing test**
+
+Create `nextjs-app/__tests__/components/DestinationsSection.test.tsx`:
+
+```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();
+ expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
+});
+
+it('renders the bar when the group is fully published', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
+});
+
+it('renders NO bar when a category is withheld', () => {
+ const { container } = render();
+ 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();
+ // 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();
+ 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:
+
+```tsx
+/**
+ * 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 (
+
+
After Year 11
+
+ 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.
+
+
+
+ );
+}
+```
+
+- [ ] **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` — `aggregateCells` for the value, or a "Not published" card when it returns `null`
+- 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 carrying `data-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 `` 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
+
+```bash
+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: ``
+
+- [ ] **Step 1: Write the failing test**
+
+Create `nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx`:
+
+```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();
+ expect(screen.getByText(/Year 13/)).toBeInTheDocument();
+});
+
+it('reports university destinations', () => {
+ render();
+ 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 `` after ``, 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
+
+```bash
+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`:
+
+```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**
+
+```bash
+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.
diff --git a/docs/superpowers/specs/2026-08-28-destination-measures-design.md b/docs/superpowers/specs/2026-08-28-destination-measures-design.md
new file mode 100644
index 0000000..492167a
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-destination-measures-design.md
@@ -0,0 +1,399 @@
+# Destination Measures — Design
+
+**Date:** 2026-08-28
+**Status:** awaiting review
+**Scope:** secondary school detail pages only
+
+## Goal
+
+Say what happened to a school's leavers after they left. Two sections on the
+secondary template:
+
+- **After Year 11** — every secondary, from the KS4 destination measures
+- **After the sixth form** — sixth-form schools only, from the 16-18 measures
+
+This replaces the "Post-16 destination data coming soon" placeholder standing in
+`nextjs-app/components/school/SecondaryAdmissionsSection.tsx:117` since the exam
+phase taxonomy work, and fills the `ks5_destinations_pct` slot specified but
+never built in `2026-07-07-exam-phase-taxonomy-design.md:201`.
+
+Mockup, with all three data states live:
+
+
+## The finding that shapes everything
+
+**Suppression is per cell, and the cells sum to the cohort.**
+
+DfE withholds a figure it considers disclosive by writing `c`. It does this at
+the level of an individual destination category, not the whole school, and it
+publishes the cohort total alongside. The categories form a clean partition. So
+where exactly one category is suppressed, subtracting the published ones from the
+cohort recovers it exactly.
+
+Verified against three real schools in the 2022/23 file:
+
+| School | URN | Withheld | Recovers to |
+|---|---|---|---|
+| North East Futures UTC | 145900 | School sixth form | **3 pupils** |
+| Whitley Bay High School | 108638 | Further education | **18 pupils** |
+| St Matthew's RC High School | 148389 | School sixth form | **4 pupils** |
+
+Those are the precise numbers the `c` exists to hide, and in a random 400-school
+sample **22% of mainstream secondaries** have exactly one suppressed category in
+their disadvantaged group. This is the normal case, not an edge case.
+
+Three rules follow, and everything else in this document is downstream of them.
+
+**R1 — Never *publish* enough to derive a remainder.**
+
+An earlier draft of this rule said "never *render* a derived remainder", and
+that was the defect code review caught in PR #137. Not drawing a number does
+nothing to stop it being computed: `GET /api/schools/{urn}` is public and
+unauthenticated, so anything in the payload is published whatever the UI
+chooses to draw. The rendering guards shipped; the payload still carried the
+cohort and every published category, and `cohort - sum(published)` returned
+Whitley Bay's withheld figure exactly.
+
+The rule is therefore about the serialiser, and the UI guards are a second line
+of defence behind it. Two identities have to be closed:
+
+- within a pupil group the categories sum to the cohort, so a group with
+ exactly **one** suppressed category gives it away;
+- across groups, disadvantaged + other = all for every category, so a category
+ suppressed in exactly **one** of the three gives itself away.
+
+`_mask_for_disclosure` applies DfE's own answer — secondary suppression —
+withholding a companion cell until every row and every column hides either none
+or at least two. It iterates, because each new suppression can break the other
+identity, and terminates because cells are only ever added.
+
+The companion must carry pupils. Suppressing a zero looks like secondary
+suppression and protects nothing: the residual still equals the original
+withheld figure.
+
+Where no companion can do the job — a sparse cohort whose every other category
+is `not_applicable`, routine in special schools and alternative provision — the
+pupil group is **dropped from the payload entirely**. A first version simply
+returned at that point with the violation intact and no signal, which review
+caught: a disclosure-control pass that fails silently is worse than none,
+because everything downstream trusts it. The function now cannot terminate
+except in a state where `disclosure_invariant_holds()` is true, and an
+exhaustive test sweeps all 81 suppression patterns of a four-category group to
+prove it.
+
+Measured cost on the 400-school sample: the all-pupils bar survives on **94%**
+of mainstream secondaries rather than 100%. That is the price of not
+republishing what DfE withheld.
+
+**R2 — Never aggregate across a suppression boundary.** Summing published
+components to fill a gap is R1 with extra steps.
+
+DfE's own aggregates (`Sustained education destination`, `Sustained education,
+employment & apprenticeships`) are ingested but **not served**. An aggregate
+spanning exactly one suppressed component names it, and nothing renders them
+today — an unused field that leaks is not a trade-off worth carrying. They can
+be re-added with their own guard if the fallback ladder is ever built.
+
+**R3 — The three pupil groups are one disclosure surface, not three.**
+Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so
+rendering any *two* of them recovers the third. Where a category is suppressed in
+the disadvantaged group, it must therefore also be withheld from **all other
+pupils** — the all-pupils view is the primary one and keeps it.
+
+This costs almost nothing, because DfE already applies the same masking: across
+the sample, 493 of 498 suppressed disadvantaged cells were suppressed in the
+other group too. The mart enforces the remaining 5, which fell on 2 schools of
+262. **The all-pupils bar is unaffected** — masking the whole page wherever the
+disadvantaged group is thin would remove the bar from 80% of schools, and is not
+what this rule says.
+
+R1 and R2 both hold within a group and still leak across the switch, which is why
+R3 is stated separately.
+
+### The convention that would break this quietly
+
+`macros/safe_numeric.sql` coerces every EES sentinel — `z`, `c`, `x`, `q`, `u` —
+to `NULL`, deliberately and correctly for attainment, where "suppressed" and "no
+data" are equally unrenderable. Here they are not the same thing: one must print
+*withheld*, the other must print nothing at all, and the difference is what keeps
+R1 enforceable.
+
+**`safe_numeric` must not be used on destination counts.** The staging model
+keeps the sentinel in a companion status column. This is the single most likely
+way for this feature to regress into a disclosure, so it gets its own dbt test.
+
+## What is actually available
+
+Measured against the EES public API (open, no key). Both datasets carry
+`geographicLevel: School` with `urn` on every location option, so the join to
+`dim_school` is direct.
+
+| | KS4 | 16-18 |
+|---|---|---|
+| Dataset id | `019d4f41-22d1-71b2-a1a7-f3b91026815b` | `019d4e73-6440-7523-b60c-bfab1ad4a30d` |
+| Rows | 1,871,739 | 3,862,658 |
+| Institutions | 4,946 | 3,065 |
+| Time periods | 2009/10–2022/23 | 2016/17–2022/23 |
+
+**Destination categories (KS4).** School sixth form · Sixth form college ·
+Further education · Other education destination · Sustained apprenticeships (with
+level breakdown) · Sustained employment destination · Not recorded as a sustained
+destination · Activity not captured. Plus the aggregates `Sustained education
+destination` and `Sustained education, employment & apprenticeships`.
+
+**16-18 adds** UK higher education institution and FE split by level, which is
+what makes the post-16 section worth having.
+
+**Breakdowns.** `Disadvantage Status` gives Disadvantaged / Not known to be
+disadvantaged / Total — exactly the three-way switch. Sex, ethnicity, FSM status,
+prior attainment and SEN provision also travel in the same table; we ingest none
+of them.
+
+**Indicators.** Both counts and percentages, plus the cohort size. Bar widths use
+the counts — the published percentages do not sum to 100.
+
+### Coverage, and what degrades
+
+Random 400-school sample, 2022/23, mainstream secondaries (n=262):
+
+| View | As published by DfE | After R1–R3 masking | Consequence |
+|---|---|---|---|
+| All pupils, all categories | 100% | **94%** | Bar works nearly everywhere |
+| Disadvantaged, headline rate | 95% | 95% | Gap panel works |
+| Disadvantaged, three grouped cards | 68% | 68% | Degrades card by card |
+| Disadvantaged, all six categories | 20% | **20%** | Bar unusable for this group |
+
+The middle column is what the site actually serves. Masking costs the
+all-pupils bar on 6% of mainstream secondaries — those are schools where a
+category was suppressed in exactly one pupil group and no non-zero companion
+existed below the all-pupils row.
+
+Special schools and alternative provision are far worse: 13% and 41% respectively
+have the whole cohort suppressed even for all pupils. The empty state is
+load-bearing, not defensive.
+
+## The display
+
+Question-led. Three cards over one bar, with the cards acting as a lens on the
+bar rather than a summary beside it — hovering a card dims the bar, table and
+England reference to the categories that card is built from. The full mockup is
+linked above; what matters for implementation:
+
+**The headline is not the sustained rate.** That figure sits between 92% and 97%
+for nearly every school in England. The mix is what varies, so the mix leads.
+
+**The grouping is ours, not DfE's.** "Academic route" = school sixth form +
+sixth-form college; "College" = FE and other colleges; "Work" = apprenticeship +
+employment. This is the most arguable thing on the page, so it lives in one place
+in `lib/destinations.ts`, is explained in a tooltip, and is reversible in one
+edit.
+
+**The absence is hatched neutral, never a colour.** "Activity not captured" means
+no record in the sources DfE holds — it includes independent schools, moving
+abroad and private training. Colouring it as a bad outcome would be a factual
+error rendered in CSS. The hatch also fixes a real contrast problem: neutral
+against the employment blue failed CVD separation at ΔE 7.6, and texture is the
+secondary encoding that rescues it. Every other adjacent pair clears ΔE 10.9
+under protanopia.
+
+**Colour tokens.** Education is one hue in three steps (school-like to
+college-like); apprenticeship and employment are separate hues. Six new tokens in
+`globals.css`, defined in both themes, per the existing token discipline.
+
+**The disadvantage split rides the same control.** One visualisation serving
+three cohorts, with the England reference repointing to the matching national
+group. The gap statement stays visible below the bar whatever is selected,
+because a gap nobody clicks on is a gap nobody sees.
+
+## Data model
+
+### Extraction
+
+A new `tap-uk-ees-destinations` extractor, separate from `tap-uk-ees`. The
+existing tap downloads a release ZIP and reads a CSV inside it; the destinations
+files are far larger than we need and the query API filters server-side, so this
+one POSTs to `/v1/data-sets/{id}/query` and pages through results.
+
+With every dimension pinned — destination measures, disadvantage status, sex
+Total, characteristic topic Total — one year returns **252,610 rows** across all
+geographic levels. Three school-level years is comfortably tractable.
+
+Pinning is mandatory, not an optimisation: leaving the characteristic dimensions
+unconstrained returned 45 rows where 9 were wanted, because every breakdown
+shares one table.
+
+The tap emits the raw value as text. **It does not coerce `c`.**
+
+### Staging
+
+`stg_ees_ks4_destinations` / `stg_ees_ks5_destinations`. Each raw value becomes
+two columns:
+
+```sql
+case when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then raw::numeric end as pupils,
+case
+ when raw ~ '^-?[0-9]+(\.[0-9]+)?$' then 'published'
+ when lower(trim(raw)) = 'c' then 'suppressed'
+ else 'not_applicable'
+end as status
+```
+
+### Marts
+
+`fact_ks4_destinations` and `fact_ks5_destinations`, **long format**:
+
+```
+urn, year, pupil_group, destination_category, cohort_pupils, pupils, percentage, status
+```
+
+This departs from the wide house pattern (`fact_ks4_performance` and friends) on
+purpose. `pupil_group` is a genuine third dimension; going wide would need three
+sets of every column, and R2 is far easier to test on rows than on columns.
+
+Roughly 8 categories × 3 groups × 4,946 schools × 3 years ≈ 356k rows.
+
+`fact_destination_national` carries the same grain for England, so the page's
+England reference repoints with the switch.
+
+### dbt tests
+
+- `assert_destinations_no_derived_remainder` — for every (urn, year,
+ pupil_group) with exactly one suppressed category, assert no aggregate row
+ exists that would let the residual be recovered. **This is the R1 guard.**
+- `assert_destinations_group_masking` — for every (urn, year, category), if the
+ disadvantaged group carries `suppressed`, so does the other-pupils group.
+ **This is the R3 guard**, applied in the mart so no consumer can reach an
+ unmasked combination.
+- `assert_destination_status_null_agreement` — `pupils is null` wherever
+ `status != 'published'`, and never null where it is.
+- `assert_destinations_join_dim_school` — no orphaned URNs, matching the
+ existing `assert_no_orphaned_facts` pattern.
+
+## API
+
+`GET /api/schools/{urn}` gains a `destinations` block:
+
+```json
+{
+ "ks4": {
+ "cohort_year": "2022/23",
+ "published": "2026-04",
+ "groups": {
+ "all": { "cohort": 180, "categories": [ … ], "aggregates": { … } },
+ "disadvantaged": { … },
+ "other": { … }
+ }
+ },
+ "ks5": { … }
+}
+```
+
+Each category carries `pupils`, `percentage` and `status`. **The serialiser never
+emits a computed remainder**, and a backend test asserts that a group containing a
+suppressed category serialises no total that closes the gap.
+
+`null` for the whole block where nothing is published — the frontend renders the
+empty state from its absence, not from a sentinel.
+
+## Frontend
+
+| File | Kind | Job |
+|---|---|---|
+| `lib/destinations.ts` | pure | Category list, the academic/college/work grouping, `canAggregate()` enforcing R2, percentage derivation from counts |
+| `components/school/DestinationsSection.tsx` | server | Section shell, renders **all pupils** into the HTML |
+| `components/school/DestinationsView.tsx` | client | Cohort switch, card↔bar linkage |
+| `components/school/Post16DestinationsSection.tsx` | server | Year 13 section, sixth-form schools only |
+| `app/globals.css` | tokens | Six destination colours, both themes |
+
+Server-first matches the directory's existing discipline — every component in
+`components/school/` is a server component except `AdmissionsViewToggle`, which
+is the precedent this follows. All-pupils figures are in the HTML for crawlers
+and for no-JS; only the switch and the hover linkage need the client.
+
+`lib/schoolSections.ts` gains `hasKs4Destinations` / `hasKs5Destinations` flags
+and the nav items, following the existing `computeSchoolFlags` pattern.
+
+**Placement** on the secondary template: GCSE results → After Year 11 → After the
+sixth form → admissions. Destinations follow attainment because they answer "and
+then what happened".
+
+**Dating.** The latest destination year is 2022/23, published April 2026, while
+the site's newest KS4 year is 2024/25. The section header states its own cohort
+year, or it reads as stale data next to the GCSE section above it.
+
+## Edge states
+
+| State | Frequency | Behaviour |
+|---|---|---|
+| Whole cohort suppressed | 13% of special, 41% of AP | Section renders the explanation, no chart |
+| Some categories withheld | 80% of disadvantaged views | Cards degrade individually; **no bar**; table marks withheld rows |
+| Disadvantaged group suppressed entirely | 5% | Switch drops to two options, gap panel not rendered |
+| No sixth form | — | Post-16 section not rendered at all — absence is correct, a "no data" placeholder would imply something is missing |
+| School too new | — | "First figures expected in 2026", not a bare no |
+
+## Testing
+
+Per CLAUDE.md, user-facing behaviour extends `e2e/` in the same PR.
+
+**Unit** — `lib/destinations.ts` is where R1 and R2 live, so it carries the
+heaviest tests: `canAggregate()` refuses a group containing one suppressed cell,
+allows one spanning two, and the bar builder refuses to emit segments for any
+group with suppression. These are the tests that must fail loudly if someone
+later "fixes" a gap in the chart.
+
+**dbt** — the three tests above.
+
+**Backend** — the serialiser emits no closing total for a partially suppressed
+group.
+
+**E2E** — a school with full data renders three cards and a bar; a school with a
+partially suppressed disadvantaged group renders the withheld state and **no bar
+element**; a suppressed school renders the explanation; a school with no sixth
+form renders no post-16 section.
+
+Note the staging caveat: mart changes are inert until the Airflow pipeline runs,
+and the staging E2E gate runs post-merge.
+
+## Out of scope
+
+- **Compare view and rankings.** The long mart shape supports both; neither is
+ built here. Flagged because "% to a school sixth form" is a plausible rankings
+ metric and the mart shape should not have to change to allow it.
+- **Ethnicity, sex, SEN and prior-attainment breakdowns.** Available in the same
+ file, ingested deliberately not at all — each is a separate editorial decision
+ about what a school page should assert.
+- **Longer term destinations** (3 and 5 years out) and **Progression to higher
+ education** — separate publications, worth a later look for sixth forms.
+- **Primary schools.** No KS2 destination measures publication exists; DfE
+ tracking starts at KS4. Naming the secondaries a primary's leavers go to needs
+ the National Pupil Database, which is not publishable at that grain.
+
+## Risks
+
+**A later change reintroduces the disclosure.** The likeliest routes are
+applying `safe_numeric` to a destination column for consistency, adding a
+`coalesce` in a mart, or — as happened in review — enforcing a disclosure rule
+at the rendering layer instead of the publishing layer. Mitigation is the dbt
+tests plus `backend/tests/test_destinations_api.py`, which reconstructs the
+residual the way an attacker would and asserts it no longer resolves.
+
+**The two-year lag reads as staleness.** Mitigated by dating the cohort in the
+section header rather than only in a tooltip.
+
+**Sixth-form retention will be misread.** "41% went to a school sixth form" says
+nothing about *which* school. The published file reports destination type, never
+destination institution. Copy must never imply "stayed on here", and the tooltip
+should say so.
+
+**Section length.** The secondary template is already long and this adds two
+sections. If it becomes a problem the post-16 section is the one to collapse
+behind a disclosure, not the Year 11 one.
+
+## Open questions
+
+1. Is the disadvantage split its own section or a sub-block inside the
+ destinations section? Modelled as a sub-block; it is the most differentiating
+ figure on the page and the most easily misread on a small cohort.
+2. Do we ingest the apprenticeship level breakdown (intermediate / advanced /
+ higher) now, or collapse to one apprenticeship figure and revisit? Collapsed
+ in this design.
diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index e006c5d..98ac194 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -2412,3 +2412,145 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
await page.getByRole('button', { name: /Search/i }).first().click();
await expect(page).toHaveURL(/search=abbey/);
});
+
+// ── Destination measures ───────────────────────────────────────────────────
+//
+// Two failure modes have to be told apart here, and conflating them is how
+// this suite would either hide a regression or block the promotion pipeline:
+//
+// * the backend does not serve the `destinations` field at all — a code
+// regression, or a deploy that did not land. FAILS.
+// * the field is served but every school is empty — the annual EES DAG has
+// not run on this environment yet. SKIPS, loudly.
+//
+// The second is a data-load precondition, not a defect, and it is true for
+// every commit between this merging and the DAG being triggered. Failing on it
+// would redden the staging gate for unrelated work. This is not the quiet skip
+// 4f01fbd removed from the distance journeys: that one hid a broken feature
+// behind a flag check, whereas the assertion that the code is deployed and
+// correctly shaped still runs here on every commit.
+
+async function secondaryWithDestinations(page: Page): Promise<{
+ urn: string; destinations: any;
+}> {
+ const res = await page.request.get('/api/schools?search=school&per_page=100');
+ expect(res.ok()).toBeTruthy();
+ const body = await res.json();
+ const urns: string[] = (body.schools ?? [])
+ .filter((s: { phase?: string; attainment_8_score?: number | null }) =>
+ s.phase === 'Secondary' && s.attainment_8_score != null)
+ .map((s: { urn: number }) => String(s.urn));
+ expect(urns.length).toBeGreaterThan(0);
+
+ let served = false;
+ for (const urn of urns.slice(0, 25)) {
+ const detail = await page.request.get(`/api/schools/${urn}`);
+ if (!detail.ok()) continue;
+ const data = await detail.json();
+ // The key must exist, even as null. Its absence means the backend in front
+ // of us does not know about destinations at all.
+ if ('destinations' in data) served = true;
+ if (data.destinations?.ks4) return { urn, destinations: data.destinations };
+ }
+
+ expect(served,
+ 'GET /api/schools/{urn} served no `destinations` key at all — the backend '
+ + 'is missing this feature, not merely missing its data').toBeTruthy();
+
+ test.skip(true,
+ 'No school has destination data yet: the annual EES DAG has not run on '
+ + 'this environment. The API shape is correct, so this is a data-load '
+ + 'precondition rather than a regression.');
+ throw new Error('unreachable');
+}
+
+test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
+ const { urn } = await secondaryWithDestinations(page);
+ await page.goto(`/school/${urn}`);
+
+ const section = page.locator('#destinations');
+ await expect(section).toBeVisible({ timeout: 15_000 });
+ await expect(section.getByRole('heading', { name: 'After Year 11' })).toBeVisible();
+ // The section must date its own cohort: destinations run about 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 bar is absent entirely whenever a figure is withheld', async ({ page }) => {
+ const { urn, destinations } = await secondaryWithDestinations(page);
+ await page.goto(`/school/${urn}`);
+ const section = page.locator('#destinations');
+ await expect(section).toBeVisible({ timeout: 15_000 });
+
+ const allGroup = destinations.ks4.groups.all;
+ const suppressed = (allGroup?.categories ?? [])
+ .filter((c: { status: string }) => c.status === 'suppressed');
+
+ if (suppressed.length > 0) {
+ // R1: a bar drawn from the published segments leaves a gap whose width is
+ // the withheld figure, readable straight off the axis.
+ await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
+ await expect(section.getByText(/withheld/i).first()).toBeVisible();
+ } else {
+ const published = (allGroup?.categories ?? [])
+ .filter((c: { status: string }) => c.status === 'published');
+ await expect(section.locator('[data-destination-segment]'))
+ .toHaveCount(published.length);
+ }
+});
+
+test('switching to disadvantaged pupils never reveals a withheld figure', async ({ page }) => {
+ const { urn, destinations } = await secondaryWithDestinations(page);
+ const disadvantaged = destinations.ks4.groups.disadvantaged;
+ test.skip(!disadvantaged, 'this school publishes no disadvantaged breakdown');
+
+ await page.goto(`/school/${urn}`);
+ const section = page.locator('#destinations');
+ await expect(section).toBeVisible({ timeout: 15_000 });
+
+ const radio = section.getByRole('radio', { name: /disadvantaged/i });
+ await expect(radio).toBeVisible();
+ await radio.click();
+
+ const suppressed = (disadvantaged.categories ?? [])
+ .filter((c: { status: string }) => c.status === 'suppressed');
+ if (suppressed.length > 0) {
+ await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
+
+ // The residual must appear nowhere on the page — it is the withheld figure.
+ const cohort: number = disadvantaged.cohort;
+ const publishedTotal = (disadvantaged.categories ?? [])
+ .filter((c: { status: string }) => c.status === 'published')
+ .reduce((sum: number, c: { pupils: number }) => sum + c.pupils, 0);
+ const residual = cohort - publishedTotal;
+ const text = (await section.textContent()) ?? '';
+ expect(text).not.toMatch(new RegExp(`\\b${residual}\\b`));
+ }
+});
+
+test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
+ const res = await page.request.get('/api/schools?search=school&per_page=100');
+ const body = await res.json();
+ const noSixthForm = (body.schools ?? [])
+ .filter((s: { phase?: string; has_sixth_form?: boolean }) =>
+ s.phase === 'Secondary' && s.has_sixth_form === false)
+ .map((s: { urn: number }) => String(s.urn));
+ test.skip(noSixthForm.length === 0, 'no sixth-form-less secondary in this dataset');
+
+ await page.goto(`/school/${noSixthForm[0]}`);
+ await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
+ // Absence is the correct statement, so there must be no placeholder either.
+ await expect(page.locator('#post16-destinations')).toHaveCount(0);
+ await expect(page.getByText(/destination data coming soon/i)).toHaveCount(0);
+});
+
+test('the destinations section never claims a pupil stayed at this school', async ({ page }) => {
+ const { urn } = await secondaryWithDestinations(page);
+ await page.goto(`/school/${urn}`);
+ const section = page.locator('#destinations');
+ await expect(section).toBeVisible({ timeout: 15_000 });
+ // The published file records the TYPE of place a leaver went to, never which
+ // one, so the page can never say a pupil stayed on here.
+ const text = (await section.textContent()) ?? '';
+ expect(text).not.toMatch(/stayed on (here|at this school)/i);
+});
diff --git a/nextjs-app/__tests__/components/DestinationsSection.test.tsx b/nextjs-app/__tests__/components/DestinationsSection.test.tsx
new file mode 100644
index 0000000..d744423
--- /dev/null
+++ b/nextjs-app/__tests__/components/DestinationsSection.test.tsx
@@ -0,0 +1,149 @@
+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 } },
+};
+
+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),
+ ],
+ },
+ },
+};
+
+describe('DestinationsSection', () => {
+ it('dates its own cohort so it is not read as stale next to the GCSE section', () => {
+ render();
+ expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
+ });
+
+ it('renders one bar segment per published category', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
+ });
+
+ it('renders NO bar at all when a category is withheld', () => {
+ const { container } = render();
+ // R1: a bar with a gap in it publishes the withheld figure by its width.
+ expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0);
+ expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0);
+ });
+
+ it('never states the remainder for a partially suppressed group', () => {
+ const { container } = render();
+ // 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere.
+ expect(container.textContent).not.toMatch(/\b21\b/);
+ });
+
+ it('shows a card value for a group whose components are all published', () => {
+ render();
+ // academic route = 75 + 21 = 96 of 180 = 53%
+ expect(screen.getByText('53%')).toBeInTheDocument();
+ });
+
+ it('refuses a card value when one of its components is withheld', () => {
+ render();
+ // academic route needs sixth_form_college, which is suppressed.
+ expect(screen.getByText(/not published/i)).toBeInTheDocument();
+ expect(screen.queryByText('53%')).not.toBeInTheDocument();
+ });
+
+ it('never claims a pupil stayed at this school', () => {
+ const { container } = render();
+ // The published file reports destination TYPE, never destination institution.
+ expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i);
+ });
+
+ it('renders nothing when no group carries categories', () => {
+ const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
+
+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/__tests__/components/Post16DestinationsSection.test.tsx b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx
new file mode 100644
index 0000000..77e8e1c
--- /dev/null
+++ b/nextjs-app/__tests__/components/Post16DestinationsSection.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+import { Post16DestinationsSection } from '@/components/school/Post16DestinationsSection';
+import type { DestinationPhase } from '@/lib/types';
+
+const phase: DestinationPhase = {
+ 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' },
+ ],
+ },
+ },
+};
+
+describe('Post16DestinationsSection', () => {
+ it('names the Year 13 cohort, not Year 11', () => {
+ const { container } = render();
+ expect(container.textContent).toMatch(/Year 13/);
+ expect(container.textContent).not.toMatch(/Year 11/);
+ });
+
+ it('reports higher education destinations', () => {
+ render();
+ expect(screen.getByText(/UK higher education/i)).toBeInTheDocument();
+ });
+
+ it('uses its own anchor so the nav does not collide with After Year 11', () => {
+ const { container } = render();
+ expect(container.querySelector('#post16-destinations')).toBeTruthy();
+ expect(container.querySelector('#destinations')).toBeNull();
+ });
+
+ it('renders nothing when no group carries categories', () => {
+ const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/nextjs-app/__tests__/components/darkThemeSafety.test.ts b/nextjs-app/__tests__/components/darkThemeSafety.test.ts
index 8904e8c..b33ba70 100644
--- a/nextjs-app/__tests__/components/darkThemeSafety.test.ts
+++ b/nextjs-app/__tests__/components/darkThemeSafety.test.ts
@@ -162,3 +162,31 @@ describe('third-party surfaces under themed text', () => {
expect(offenders).toEqual([]);
});
});
+
+/**
+ * Destination measures add the first new colour family since the palette was
+ * set. The tokens have to exist in both blocks or the section renders one
+ * theme's fills on the other theme's ground — the exact failure the suite
+ * above exists to catch, but for tokens rather than literals.
+ */
+describe('destination tokens', () => {
+ const css = fs.readFileSync(
+ path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8');
+
+ const TOKENS = [
+ '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
+ '--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch',
+ ];
+
+ const DARK_AT = css.indexOf('@media (prefers-color-scheme: dark)');
+
+ it('defines every destination token in the light palette', () => {
+ const light = css.slice(0, DARK_AT);
+ expect(TOKENS.filter((t) => !light.includes(`${t}:`))).toEqual([]);
+ });
+
+ it('redefines every destination token for dark', () => {
+ const dark = css.slice(DARK_AT);
+ expect(TOKENS.filter((t) => !dark.includes(`${t}:`))).toEqual([]);
+ });
+});
diff --git a/nextjs-app/__tests__/lib/destinations.test.ts b/nextjs-app/__tests__/lib/destinations.test.ts
new file mode 100644
index 0000000..def1949
--- /dev/null
+++ b/nextjs-app/__tests__/lib/destinations.test.ts
@@ -0,0 +1,87 @@
+import {
+ canAggregate, aggregateCells,
+ canRenderBar, toBarSegments, CARD_GROUPS,
+ type DestinationCell, type DestinationGroup, type DestinationCategory,
+} from '@/lib/destinations';
+
+const pub = (category: DestinationCategory, pupils: number, cohort: number): DestinationCell => ({
+ category, pupils, percentage: (pupils / cohort) * 100, status: 'published',
+});
+const sup = (category: DestinationCategory): 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),
+ ],
+});
+
+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('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');
+ });
+});
diff --git a/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts
new file mode 100644
index 0000000..c91584d
--- /dev/null
+++ b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts
@@ -0,0 +1,83 @@
+import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections';
+import type { School, SchoolDestinations } from '@/lib/types';
+
+const schoolInfo = {
+ urn: 137083, school_name: 'Northbrook Academy', phase: 'Secondary',
+ has_sixth_form: true,
+} as unknown as School;
+
+const base = { schoolInfo, yearlyData: [], deprivation: null, finance: null };
+
+const phase = (categories = 1) => ({
+ cohort_year: '2022/23',
+ groups: {
+ all: {
+ cohort: 180,
+ categories: Array.from({ length: categories }, () => ({
+ category: 'school_sixth_form' as const,
+ pupils: 75, percentage: 41.7, status: 'published' as const,
+ })),
+ },
+ },
+});
+
+const ks4Only: SchoolDestinations = { ks4: phase(), ks5: null };
+const both: SchoolDestinations = { ks4: phase(), ks5: phase() };
+
+describe('computeSecondaryFlags — destinations', () => {
+ it('flags KS4 destinations when the block carries categories', () => {
+ const flags = computeSecondaryFlags({ ...base, destinations: ks4Only });
+ expect(flags.hasKs4Destinations).toBe(true);
+ expect(flags.hasKs5Destinations).toBe(false);
+ });
+
+ it('flags both phases when both are present', () => {
+ const flags = computeSecondaryFlags({ ...base, destinations: both });
+ expect(flags.hasKs4Destinations).toBe(true);
+ expect(flags.hasKs5Destinations).toBe(true);
+ });
+
+ it('flags neither when the block is absent', () => {
+ const flags = computeSecondaryFlags({ ...base, destinations: null });
+ expect(flags.hasKs4Destinations).toBe(false);
+ expect(flags.hasKs5Destinations).toBe(false);
+ });
+
+ it('does not flag a phase whose groups carry no categories', () => {
+ const empty: SchoolDestinations = {
+ ks4: { cohort_year: '2022/23', groups: {} }, ks5: null,
+ };
+ expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
+ .toBe(false);
+ });
+
+ it('does not flag a phase whose only group has an empty category list', () => {
+ const empty: SchoolDestinations = { ks4: phase(0), ks5: null };
+ expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
+ .toBe(false);
+ });
+});
+
+describe('buildSecondaryNavItems — destinations', () => {
+ const navInput = {
+ ofsted: null, admissions: null, admissionDistance: null,
+ hasLocation: false, yearlyDataLength: 0,
+ };
+
+ it('adds both entries, after GCSEs', () => {
+ const flags = computeSecondaryFlags({ ...base, destinations: both });
+ const ids = buildSecondaryNavItems({ ...flags, hasResults: true }, navInput)
+ .map(i => i.id);
+ expect(ids).toContain('destinations');
+ expect(ids).toContain('post16-destinations');
+ expect(ids.indexOf('destinations')).toBeGreaterThan(ids.indexOf('gcse'));
+ expect(ids.indexOf('post16-destinations')).toBe(ids.indexOf('destinations') + 1);
+ });
+
+ it('adds no entry for a phase that will not render — the nav must not link to a missing anchor', () => {
+ const flags = computeSecondaryFlags({ ...base, destinations: null });
+ const ids = buildSecondaryNavItems(flags, navInput).map(i => i.id);
+ expect(ids).not.toContain('destinations');
+ expect(ids).not.toContain('post16-destinations');
+ });
+});
diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css
index 7800688..327e68c 100644
--- a/nextjs-app/app/globals.css
+++ b/nextjs-app/app/globals.css
@@ -105,6 +105,23 @@
--series-7: #0E7A86;
--series-8: #8A4A6B;
+ /* ── Destination measures ───────────────────────────────────────────
+ Education is one hue in three steps (school-like -> college-like) so the
+ education destinations read as one family; apprenticeship and employment
+ are separate hues. The absence is neutral and HATCHED, never a colour:
+ "activity not captured" covers independent schools, moving abroad and
+ training DfE holds no data on, so rendering it as a bad outcome would be
+ a factual error. The hatch is also the secondary encoding that rescues
+ the neutral/blue pair, which separates at only dE 7.6 as flat fills.
+ Every other adjacent pair clears dE 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);
+
/* ── Phase: category, desaturated so it stays under the status hues ── */
--phase-primary: #0F766E;
--phase-primary-bg: rgba(167, 215, 197, 0.40);
@@ -293,6 +310,17 @@
--series-7: #6FD0DC;
--series-8: #D99BB8;
+ /* Destinations. Not a naive inversion: the education ramp reverses
+ direction so its darkest step stays the one furthest from the
+ school, and each step is re-checked against the dark card. */
+ --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);
+
--phase-primary: #5FC7BB;
--phase-primary-bg: rgba(95, 199, 187, 0.16);
--phase-primary-text: #8ADACF;
diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx
index bef695a..be0674f 100644
--- a/nextjs-app/app/school/[slug]/page.tsx
+++ b/nextjs-app/app/school/[slug]/page.tsx
@@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
- const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
+ const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -171,6 +171,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
schoolInfo: school_info, yearlyData: yearly_data,
absenceData: absence_data, census: census ?? null,
deprivation: deprivation ?? null, finance: finance ?? null,
+ destinations: destinations ?? null,
};
const primaryFlags = computeSchoolFlags(sectionInput);
const secondaryFlags = computeSecondaryFlags(sectionInput);
@@ -236,6 +237,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
+ destinations={destinations ?? null}
flags={secondaryFlags}
/>
diff --git a/nextjs-app/components/school/DestinationsSection.tsx b/nextjs-app/components/school/DestinationsSection.tsx
new file mode 100644
index 0000000..ef8c4f4
--- /dev/null
+++ b/nextjs-app/components/school/DestinationsSection.tsx
@@ -0,0 +1,38 @@
+/**
+ * DestinationsSection — where a school's Year 11 leavers went. Server component.
+ *
+ * The headline is deliberately NOT the sustained-destination rate. That figure
+ * sits between 92% and 97% for nearly every school in England, so leading with
+ * it would say nothing; the mix is what actually varies between schools.
+ *
+ * The section dates its own cohort because destination measures are published
+ * about two GCSE years behind the results in the section above — undated, the
+ * figures read as stale rather than as a different question.
+ */
+
+import type { DestinationPhase } from '@/lib/types';
+import { Section, sectionStyles } from './sectionShared';
+import { DestinationsView } from './DestinationsView';
+
+export function DestinationsSection({ destinations }: { destinations: DestinationPhase }) {
+ const all = destinations.groups.all;
+ const hasContent = Object.values(destinations.groups)
+ .some(group => (group?.categories?.length ?? 0) > 0);
+ if (!hasContent) return null;
+
+ const cohort = all?.cohort ?? null;
+ const year = destinations.cohort_year;
+
+ return (
+
+
After Year 11
+
+ Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11
+ {year ? ` in ${year}` : ''} were during the following year. The Department
+ for Education tracks leavers for two terms, so these figures cover an
+ earlier year group than the GCSE results above.
+
+
+
+ );
+}
diff --git a/nextjs-app/components/school/DestinationsView.tsx b/nextjs-app/components/school/DestinationsView.tsx
new file mode 100644
index 0000000..f538d57
--- /dev/null
+++ b/nextjs-app/components/school/DestinationsView.tsx
@@ -0,0 +1,261 @@
+'use client';
+
+/**
+ * DestinationsView — the interactive body of both destination sections.
+ *
+ * Three question cards over one bar, with the cards acting as a lens on the
+ * bar rather than a summary beside it: focusing a card dims everything the
+ * card is not made of, so the grouping we chose is inspectable rather than
+ * asserted.
+ *
+ * Everything here defers to lib/destinations.ts for what may be shown. In
+ * particular the bar is rendered only when canRenderBar() allows it: the
+ * destination categories sum to the cohort, so a bar drawn from the published
+ * segments leaves a gap whose width IS the withheld figure.
+ *
+ * The one client component in this directory besides AdmissionsViewToggle.
+ * The all-pupils view is what the server renders into the HTML; the switch and
+ * the hover linkage are the only parts that need the browser.
+ */
+
+import { useState } from 'react';
+import type { DestinationPhase, DestinationGroupPayload } from '@/lib/types';
+import {
+ CARD_GROUPS, CARD_QUESTIONS, CATEGORY_LABELS, CATEGORY_ORDER,
+ aggregateCells, canRenderBar, toBarSegments, cardGroupFor,
+ type CardGroup, type DestinationCell, type DestinationGroup, type PupilGroup,
+} from '@/lib/destinations';
+import styles from './destinations.module.css';
+
+const GROUP_LABELS: Record = {
+ all: 'All pupils',
+ disadvantaged: 'Disadvantaged',
+ other: 'All other pupils',
+};
+
+const GROUP_ORDER: PupilGroup[] = ['all', 'disadvantaged', 'other'];
+
+function toGroup(payload: DestinationGroupPayload): DestinationGroup {
+ return {
+ cohort: payload.cohort ?? 0,
+ cells: payload.categories,
+ };
+}
+
+function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
+ const wanted = new Set(CARD_GROUPS[card]);
+ 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' }) {
+ const available = GROUP_ORDER.filter(
+ g => (destinations.groups[g]?.categories?.length ?? 0) > 0,
+ );
+ const [selected, setSelected] = useState(available[0] ?? 'all');
+ const [focused, setFocused] = useState(null);
+
+ const payload = destinations.groups[selected];
+ if (!payload) return null;
+ const group = toGroup(payload);
+
+ const barDrawable = canRenderBar(group);
+ const segments = barDrawable ? toBarSegments(group) : [];
+ const withheld = group.cells.filter(c => c.status === 'suppressed');
+
+ const dimmed = (card: CardGroup | null) => focused !== null && focused !== card;
+
+ return (
+
+ {withheld.length === 1
+ ? 'One of the destinations is withheld'
+ : `${withheld.length} of the destinations are withheld`}
+ {' '}because too few pupils went there. These destinations add up to
+ the whole year group, so drawing the rest as a chart would give the
+ withheld figures away. The table below shows what was published,
+ and nothing more.
+
+ Shares are rounded and may not add up to 100%. A pupil counted under a
+ school sixth form may have moved to a different school's sixth
+ form — the published data records the type of place, not which one.
+
+
+ );
+}
diff --git a/nextjs-app/components/school/Post16DestinationsSection.tsx b/nextjs-app/components/school/Post16DestinationsSection.tsx
new file mode 100644
index 0000000..e5113eb
--- /dev/null
+++ b/nextjs-app/components/school/Post16DestinationsSection.tsx
@@ -0,0 +1,40 @@
+/**
+ * Post16DestinationsSection — where a school's Year 13 leavers went.
+ * Server component.
+ *
+ * A separate publication, a separate cohort and a separate question from
+ * After Year 11, so it is a separate section rather than a tab: a parent
+ * choosing a secondary and a student choosing a sixth form are not the same
+ * reader.
+ *
+ * Not rendered at all for a school without post-16 provision. A "no data"
+ * placeholder there would imply something is missing, when the truthful
+ * statement is that the question does not apply — which is why the old
+ * "Post-16 destination data coming soon" note is gone rather than reworded.
+ */
+
+import type { DestinationPhase } from '@/lib/types';
+import { Section, sectionStyles } from './sectionShared';
+import { DestinationsView } from './DestinationsView';
+
+export function Post16DestinationsSection({
+ destinations,
+}: { destinations: DestinationPhase }) {
+ const hasContent = Object.values(destinations.groups)
+ .some(group => (group?.categories?.length ?? 0) > 0);
+ if (!hasContent) return null;
+
+ const cohort = destinations.groups.all?.cohort ?? null;
+ const year = destinations.cohort_year;
+
+ return (
+
+
After the sixth form
+
+ Where {cohort ? `the ${cohort} students` : 'the students'} who finished
+ Year 13{year ? ` in ${year}` : ''} went next.
+
+
+
+ );
+}
diff --git a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
index 63471dc..b787a03 100644
--- a/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
+++ b/nextjs-app/components/school/SecondaryAdmissionsSection.tsx
@@ -15,7 +15,7 @@ import {
} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
- admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
+ admissions, admissionsHistory, admissionDistance, schoolInfo,
}: {
/* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
@@ -23,7 +23,6 @@ export function SecondaryAdmissionsSection({
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null | undefined;
schoolInfo: School;
- hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
/* Absent means cut-offs are not being published at all; null means this
@@ -118,11 +117,6 @@ export function SecondaryAdmissionsSection({
) : null}
- {hasSixthForm && (
-
- This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
-