Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9423f11567 | ||
|
|
576013d627 | ||
|
|
4fdeb70a93 | ||
|
|
9a1f56c431 | ||
|
|
ade9dbb3ba |
No files matched your search
+15
-2
@@ -1337,9 +1337,22 @@ async def get_place(request: Request, kind: str, slug: str,
|
|||||||
for m in ("rwm_expected_pct", "attainment_8_score")
|
for m in ("rwm_expected_pct", "attainment_8_score")
|
||||||
}
|
}
|
||||||
|
|
||||||
cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
# dict.fromkeys, not a list: SCHOOL_COLUMNS already ends with latitude and
|
||||||
|
# longitude, so concatenating them again selected each twice and pandas
|
||||||
|
# dropped one of every duplicated pair with a "columns are not unique"
|
||||||
|
# warning. Ordered de-duplication keeps the column order and the warning
|
||||||
|
# cannot come back.
|
||||||
|
#
|
||||||
|
# nursery_provision and parliamentary_constituency are not in
|
||||||
|
# SCHOOL_COLUMNS and the place table shows both. The `in rows.columns`
|
||||||
|
# guard is what keeps a mart the pipeline has not rebuilt working: those
|
||||||
|
# two are the optional GIAS columns data_loader degrades to NULL.
|
||||||
|
cols = [c for c in dict.fromkeys(
|
||||||
|
SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
||||||
|
"nursery_provision",
|
||||||
|
"parliamentary_constituency",
|
||||||
"rwm_expected_pct", "attainment_8_score",
|
"rwm_expected_pct", "attainment_8_score",
|
||||||
"total_pupils"]
|
"total_pupils"])
|
||||||
if c in rows.columns]
|
if c in rows.columns]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -137,3 +137,49 @@ def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_cli
|
|||||||
by_name = {a["name"]: a for a in body["place"]["authorities"]}
|
by_name = {a["name"]: a for a in body["place"]["authorities"]}
|
||||||
assert by_name["Essex"]["slug"] == "essex"
|
assert by_name["Essex"]["slug"] == "essex"
|
||||||
assert by_name["Isles Of Scilly"]["slug"] is None
|
assert by_name["Isles Of Scilly"]["slug"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _attributed_df() -> pd.DataFrame:
|
||||||
|
"""The same town, with the four attributes the place table now shows."""
|
||||||
|
df = _schools_df()
|
||||||
|
df["age_range"] = "4-11"
|
||||||
|
df["religious_denomination"] = "Church of England"
|
||||||
|
df["nursery_provision"] = True
|
||||||
|
df["parliamentary_constituency"] = "Brentwood and Ongar"
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def attributed_client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _attributed_df)
|
||||||
|
monkeypatch.setattr(app_module, "load_latest_school_data", _attributed_df)
|
||||||
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_place_detail_carries_the_attributes_the_table_shows(attributed_client):
|
||||||
|
"""age_range and religious_denomination ride in on SCHOOL_COLUMNS.
|
||||||
|
|
||||||
|
nursery_provision and parliamentary_constituency do not, and the place
|
||||||
|
table needs all four — a column the response cannot fill is a column of
|
||||||
|
dashes on ~3,900 pages.
|
||||||
|
"""
|
||||||
|
body = attributed_client.get("/api/places/town/brentwood").json()
|
||||||
|
school = body["schools"][0]
|
||||||
|
assert school["age_range"] == "4-11"
|
||||||
|
assert school["religious_denomination"] == "Church of England"
|
||||||
|
assert school["nursery_provision"] is True
|
||||||
|
assert school["parliamentary_constituency"] == "Brentwood and Ongar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_place_detail_survives_a_mart_without_the_optional_columns(client):
|
||||||
|
"""The base fixture has neither column, as an unrebuilt mart does not.
|
||||||
|
|
||||||
|
data_loader degrades those to NULL rather than failing the load, so the
|
||||||
|
endpoint must not assume they are present.
|
||||||
|
"""
|
||||||
|
res = client.get("/api/places/town/brentwood")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "nursery_provision" not in res.json()["schools"][0]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,352 @@
|
|||||||
|
# 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:
|
||||||
|
<https://claude.ai/code/artifact/5be149d6-252f-473c-9a4f-4c36b05161b0>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
Two rules follow, and everything else in this document is downstream of them.
|
||||||
|
|
||||||
|
**R1 — Never render a derived remainder.** Not as a number, and not as a bar
|
||||||
|
segment: a segment sized by the residual can be read straight off the axis. Where
|
||||||
|
any category in a pupil group is suppressed, the page shows the published
|
||||||
|
categories, says the rest are withheld, and stops.
|
||||||
|
|
||||||
|
**R2 — Never aggregate across a suppression boundary.** A group total is
|
||||||
|
publishable only when DfE published that total itself, or when the aggregate
|
||||||
|
spans **two or more** suppressed cells. Summing published components to fill a
|
||||||
|
gap is R1 with extra steps.
|
||||||
|
|
||||||
|
**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 | Published | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| All pupils, all categories | **100%** | Full bar works everywhere |
|
||||||
|
| Disadvantaged, headline rate | 95% | Gap panel works |
|
||||||
|
| Disadvantaged, three grouped cards | 68% | Degrades card by card |
|
||||||
|
| Disadvantaged, all six categories | **20%** | Bar unusable for this group |
|
||||||
|
|
||||||
|
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 route is someone
|
||||||
|
applying `safe_numeric` to a destination column for consistency, or adding a
|
||||||
|
`coalesce` in a mart. Mitigation is the dbt test plus the unit tests on
|
||||||
|
`canAggregate()` — the rule has to be executable, not documentary.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -1978,6 +1978,59 @@ test('a place page links its phase variants, and they resolve', async ({ page })
|
|||||||
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
|
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The table shipped with one column of scores. A parent shortlisting from a
|
||||||
|
* town page needs to know whether a school takes their child's age, whether
|
||||||
|
* it is a faith school, and — for a primary — whether it has a nursery,
|
||||||
|
* before a percentage means anything.
|
||||||
|
*
|
||||||
|
* These assert the column headings rather than the values: nursery_provision
|
||||||
|
* and parliamentary_constituency are optional mart columns, and on an
|
||||||
|
* environment whose pipeline has not rebuilt them the API degrades them to
|
||||||
|
* absent. A value assertion would then fail for a data reason, not a code one.
|
||||||
|
*/
|
||||||
|
async function phasedPlace(page: Page, phase: 'primary' | 'secondary') {
|
||||||
|
const place = await firstPlaceOfKind(page, 'town');
|
||||||
|
const detail = await (await page.request.get(`/api/places/town/${place.slug}`)).json();
|
||||||
|
test.skip(!(detail.place.phases ?? []).includes(phase),
|
||||||
|
`no ${phase} page clears the threshold here`);
|
||||||
|
return place;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a primary place page names each school as well as scoring it', async ({ page }) => {
|
||||||
|
const place = await phasedPlace(page, 'primary');
|
||||||
|
await page.goto(`/schools/${place.slug}/primary`);
|
||||||
|
for (const heading of ['Ages', 'Religious character', 'Nursery', 'Constituency']) {
|
||||||
|
await expect(page.getByRole('columnheader', { name: heading, exact: true }))
|
||||||
|
.toBeVisible();
|
||||||
|
}
|
||||||
|
// age_range rides in on SCHOOL_COLUMNS and predates the optional columns,
|
||||||
|
// so it is the one attribute safe to assert a value for anywhere.
|
||||||
|
await expect(page.locator('table tbody td').filter({ hasText: /^\d+–\d+$/ }).first())
|
||||||
|
.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secondary place page does not ask about nurseries', async ({ page }) => {
|
||||||
|
const place = await phasedPlace(page, 'secondary');
|
||||||
|
await page.goto(`/schools/${place.slug}/secondary`);
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Ages', exact: true }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Nursery', exact: true }))
|
||||||
|
.toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the measure stays beside the school name, not behind a swipe', async ({ page }) => {
|
||||||
|
// Six columns overflow a phone; .tableWrap turns that into a horizontal
|
||||||
|
// scroll. With the measure last, the number the page exists for is the one
|
||||||
|
// off the screen.
|
||||||
|
const place = await phasedPlace(page, 'primary');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(`/schools/${place.slug}/primary`);
|
||||||
|
const second = page.locator('table thead th').nth(1);
|
||||||
|
await expect(second).toContainText(/reading, writing/i);
|
||||||
|
await expect(second).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
test('phase variants are submitted in the places sitemap', async ({ page }) => {
|
test('phase variants are submitted in the places sitemap', async ({ page }) => {
|
||||||
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
||||||
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
|
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
|
||||||
|
|||||||
@@ -346,3 +346,134 @@ describe('PlaceView unlinkable authorities', () => {
|
|||||||
.toContain('Isles Of Scilly');
|
.toContain('Isles Of Scilly');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PlaceView school attributes', () => {
|
||||||
|
/*
|
||||||
|
* The table shipped with one column of scores, which answers "how did they
|
||||||
|
* do" and nothing about whether the school is one a family could use. Age
|
||||||
|
* range, faith, nursery and constituency are the four facts a parent
|
||||||
|
* filters on before they look at a number at all.
|
||||||
|
*/
|
||||||
|
const withAttributes: PlaceDetail = {
|
||||||
|
place: { kind: 'town', slug: 'chelmsford', name: 'Chelmsford', count: 3,
|
||||||
|
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
||||||
|
schools: [
|
||||||
|
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||||
|
rwm_expected_pct: 82, attainment_8_score: null,
|
||||||
|
age_range: '4-11', religious_denomination: 'Church of England',
|
||||||
|
nursery_provision: true,
|
||||||
|
parliamentary_constituency: 'Chelmsford' } as never,
|
||||||
|
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
||||||
|
rwm_expected_pct: null, attainment_8_score: 47,
|
||||||
|
age_range: '11-16', religious_denomination: 'Does not apply',
|
||||||
|
nursery_provision: false,
|
||||||
|
parliamentary_constituency: 'Witham' } as never,
|
||||||
|
],
|
||||||
|
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function headings(container: HTMLElement, table = 0): string[] {
|
||||||
|
return Array.from(container.querySelectorAll('table')[table]
|
||||||
|
.querySelectorAll('thead th')).map((th) => th.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('heads a primary table with all four attributes', () => {
|
||||||
|
const { container } = render(<PlaceView detail={withAttributes}
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
expect(headings(container)).toEqual([
|
||||||
|
'School', 'Reading, writing & maths',
|
||||||
|
'Ages', 'Religious character', 'Nursery', 'Constituency',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits nursery from a secondary table, where it does not apply', () => {
|
||||||
|
const { container } = render(<PlaceView detail={withAttributes}
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
expect(headings(container, 1)).toEqual([
|
||||||
|
'School', 'Attainment 8', 'Ages', 'Religious character', 'Constituency',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the measure beside the school name, where a phone can see it', () => {
|
||||||
|
// Six columns overflow a phone and .tableWrap turns that into a swipe.
|
||||||
|
// With the measure last, the one number the page exists for is the one
|
||||||
|
// scrolled off the screen.
|
||||||
|
const { container } = render(<PlaceView detail={withAttributes}
|
||||||
|
phase="primary" englandAverage={61} neighbours={[]} />);
|
||||||
|
expect(headings(container)[1]).toBe('Reading, writing & maths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the age range without repeating the column heading', () => {
|
||||||
|
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByText('4–11')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Ages 4–11')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the faith of a faith school', () => {
|
||||||
|
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByText('Church of England')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads "Does not apply" as no religious character, not as a value', () => {
|
||||||
|
// GIAS spells the absence of a faith as "Does not apply", which is a
|
||||||
|
// database answer rather than an English one. The school page already
|
||||||
|
// suppresses it; the two must not disagree about the same school.
|
||||||
|
const { container } = render(<PlaceView detail={withAttributes}
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
const secondary = container.querySelectorAll('table')[1]
|
||||||
|
.querySelectorAll('tbody td');
|
||||||
|
expect(secondary[3].textContent).toBe('—');
|
||||||
|
expect(screen.queryByText(/Does not apply/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a nursery as such and a school without one as not', () => {
|
||||||
|
const { container } = render(<PlaceView detail={withAttributes}
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
const cells = container.querySelectorAll('table')[0]
|
||||||
|
.querySelectorAll('tbody td');
|
||||||
|
expect(cells[4].textContent).toBe('Yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the constituency of each school', () => {
|
||||||
|
render(<PlaceView detail={withAttributes} englandAverage={61}
|
||||||
|
neighbours={[]} />);
|
||||||
|
expect(screen.getByText('Chelmsford', { selector: 'td' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Witham', { selector: 'td' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dashes an attribute the data does not carry', () => {
|
||||||
|
// nursery_provision and parliamentary_constituency are absent from marts
|
||||||
|
// the pipeline has not rebuilt, and the API degrades them to null rather
|
||||||
|
// than failing. A row must survive that.
|
||||||
|
const bare: PlaceDetail = {
|
||||||
|
...withAttributes,
|
||||||
|
schools: [{ urn: 3, school_name: 'Gamma Primary', phase: 'Primary',
|
||||||
|
rwm_expected_pct: 70 } as never],
|
||||||
|
};
|
||||||
|
const { container } = render(<PlaceView detail={bare} phase="primary"
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
const cells = Array.from(container.querySelectorAll('tbody td'))
|
||||||
|
.map((td) => td.textContent);
|
||||||
|
expect(cells.slice(2)).toEqual(['—', '—', '—', '—']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an all-through school its nursery under primary only', () => {
|
||||||
|
// All-through schools render in both groups. Nursery belongs to the
|
||||||
|
// primary reading of the same school, not the secondary one.
|
||||||
|
const allThrough: PlaceDetail = {
|
||||||
|
...withAttributes,
|
||||||
|
schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through',
|
||||||
|
rwm_expected_pct: 66, attainment_8_score: 51,
|
||||||
|
age_range: '4-18', religious_denomination: 'None',
|
||||||
|
nursery_provision: true,
|
||||||
|
parliamentary_constituency: 'Chelmsford' } as never],
|
||||||
|
};
|
||||||
|
const { container } = render(<PlaceView detail={allThrough}
|
||||||
|
englandAverage={61} neighbours={[]} />);
|
||||||
|
const tables = container.querySelectorAll('table');
|
||||||
|
expect(tables[0].textContent).toContain('Yes');
|
||||||
|
expect(tables[1].textContent).not.toContain('Yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
metricKind,
|
metricKind,
|
||||||
shortName,
|
shortName,
|
||||||
computeYBounds,
|
computeYBounds,
|
||||||
|
formatAgeRange,
|
||||||
|
formatAgeSpan,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
|
||||||
describe('formatPercentage', () => {
|
describe('formatPercentage', () => {
|
||||||
@@ -320,3 +322,27 @@ describe('shortName', () => {
|
|||||||
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatAgeSpan', () => {
|
||||||
|
it('normalises a hyphenated range to an en dash, without a label', () => {
|
||||||
|
// The place table carries "Ages" in the column heading, so repeating it
|
||||||
|
// in every cell is noise. formatAgeRange keeps the label for the contexts
|
||||||
|
// that have no heading to hang it on.
|
||||||
|
expect(formatAgeSpan('4-11')).toBe('4–11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a range it does not recognise alone rather than mangling it', () => {
|
||||||
|
expect(formatAgeSpan('3-19 (SEN)')).toBe('3-19 (SEN)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for a missing range', () => {
|
||||||
|
expect(formatAgeSpan(null)).toBe('');
|
||||||
|
expect(formatAgeSpan(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAgeRange', () => {
|
||||||
|
it('keeps its label, so the two helpers stay distinguishable', () => {
|
||||||
|
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -164,6 +164,34 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attribute columns. Muted, because they qualify the row rather than compete
|
||||||
|
* with the measure for it, and hugging their content so the school name keeps
|
||||||
|
* the spare width — the same width:1% trick as .num, which is what stops six
|
||||||
|
* columns from splitting evenly and squeezing the names into two lines each.
|
||||||
|
*
|
||||||
|
* .attr never wraps: "4–11" and "Yes" broken across lines read as two values.
|
||||||
|
* .attrWide may — "Church of England" and some constituency names are long
|
||||||
|
* enough that forcing one line would push the measure off a phone screen.
|
||||||
|
*/
|
||||||
|
.table th.attr,
|
||||||
|
.table td.attr,
|
||||||
|
.table th.attrWide,
|
||||||
|
.table td.attrWide {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 1%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th.attr,
|
||||||
|
.table td.attr {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th.attrWide,
|
||||||
|
.table td.attrWide {
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* The measure is spelled out; the tooltip carries the definition. */
|
/* The measure is spelled out; the tooltip carries the definition. */
|
||||||
.metricHead {
|
.metricHead {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Link from 'next/link';
|
|||||||
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
|
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
|
||||||
import { placeUrl, authoritySlug } from '@/lib/places';
|
import { placeUrl, authoritySlug } from '@/lib/places';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { schoolUrl } from '@/lib/utils';
|
import { schoolUrl, formatAgeSpan } from '@/lib/utils';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import { TrackPlaceView } from './TrackPlaceView';
|
import { TrackPlaceView } from './TrackPlaceView';
|
||||||
import styles from './PlaceView.module.css';
|
import styles from './PlaceView.module.css';
|
||||||
@@ -64,8 +64,31 @@ function isPhase(school: School, phase: PhaseKey): boolean {
|
|||||||
: p.includes('primary') || p.includes('middle');
|
: p.includes('primary') || p.includes('middle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* GIAS spells the absence of a faith as "Does not apply", and sometimes
|
||||||
|
* "None" or "Not applicable" — database answers, not English ones. The school
|
||||||
|
* page and the comparison already suppress all three; this is the same rule,
|
||||||
|
* so the two surfaces cannot disagree about the same school.
|
||||||
|
*/
|
||||||
|
const NO_FAITH = /^(none|does not apply|not applicable)$/i;
|
||||||
|
|
||||||
|
/** An attribute the data does not carry. Distinct from the measure's "Not
|
||||||
|
* published": four of those per row would drown the row it qualifies. */
|
||||||
|
const NO_VALUE = '—';
|
||||||
|
|
||||||
|
function faithOf(school: School): string {
|
||||||
|
const denom = school.religious_denomination ?? '';
|
||||||
|
return denom && !NO_FAITH.test(denom) ? denom : NO_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
|
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
|
||||||
const metric = METRICS[phase];
|
const metric = METRICS[phase];
|
||||||
|
/*
|
||||||
|
* Nursery is a primary question. An all-through school renders in both
|
||||||
|
* groups, and its nursery belongs to the primary reading of it — under
|
||||||
|
* "Secondary schools" the column would be a fact about a different intake.
|
||||||
|
*/
|
||||||
|
const showNursery = phase === 'primary';
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
@@ -79,6 +102,13 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
|
|||||||
{metric.heading}
|
{metric.heading}
|
||||||
</abbr>
|
</abbr>
|
||||||
</th>
|
</th>
|
||||||
|
{/* The measure sits second, not last. Six columns overflow a
|
||||||
|
phone and .tableWrap turns that into a swipe; last would put
|
||||||
|
the one number the page exists for off the screen. */}
|
||||||
|
<th scope="col" className={styles.attr}>Ages</th>
|
||||||
|
<th scope="col" className={styles.attrWide}>Religious character</th>
|
||||||
|
{showNursery && <th scope="col" className={styles.attr}>Nursery</th>}
|
||||||
|
<th scope="col" className={styles.attrWide}>Constituency</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -96,6 +126,19 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
|
|||||||
? <span className={styles.noData}>Not published</span>
|
? <span className={styles.noData}>Not published</span>
|
||||||
: `${Math.round(Number(value))}${metric.unit}`}
|
: `${Math.round(Number(value))}${metric.unit}`}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={styles.attr}>{formatAgeSpan(s.age_range) || NO_VALUE}</td>
|
||||||
|
<td className={styles.attrWide}>{faithOf(s)}</td>
|
||||||
|
{showNursery && (
|
||||||
|
<td className={styles.attr}>
|
||||||
|
{/* Undefined is a mart the pipeline has not rebuilt, and
|
||||||
|
false is a school without one. Neither is a "Yes", and
|
||||||
|
neither is worth two different words. */}
|
||||||
|
{s.nursery_provision ? 'Yes' : NO_VALUE}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className={styles.attrWide}>
|
||||||
|
{s.parliamentary_constituency || NO_VALUE}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+12
-2
@@ -82,11 +82,21 @@ export function shortName(name: string, maxLength = 32): string {
|
|||||||
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
||||||
* detection) untouched. Falls back to the raw value if it's not a plain range.
|
* detection) untouched. Falls back to the raw value if it's not a plain range.
|
||||||
*/
|
*/
|
||||||
export function formatAgeRange(ageRange: string | null | undefined): string {
|
export function formatAgeSpan(ageRange: string | null | undefined): string {
|
||||||
if (!ageRange) return '';
|
if (!ageRange) return '';
|
||||||
const match = ageRange.match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
const match = ageRange.match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||||||
if (!match) return ageRange;
|
if (!match) return ageRange;
|
||||||
return `Ages ${match[1]}–${match[2]}`;
|
return `${match[1]}–${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same span, labelled — for the places that show it with no column
|
||||||
|
* heading to carry the word "Ages". Delegates so the en-dash normalisation
|
||||||
|
* lives in one place.
|
||||||
|
*/
|
||||||
|
export function formatAgeRange(ageRange: string | null | undefined): string {
|
||||||
|
const span = formatAgeSpan(ageRange);
|
||||||
|
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in new issue
Block a user