Compare commits

..
Author SHA1 Message Date
Tudor 90b7d09086 Merge #183's review fixes into the site branch
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m26s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 52s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
2026-10-05 16:39:34 +01:00
TudorandClaude Opus 5.5 480ac4b9a9 docs: plan names the session's trailer; spec reads the latest visit from dates
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 12s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m27s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 53s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 27s
The plan hard-coded one model's Co-Authored-By trailer; an executor should use the one its own session specifies. The spec's report-card rule now matches int_ofsted_latest. Review on #183.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:24 +01:00
TudorandClaude Opus 5.5 93f17211b1 fix(pipeline): read the latest Ofsted visit from the dates
int_ofsted_latest treated any report card as the latest visit, relying on report cards postdating legacy inspections. That holds today (0 of 2,451 report-card schools in the 31 Aug 2026 MI) but is now not assumed: the latest visit is the newest of the three dates. A report card still leaves no legacy grade in force, which is the spec's rule rather than an inference. New unit test newer_legacy_visit_after_a_report_card failed RED on the old logic. Review on #183.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:24 +01:00
TudorandClaude Opus 5.5 f86d6f45d1 test(e2e): no grade is carried past a no-grade inspection
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m25s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 45s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Rabbsfarm (102408) must read 'Inspected · 2025' with no overall grade, and Robins Lane (104762) must say its Good was confirmed at an ungraded inspection on 18 July 2024. Both fail against staging today (no current_grade field, no confirmation line) and pass once PR #183's pipeline run and this branch are deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:33:53 +01:00
TudorandClaude Opus 5.5 5eed09dfa7 refactor(utils): drop the unused Ofsted hero chip and summary sentence
buildOfstedHeroChip and buildSchoolSummary were rendered nowhere and encoded the old carried-forward rule (and a framework value, 'ReportCard', the API never sends).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:32:53 +01:00
TudorandClaude Opus 5.5 d37baa572b fix(compare): Ofsted rows show the grade still in force and the latest visit
'Inspected' is the school's latest visit, so Washwood Heath reads 21 May 2025 rather than '3 Mar 2020 · 4+ years ago' (audit M1). The Result row names the inspection a grade came from, or says 'No overall grade'; 'Grade carried forward' and 'transitional framework' go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:32:19 +01:00
TudorandClaude Opus 5.5 9ef48da4b1 fix(school): the Ofsted section shows the grade still in force and the latest visit
The section is dated by the latest visit and headlines the grade still in force with the inspection that awarded or confirmed it, or 'No overall grade'. A later visit that isn't the grade's source gets its own line, with an ungraded outcome such as 'Standards maintained'. The primary and secondary no-grade branches merge, so the secondary page no longer drops the sixth-form and early-years judgements (audit M2), and 'Not rated' goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:31:26 +01:00
TudorandClaude Opus 5.5 a42c586cc8 fix(ofsted): badge and compare read the grade still in force
Search badges date a grade by the inspection that awarded or confirmed it (ofsted_grade_date) and say 'Inspected · year' when the latest inspection gave no grade. ofstedDisplay's kinds become graded / confirmed / no_overall_grade, read from current_grade rather than a carried-forward overall_effectiveness. lib/ofstedStatus.ts holds the two sentences the school and compare pages share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:29:54 +01:00
TudorandClaude Opus 5.5 a9e3a6a700 fix(backend): serve the current Ofsted status from fact_ofsted_latest
The list query and the batch Ofsted fetch each picked 'the latest row' of fact_ofsted_inspection themselves, and _ofsted_block carried an older ungraded grade forward when the latest graded inspection gave none. All three now read marts.fact_ofsted_latest. List rows: ofsted_grade is the grade still in force, ofsted_grade_date when it was awarded or confirmed, ofsted_date the latest visit. The ofsted block gains current_grade and latest_visit and loses grade_source; overall_effectiveness is the graded inspection's own result. A school with only an inspection stays publishable in the sitemap.

Requires fact_ofsted_latest (PR #183's pipeline run) on the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:28:30 +01:00
TudorandClaude Opus 5.5 96732c56d1 fix(pipeline): dim_school's Ofsted grade is the one still in force
dim_school.ofsted_grade (Typesense's rating) coalesced the graded grade with an older ungraded visit's, so Rabbsfarm's 2020 'remains Good' survived its 2025 no-grade inspection. It now reads int_ofsted_latest's current_grade, and ofsted_date is the latest visit. Unit tests pin both (audit C1, M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:26:51 +01:00
Tudor befaf950a7 Merge PR 1 (fix/ofsted-current-status-pipeline, #183) into the site branch
PR 2 reads the columns PR 1 adds. Merge main back in once #183 lands.
2026-10-05 16:25:49 +01:00
TudorandClaude Opus 5.5 dfa4928641 feat(pipeline): add fact_ofsted_latest, one current Ofsted status per school
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m18s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m31s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 50s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 32s
The backend will read this instead of picking the latest row of fact_ofsted_inspection itself (twice, with arbitrary ties). Schema tests and assert_ofsted_current_grade_consistent pin its invariants. Built by the monthly Ofsted DAG (int_ofsted_latest+), not the daily one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:24:41 +01:00
TudorandClaude Opus 5.5 e6babe21f5 feat(pipeline): work out each school's current Ofsted status once
int_ofsted_latest now picks each school's latest visit (report card, graded or ungraded inspection) and the overall grade still in force, dated by the inspection that awarded or confirmed it. A 'School remains Good' from an older ungraded visit is no longer carried past a newer inspection that gave no grade (audit C1). Duplicate monthly rows resolve to the newest visit, so a newer report card always wins. dim_school's columns are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:23:38 +01:00
TudorandClaude Opus 5.5 358705bf39 feat(pipeline): keep Ofsted's graded, ungraded and report-card dates apart
int_ofsted_latest needs to know which inspection came last. Report-card-only schools are no longer dropped (123 schools, audit H3); inspection_date stays for current readers and now falls back to the report-card date, so fact_ofsted_inspection's not_null test still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:22:49 +01:00
TudorandClaude Opus 5.5 3be902e98f docs(plan): implementation plan for the current Ofsted status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:20:49 +01:00
TudorandClaude Opus 5.5 12c52244ee docs(spec): one current Ofsted status per school
Design for audit findings C1 and M1. A grade carried forward from an older
ungraded visit is shown under a newer inspection's date ("Good · 2025" for
Rabbsfarm, whose 2025 inspection gave no grade), and "Inspected" dates show the
last graded inspection rather than the latest visit. The rule moves into
int_ofsted_latest and a new fact_ofsted_latest mart, shipped as a pipeline PR
and then a backend/UI PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:00:35 +01:00
tudor e1373fb6df Merge pull request 'fix(school): drop the nearby section's lede, which repeated its heading' (#180) from fix/nearby-drop-lede into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m31s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m8s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #180
2026-10-04 09:13:48 +00:00
TudorandClaude Opus 5.5 ccfa44389e fix(school): drop the nearby section's lede, which repeated its heading
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m19s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m32s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m18s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
"Other schools nearby" was followed by "Other primary schools near
<school>.", which says the same thing again. The heading now stands alone.

nearbyNoun() and the phase and schoolName props existed only to build that
line, so they go with it. Its bottom margin was the only gap between the
heading and the cards, so the header row carries that gap now, and centres
the heading against the carousel arrows now that it is a single line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:10:36 +01:00
tudor 423b27140c Merge pull request 'fix(school): send the school page its admissions policy, and read it exactly' (#179) from fix/school-page-selective-flag into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 30s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m22s
Reviewed-on: #179
2026-10-02 22:56:36 +00:00
tudor 1c62e8247d Merge pull request 'fix(search): stop replaying a failed LA-averages request forever' (#178) from fix/la-average-cached-failure into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m27s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m47s
Reviewed-on: #178
2026-10-02 22:44:57 +00:00
TudorandClaude Opus 5.5 da5d63593f test(e2e): a no-faith comprehensive makes no Selective or faith claim
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
The Admissions section of a non-selective secondary with no religious
character must say neither "Selective:" nor "Faith priority:". Run against
staging before the fix, it fails on "Faith priority" (Burntwood's "(None)").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:43:43 +01:00
TudorandClaude Opus 5.5 ef4a2ccccb fix(school): send the school page its admissions policy, and read it exactly
The header's Selective flag read school_info.admissions_policy, which the
detail endpoint never sent, so no school page could flag Selective while
its search row did (staging E2E: The Grammar School at Leeds). The detail
payload now carries it, and a contract test checks it carries every field
the header's flags read.

Sending it would have switched on two older copies of the tag logic #176
fixed in the rows. The Admissions section and the cut-off note both tested
includes('selective'), so every non-selective secondary would have read
"entry is by selective examination". The section also counted "None" as a
faith: Burntwood reads "a faith-based admissions priority (None)" today.
All of them now share isSelective() and hasReligiousCharacter(), which also
treats "Not applicable" as no faith, as the place table already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:43:43 +01:00
tudor 392ec29fdb Merge pull request 'feat(school): facts and flags in the school header, shared with search rows' (#177) from feat/header-facts-and-flags into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m38s
Reviewed-on: #177
2026-10-02 22:32:47 +00:00
Tudor 03e44cf154 Merge branch 'main' into feat/header-facts-and-flags
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
2026-10-02 23:28:33 +01:00
TudorandClaude Opus 5.5 59ea8a4bdd fix(search): stop replaying a failed LA-averages request forever
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The search page fetched LA averages with cache: 'force-cache', which serves
any stored response, however old, without asking the server. One failed
request (a staging deploy restart; the July proxy outage) was stored and
replayed on every later visit, and the error was swallowed, so the
"vs LA avg" delta silently vanished from every secondary row in that
browser. A Playwright profile still held a 500 dated 5 July.

The default cache mode honours the API's Cache-Control (five minutes), so
a good answer is still reused and an error never is. Browsers holding a
stored failure recover on their next visit.

A journey now checks that a mainstream secondary's row shows the
comparison: nothing did, which is how it could go missing unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:20:14 +01:00
tudor c931d1078c Merge pull request 'fix(search): tag only what the register records, and count the whole school' (#176) from fix/search-row-facts into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m11s
Reviewed-on: #176
2026-10-02 22:05:56 +00:00
tudor 807133c305 Merge pull request 'fix(school): show Nursery only for nursery classes, and say Girls' school' (#175) from fix/header-nursery-and-gender into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m8s
Reviewed-on: #175
2026-10-02 22:04:29 +00:00
TudorandClaude Opus 5.5 41d3f3b971 fix(school): never open a wrapped fact line with a separator
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
At phone widths the fact line wraps, and each dot travelled with the value
after it, so the second line read "· 1,478 pupils". The line now starts
1.125rem left of the column and clips that strip: a value that starts a
line sits flush with the title and its dot is hidden. Measured on staging
at 360, 390 and 430 px: no element past the gutter, no page overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:01:24 +01:00
TudorandClaude Opus 5.5 c013265cb4 test(e2e): journeys for the header's facts and flags
A selective school is flagged Selective on its page and in its search row,
and its type reads in the search filter's words. The girls' school journey
looks for "Nursery class", the flag's new label, and the identity journey
checks that constituency and county have left the header. Run against
staging before the change, all three fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
TudorandClaude Opus 5.5 ad9d3b67f7 feat(search): print the school page's type and flags in search rows
Both rows now take their type label and tags from lib/schoolFacts, so a
parent reads the same words in the list and on the page: "State school"
instead of "Academy converter", "Girls' school" instead of "Girls", the
religious character instead of "Faith priority", and a Nursery class flag
on primaries. Who-can-apply tags are outlined with an inset ring, which
keeps them the size of their neighbours; the Selective tag no longer
borrows the status colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
TudorandClaude Opus 5.5 ca47d08186 feat(school): state the header's facts in one line, and flag what applies
The row of grey chips under the name mixed the council, a GIAS governance
term, the age range and provision, with no labels. Parents could not tell
what "Wandsworth" or "Nursery" meant, and selective, faith and fees were
missing or buried in the details.

The header now prints one fact line (phase pill · ages · type · pupils),
then flags only for what applies, in a fixed order: who can apply or what
it costs (Fee-paying, Selective, Girls'/Boys' school, the religious
character as recorded), then what the school offers (Nursery class, Sixth
form). Flags carry no hue: they are facts, not verdicts. The council joins
the address unless the address already names it. The details keep
headteacher, website, phone, academy trust (hidden when it has the
school's own name) and capacity; county, constituency and religious
character leave the header. The proposed-to-close strip is unchanged.

lib/schoolFacts holds the type label and the flags so the search rows can
print the same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
TudorandClaude Opus 5.5 62a6bfaf0c feat(api): name each school's type group, and send search its nursery provision
The school page and the search rows now print a school's type in the search
filter's terms ("State school", "Independent school") instead of GIAS's 34
establishment types. The list, place and detail payloads carry type_group,
computed with the filter's own type_group_for, so the two never disagree; a
type in no group stays null and the page prints the register's name.

Search rows flag a nursery class, so nursery_provision joins SCHOOL_COLUMNS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:45 +01:00
Tudor e344298440 Merge branch 'fix/search-row-facts' into feat/header-facts-and-flags 2026-10-02 22:51:35 +01:00
TudorandClaude Opus 5.5 8020191832 test(e2e): journeys for row tags and pupil counts, and ask for page_size
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
Three journeys pin the fixes: a non-selective secondary's row has no
Selective tag, a school recorded with no religious character has no faith
tag, and the list and the school page agree on a secondary's pupil count.
Run against staging before the fix, all three fail.

The list API reads page_size. Nine journeys asked for per_page, which it
ignores, so each got the default 25 rows whatever it asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:51:31 +01:00
TudorandClaude Opus 5.5 5f9caad7f4 fix: count pupils as the whole school, not the GCSE year group
fact_performance's total_pupils is the cohort a year's results were
measured on. For a secondary that is Year 11 alone, and the list API sent
it as the card's "pupils": Burntwood showed 245 in search and 1,462 on its
page. The list and place payloads now carry the register's whole-school
count, and nothing when the register has none. Map popups and the compare
basket read the same field.

The header and the wellbeing section fell back to the same results figure
when the census had no record. They now fall back to the register count.
The shell took yearlyData only for that fallback, so the prop is gone and
the results array no longer ships to the client for the chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:51:31 +01:00
TudorandClaude Opus 5.5 e65c93b68e fix(search): tag only what the register records
The secondary row tested the admissions policy with includes('selective'),
which "Non-selective" passes, so every comprehensive (Burntwood, Graveney)
was tagged Selective. It now needs an exact "Selective".

Both rows excluded only "Does not apply" from the religious character, so a
school recorded as "None" got "Faith priority" (Putney High) or a bare
"None" chip (Abacus Belsize Primary). hasReligiousCharacter() treats both of
the register's no-faith values as no faith.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:50:44 +01:00
TudorandClaude Opus 5.5 e3f21a5bc7 fix(school): address review on the header chip fixes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
- E2E: list with page_size (per_page was ignored), fail clearly if the
  gender filter is ignored, and require nursery_provision on the detail
  payload so the Nursery assertion cannot pass vacuously.
- singleSexLabel ignores case, as hasNurseryClasses does.
- Header test: type withSchool with Partial<School>, and match the
  single-sex labels exactly instead of any span ending in "school".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:16:49 +01:00
TudorandClaude Opus 5.5 dea435a906 fix(school): show Nursery only for nursery classes, and say Girls' school
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
GIAS NurseryProvision is text ("Has Nursery Classes", "No Nursery
Classes", "Not applicable"), but the header and the place table tested it
for truthiness. Every school with a value got a Nursery chip or a "Yes",
including secondaries aged 11-18. hasNurseryClasses() matches the one
value that means a nursery, and the type now says the field is a string.

The single-sex chip appended 's to the plural GIAS gender, giving
"Girls's school". singleSexLabel() gives "Girls' school" / "Boys' school".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:02:32 +01:00
tudor dd5b48e612 Merge pull request 'feat(search): phases in the order a child meets them' (#174) from feat/phase-order-child-path into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 31s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m10s
Reviewed-on: #174
2026-10-02 18:07:08 +00:00
TudorandClaude Opus 5.5 a88139a539 feat(search): phases in the order a child meets them
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
The phase filter listed GIAS phases alphabetically, so "16 plus" and
"All-through" came before Nursery. /api/filters (and the result-scoped
list) now order them Nursery, Primary, Middle deemed primary, Middle
deemed secondary, Secondary, 16 plus, then All-through, which spans the
whole path. A phase GIAS adds later follows the known ones, A-Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:56:14 +01:00
tudor 9b765125ad Merge pull request 'fix(search): desktop Clear all keeps the search' (#173) from fix/desktop-clear-keeps-search into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m8s
Reviewed-on: #173
2026-10-02 15:50:22 +00:00
tudor ccf0892a0e Merge pull request 'fix(modal): give the close button a 44px touch target' (#172) from fix/modal-close-tap-target into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m7s
Reviewed-on: #172
2026-10-02 15:50:14 +00:00
TudorandClaude Opus 5.5 0450f8ecd6 fix(search): desktop Clear all keeps the search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
The desktop toolbar's Clear went to the bare home URL, dropping the
search along with the filters, so it landed on the home page instead of
the unfiltered results. It now clears every filter and keeps the search,
postcode and distance, as the phone's Clear all already did. It is
offered only once a filter is applied, since with only a search there is
nothing for it to clear, and is labelled Clear all to match the phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:43:41 +01:00
TudorandClaude Opus 5.5 27d83f9bd0 fix(modal): give the close button a 44px touch target
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 12s
The close button was 40px square: a 24px icon in 8px of padding. MOBILE.md
sets 44px as the minimum, and the phone filter sheet journey, which
checks every target in the sheet, has failed on it in the staging E2E
gate since the sheet shipped. Measured on staging with the rule
injected: the button is 44x44, nothing in the sheet is under 44, and
the page does not overflow. Every Modal gets the same button, the
compare page's school search included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:40:45 +01:00
tudor 19c574edb0 Merge pull request 'feat(search): one state school group, not academy and council-run' (#171) from feat/single-state-school-group into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 25s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m2s
Reviewed-on: #171
2026-10-02 15:33:13 +00:00
TudorandClaude Opus 5.5 e78ec14e2e feat(search): one state school group, not academy and council-run
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The School type filter split state schools into "academy or free
school" and "council-run". The two were near-halves of one pool (11,186
and 9,316 schools), so choosing one rarely narrowed anything, and the
split did not follow the difference a parent feels most, admissions:
voluntary aided and foundation schools set their own, as academies do.
Faith, which voluntary aided mostly meant, has its own filter.

They are now one group, "State school (free)", leaving five. The old
keys academy and council resolve to state, so a link made with them
keeps working instead of falling through to the raw-label path and
returning nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:51:35 +01:00
tudor fb3ef7d2b9 Merge pull request 'feat(search): school type groups and a faith filter' (#170) from feat/school-type-groups-faith into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 25s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m5s
Reviewed-on: #170
2026-10-02 14:05:43 +00:00
TudorandClaude Opus 5.5 49ac96b487 refactor(search): drop the applied-filter row above the results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The row repeated the search, local authority and school type above the
results list, and nothing else: no phase, distance, gender, sixth form,
admissions or faith. The filter bar already shows every one of them, in
its selects and More filters count on desktop and as removable chips on
phones, so the row was a partial second copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:27:53 +01:00
TudorandClaude Opus 5.5 214c80663e docs(spec): describe the API filters as they are built
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The /api/schools section still described filtering on stored
school_type_group and faith_groups columns, which the architecture
correction had already dropped: the filters look up the name columns at
request time. Also trims a trailing blank line from two test files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:25:20 +01:00
TudorandClaude Opus 5.5 84caee9f72 fix(search): show applied type and faith by label, in any case
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
The results list's chip printed the school type group's key ("post16")
rather than its label. A URL value that no option spelled the same way
(an old link's raw GIAS type, or a key in another case) left the select
reading "Any school type" while the list stayed filtered, and choosing
"Any" changed nothing. Values now match options ignoring case, as the
API does, and an unlisted one is shown as an option of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:00:54 +01:00
TudorandClaude Opus 5.5 c992d7f3b9 test(e2e): school type groups and the faith filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:55:32 +01:00
TudorandClaude Opus 5.5 17bfb4a3f7 feat(search): school type groups and a faith filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:54:04 +01:00
TudorandClaude Opus 5.5 15b8923e60 feat(api): filter schools by type group and by faith
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:53:14 +01:00
TudorandClaude Opus 5.5 354244f755 feat(api): group GIAS school types and religions for parents
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:52:43 +01:00
TudorandClaude Opus 5.5 3e2fa4a419 docs(plan): school type groups and a faith filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:44:57 +01:00
TudorandClaude Opus 5.5 50546ecf22 docs(spec): school type groups and a faith filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:39:07 +01:00
tudor 99d62ef748 Merge pull request 'fix(search): offer every filter option, not only those in the results' (#169) from fix/filter-options-not-from-results into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 31s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m7s
Reviewed-on: #169
2026-10-02 09:34:02 +00:00
TudorandClaude Opus 5.5 68452681f8 fix(search): match primary phases by their words, not exact labels
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The secondary-only filters were hidden for an exact set of lowercased
labels, so a change of case, hyphen or spacing in a GIAS phase label
would have left them showing. Phases are now matched on their words, as
compareGroups already reads "primary".

The E2E journey no longer counts school types: it asserts that choosing
one leaves the same list on offer, whatever the data holds. A test now
pins that a filter with no options is left out, not shown with only its
"any" option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:28:34 +01:00
TudorandClaude Opus 5.5 0cc4f52816 fix(search): offer every filter option, not only those in the results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m17s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m22s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
School type, gender and admissions took their options from the result
set, which the filter had already narrowed: choose "Girls" and only
"Girls" was offered, so switching to "Boys" meant clearing first. They
now offer the full lists from /api/filters, as phase already did. Local
authority stays scoped to the results, so a postcode search offers the
councils nearby rather than all 153.

Whether gender, sixth form and admissions show was also decided by the
results (any secondary school in them). It is now decided by the phase
alone: hidden for Primary, Nursery and Middle deemed primary, shown
otherwise. Choosing one of those phases clears the three filters, which
would otherwise stay applied with no control showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:22:39 +01:00
tudor eb13ab0b5e Merge pull request 'feat(search): filter on phones through one sheet' (#168) from feat/mobile-filter-sheet into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m4s
Reviewed-on: #168
2026-10-02 08:51:17 +00:00
TudorandClaude Opus 5.5 fa49164143 fix(search): keep the filter sheet usable while a change lands
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The sheet's controls were disabled while a filter change navigated, and
a control disabled under focus drops it to <body>, out of the dialog.
They now stay enabled, with aria-busy on the sheet instead.

The disabling had also been covering a race: updateURL built from
useSearchParams, which only catches up once a navigation lands, so a
second change made before then undid the first. It now builds on the
URL the navigation in flight is heading to.

The sheet also closes if the screen widens past phone width while it is
open, so its selects and the desktop row's are never both showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:40:29 +01:00
TudorandClaude Opus 5.5 cf3c773f86 feat(search): filter on phones through one sheet
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
On phones the results toolbar's filters were a sideways-scrolling row
led by "More filters", so phase showed only in part and school type not
at all, and the panel "More filters" opened held neither of them.

Phones now get a single Filters button beside the folded search summary,
counting every applied filter. It opens a bottom sheet with every filter:
distance as five segments, then phase, school type, local authority and
the secondary-only filters. Changes apply at once, as on desktop, so the
footer's "Show N schools" only closes the sheet. Applied filters show as
removable chips on a second line, which appears only when something is
applied. Desktop and tablet are unchanged.

Modal gains dialog semantics, a pinned footer and focus handling, and
moves above the pinned toolbar, the floating List/Map button and the
comparison toast, which its old z-index sat beneath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:31:53 +01:00
tudor 4b54c25943 Merge pull request 'test(e2e): scroll a long list, and wait out the popup crossfade' (#167) from fix/e2e-after-half-mile-default into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m3s
Reviewed-on: #167
2026-10-01 21:22:34 +00:00
TudorandClaude Opus 5.5 e7645d1ba5 test(e2e): scroll a long list, and wait out the popup crossfade
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m17s
PR Checks / Backend Smoke (pull_request) Successful in 11s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m31s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The pinned-toolbar journeys scrolled the results of a typed B1 1BB search.
At the new half-mile default that is two schools, so the page ended before
the toolbar had anything to stay pinned over (desktop scrollY stopped at
229; on phones the scroll reached the footer). They now open the search
at a mile, which is what they were measuring.

Leaflet crossfades popups for 200ms, so for that window there are two
.sc-popup elements, and a strict locator fails at once rather than
retrying. Wait for one popup before reading it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:43:06 +01:00
tudor 29b5f85952 Merge pull request 'fix(map): draw the map card's View button like the list's' (#166) from fix/map-popup-view-button into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m23s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 4m4s
Reviewed-on: #166
2026-10-01 20:17:27 +00:00
TudorandClaude Opus 5.5 c077c27720 fix(map): draw the map card's View button like the list's
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
leaflet.css colours every link in the map `.leaflet-container a`
(#0078A8, specificity 0,1,1), which beat .btn-tertiary (0,1,0), so the
card's View button came out in Leaflet's link blue in both themes. Pull
it back onto --text-secondary next to the other Leaflet overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:10:14 +01:00
tudor e2fc7a8f15 Merge pull request 'fix(search): default to half a mile, and offer a quarter mile' (#165) from fix/default-radius-half-mile into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 4m4s
Reviewed-on: #165
2026-10-01 17:49:12 +00:00
TudorandClaude Opus 5.5 355a5a841c feat(search): offer a quarter-mile distance
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
Adds "Within 0.25 miles" to the Distance control; 0.5 stays the default.
The radius shown in the results heading and drawn on the map was rounded
to one decimal place, which would have turned 0.25 into 0.3. It now keeps
two, and the list heading says "1 mile" rather than "1.0 miles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:52:59 +01:00
TudorandClaude Opus 5.5 96deab7d58 fix(search): start postcode searches at half a mile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
New postcode searches, the near-me button and URLs without a radius now
use 0.5 miles. A postcode URL with no radius used to show "1 mile" in the
Distance control while the API applied its own 5-mile default; the page
and the map fetch now send the same default the control displays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:40:44 +01:00
tudor e9886361d2 Merge pull request 'fix(search): set the toolbar's line count by width, not by results' (#164) from fix/toolbar-one-line into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m3s
Reviewed-on: #164
2026-10-01 15:06:31 +00:00
TudorandClaude Opus 5.5 8ebe461435 fix(search): set the toolbar's line count by width, not by results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The results toolbar wrapped wherever it ran out of room, and the List/Map
switch only appears when there are results, so the same search took two
lines with results and one without.

From 1340px the controls never wrap away from the search, which takes
what they leave (at least 12rem); phase and type chips cap at 11rem to
fit. Between 641px and 1339px the controls always take a full line of
their own. The switch now sits in FilterBar's row via a viewSwitch slot,
so that line runs the full width instead of stopping short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:27:14 +01:00
tudor 2002529137 Merge pull request 'fix(api): filter by every GIAS phase, not just the grouped ones' (#163) from fix/phase-filter-exact-match into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 22s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m3s
Reviewed-on: #163
2026-10-01 14:07:56 +00:00
88 changed files with 7414 additions and 993 deletions

No files matched your search

+110 -17
View File
@@ -40,7 +40,15 @@ from .data_loader import (
from .data_loader import get_data_info as get_db_info
from . import flags
from .places import build_place_index, build_place_registry, places_for_urn
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, PHASE_ORDER, RANKING_COLUMNS, SCHOOL_COLUMNS
from .school_groups import (
FAITH_GROUPS,
FAITH_KEYS,
TYPE_GROUPS,
faith_groups_for,
type_group_for,
type_group_key,
)
from .nearby_schools import select_nearby
from .utils import clean_for_json, convert_to_native
@@ -89,8 +97,9 @@ STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
# A page has something a search result could state if any of these is present
# in any year. Shared by _has_publishable_data and the per-school check in
# _school_sitemap_rows so the two can never drift.
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
# _school_sitemap_rows so the two can never drift. An inspection with no
# overall grade (ofsted_date alone) still gives a page something to state.
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade", "ofsted_date")
def _has_publishable_data(row) -> bool:
@@ -373,6 +382,12 @@ def clean_filter_values(series: pd.Series) -> list[str]:
)
def order_phases(phases: list[str]) -> list[str]:
"""Phases in the order a child meets them; any GIAS adds later follow, A-Z."""
rank = {p: i for i, p in enumerate(PHASE_ORDER)}
return sorted(phases, key=lambda p: (rank.get(p.lower(), len(rank)), p))
# =============================================================================
# SECURITY MIDDLEWARE & HELPERS
# =============================================================================
@@ -607,7 +622,41 @@ def verify_admin_api_key(x_api_key: str = Header(None)) -> bool:
return True
def _with_whole_school_pupils(rows: pd.DataFrame, source: pd.DataFrame) -> pd.DataFrame:
"""Set total_pupils to the size of the school.
fact_performance's total_pupils is the cohort a year's results were
measured on. For a secondary that is the GCSE year group alone (Burntwood:
245 against 1,462 on roll). Cards, map popups and place rows label it
"pupils", so they take the register's whole-school count instead, and
nothing when the register has none.
"""
whole = source["gias_total_pupils"] if "gias_total_pupils" in source.columns else None
return rows.assign(total_pupils=whole)
def _with_type_group(rows: pd.DataFrame) -> pd.DataFrame:
"""Name each row's search-filter type group, or None for a type in no group.
The school page and the search rows print it ("State school") in place of
the GIAS establishment type, and an independent school gets a Fee-paying
flag from it.
"""
if "school_type" not in rows.columns:
return rows
return rows.assign(type_group=rows["school_type"].map(type_group_for))
# Input validation helpers
def _names_in_group(names: pd.Series, in_group) -> set:
"""The distinct names in a column that a group predicate accepts.
Evaluated once per distinct name rather than per row, so a filter over
every school costs a few dozen lookups.
"""
return {n for n in names.dropna().unique() if in_group(n)}
def sanitize_search_input(value: Optional[str], max_length: int = 100) -> Optional[str]:
"""Sanitize search input to prevent injection attacks."""
if value is None:
@@ -744,6 +793,7 @@ async def get_schools(
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
faith: Optional[str] = Query(None, description="Filter by faith group key", max_length=40),
):
"""
Get list of schools with pagination.
@@ -756,6 +806,7 @@ async def get_schools(
local_authority = sanitize_search_input(local_authority)
school_type = sanitize_search_input(school_type)
phase = sanitize_search_input(phase)
faith = sanitize_search_input(faith)
postcode = validate_postcode(postcode)
# Load the pre-computed latest-year snapshot (cached after first request / startup).
@@ -796,6 +847,22 @@ async def get_schools(
flag = df_latest["age_range"].str.contains("18", na=False)
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
# Faith group (backend/school_groups.py). A joint school is in every faith
# its label names; a missing religious character is "none". An unknown key
# matches nothing rather than being ignored, so a typo cannot show all.
if faith:
faith_key = faith.lower()
if faith_key in FAITH_KEYS and "religious_denomination" in df_latest.columns:
column = df_latest["religious_denomination"]
matches = column.isin(_names_in_group(column, lambda n: faith_key in faith_groups_for(n)))
# _names_in_group skips missing names; a missing religious
# character is "No religious character".
if faith_key == "none":
matches = matches | column.isna()
df_latest = df_latest[matches]
else:
df_latest = df_latest.iloc[0:0]
# Include key result metrics for display on cards
location_cols = ["latitude", "longitude"]
result_cols = [
@@ -818,7 +885,7 @@ async def get_schools(
if c in df_latest.columns
]
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
schools_df = df_latest[available_cols]
schools_df = _with_whole_school_pupils(df_latest[available_cols], df_latest)
# Location-based search (uses pre-geocoded data from database)
search_coords = None
@@ -883,10 +950,17 @@ async def get_schools(
schools_df["local_authority"].str.lower() == local_authority.lower()
]
# A type group key (backend/school_groups.py), old keys included, or for
# an old link a raw GIAS type label, matched exactly as before.
if school_type:
schools_df = schools_df[
schools_df["school_type"].str.lower() == school_type.lower()
]
type_key = type_group_key(school_type)
if type_key:
column = schools_df["school_type"]
schools_df = schools_df[
column.isin(_names_in_group(column, lambda n: type_group_for(n) == type_key))
]
else:
schools_df = schools_df[schools_df["school_type"].str.lower() == school_type.lower()]
# Compute result-scoped filter values (before pagination).
# Gender and admissions are secondary-only filters — scope them to schools
@@ -895,7 +969,7 @@ async def get_schools(
result_filters = {
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
"phases": order_phases(clean_filter_values(schools_df["phase"])) if "phase" in schools_df.columns else [],
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
}
@@ -904,7 +978,7 @@ async def get_schools(
total = len(schools_df)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
schools_df = schools_df.iloc[start_idx:end_idx]
schools_df = _with_type_group(schools_df.iloc[start_idx:end_idx])
return {
"schools": clean_for_json(schools_df),
@@ -968,11 +1042,14 @@ async def get_school_details(request: Request, urn: int):
"school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""),
"type_group": type_group_for(latest.get("school_type")),
"address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"nursery_provision": latest.get("nursery_provision"),
# The header's Selective flag reads it (lib/schoolFacts).
"admissions_policy": latest.get("admissions_policy"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
@@ -1161,15 +1238,29 @@ async def get_filter_options(request: Request):
"local_authorities": [],
"school_types": [],
"years": [],
"school_type_groups": [],
"faiths": [],
}
# Phases: return values from data, ordered sensibly
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
# Phases: the values in the data, in the order a child meets them
phases = order_phases(clean_filter_values(df["phase"])) if "phase" in df.columns else []
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
def offered(groups, present):
return [{"value": key, "label": label} for key, label, _ in groups if key in present]
type_groups_present = (
{type_group_for(n) for n in df["school_type"].dropna().unique()} - {None}
if "school_type" in df.columns else set()
)
faiths_present = (
{f for n in df["religious_denomination"].unique() for f in faith_groups_for(n)}
if "religious_denomination" in df.columns else set()
)
return {
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
@@ -1177,6 +1268,8 @@ async def get_filter_options(request: Request):
"phases": phases,
"genders": genders,
"admissions_policies": admissions_policies,
"school_type_groups": offered(TYPE_GROUPS, type_groups_present),
"faiths": offered(FAITH_GROUPS, faiths_present),
}
@@ -1445,13 +1538,12 @@ async def get_place(request: Request, kind: str, slug: str,
# 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.
# parliamentary_constituency is not in SCHOOL_COLUMNS and the place table
# shows it. The `in rows.columns` guard is what keeps a mart the pipeline
# has not rebuilt working: it and nursery_provision 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",
"total_pupils"])
@@ -1481,7 +1573,8 @@ async def get_place(request: Request, kind: str, slug: str,
# variants that exist rather than 404s.
"phases": [ph for ph in ("primary", "secondary")
if place.publishes_phase(ph)]},
"schools": clean_for_json(rows[cols]),
"schools": clean_for_json(
_with_type_group(_with_whole_school_pupils(rows[cols], rows))),
"averages": averages,
}
+37 -56
View File
@@ -18,7 +18,7 @@ from .config import settings
from .database import SessionLocal, engine
from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactOfstedLatest, FactAdmissions, FactAdmissionDistance,
FactDeprivation, FactFinance, FactPupilCharacteristics,
FactKs4Destinations, FactKs5Destinations,
)
@@ -251,10 +251,11 @@ _MAIN_QUERY = text("""
s.website,
s.telephone,
s.nursery_provision,
foi.ofsted_grade,
foi.ofsted_date,
foi.ofsted_framework,
foi.ofsted_rc_date,
foi.current_grade AS ofsted_grade,
foi.current_grade_date AS ofsted_grade_date,
foi.latest_visit_date AS ofsted_date,
foi.framework AS ofsted_framework,
foi.rc_inspection_date AS ofsted_rc_date,
l.local_authority_name AS local_authority,
l.local_authority_code,
l.address_line1 AS address1,
@@ -335,21 +336,10 @@ _MAIN_QUERY = text("""
FROM marts.dim_school s
JOIN marts.dim_location l ON s.urn = l.urn
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
LEFT JOIN (
SELECT DISTINCT ON (urn)
urn,
-- Fall back to the ungraded-inspection grade when no graded grade exists.
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
inspection_date AS ofsted_date,
framework AS ofsted_framework,
-- Report-card signal for list/map badges: non-null only when the
-- latest inspection carries report-card grades. framework is the
-- raw event grouping ("Schools - S5"), never "ReportCard", so it
-- can't be used to detect report cards.
rc_inspection_date AS ofsted_rc_date
FROM marts.fact_ofsted_inspection
ORDER BY urn, inspection_date DESC NULLS LAST
) foi ON s.urn = foi.urn
-- One current Ofsted status per school (pipeline: int_ofsted_latest): the
-- grade still in force, dated by the inspection that awarded or confirmed
-- it, and the latest visit of any kind.
LEFT JOIN marts.fact_ofsted_latest foi ON s.urn = foi.urn
ORDER BY s.school_name, p.year
""")
@@ -731,39 +721,36 @@ def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None)
}
def _iso(d):
return d.isoformat() if d else None
def _ofsted_block(o, urn: int) -> dict:
"""Serialize the latest Ofsted inspection row for API responses.
"""Serialize a fact_ofsted_latest row for API responses.
`grade_source` records where the effective overall grade came from:
a graded (Section 5) inspection, or carried forward from an ungraded
(Section 8) outcome — materially different claims a UI must be able
to distinguish. `report_card` holds coded+labelled renewed-framework
(Nov 2025) area judgements; safeguarding is a separate boolean and
never appears among the graded areas.
`current_grade` is the overall grade still in force, dated by the
inspection that awarded or confirmed it; `latest_visit` is the school's
most recent inspection of any kind. The rule lives in int_ofsted_latest
(docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md).
`overall_effectiveness` and `inspection_date` describe the graded
inspection itself and label its area judgements. `report_card` holds the
renewed-framework (Nov 2025) area judgements; safeguarding is a separate
boolean and never appears among the graded areas.
"""
if o.overall_effectiveness is not None:
grade_source = "graded"
overall = o.overall_effectiveness
elif o.ungraded_grade is not None:
# Fall back to the grade parsed from an ungraded (Section 8) outcome
# (e.g. "School remains Good") so the detail page matches the list badge.
grade_source = "ungraded_carried_forward"
overall = o.ungraded_grade
else:
grade_source = None
overall = None
block = {
"framework": o.framework,
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
"rc_inspection_date": (
o.rc_inspection_date.isoformat()
if getattr(o, "rc_inspection_date", None)
else None
),
"inspection_date": _iso(o.graded_inspection_date),
"rc_inspection_date": _iso(o.rc_inspection_date),
"inspection_type": o.inspection_type,
"overall_effectiveness": overall,
"grade_source": grade_source,
"overall_effectiveness": o.overall_effectiveness if o.overall_effectiveness in (1, 2, 3, 4) else None,
"current_grade": (
{"grade": o.current_grade, "date": _iso(o.current_grade_date), "basis": o.current_grade_basis}
if o.current_grade is not None else None
),
"latest_visit": (
{"date": _iso(o.latest_visit_date), "kind": o.latest_visit_kind, "outcome": o.latest_visit_outcome}
if o.latest_visit_date else None
),
"quality_of_education": o.quality_of_education,
"behaviour_attitudes": o.behaviour_attitudes,
"personal_development": o.personal_development,
@@ -1097,20 +1084,14 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
db.rollback()
# Ofsted — latest inspection per URN. Ordered so the first row seen per
# URN is the most recent.
# Ofsted — the mart already holds one current status per URN.
def _ofsted():
rows = (
db.query(FactOfstedInspection)
.filter(FactOfstedInspection.urn.in_(urns))
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
db.query(FactOfstedLatest)
.filter(FactOfstedLatest.urn.in_(urns))
.all()
)
seen = set()
for o in rows:
if o.urn in seen:
continue
seen.add(o.urn)
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
_safe(_ofsted)
+43
View File
@@ -162,6 +162,49 @@ class FactOfstedInspection(Base):
report_url = Column(Text)
class FactOfstedLatest(Base):
"""Current Ofsted status — one row per URN (pipeline: int_ofsted_latest).
`current_grade` is the overall grade still in force, dated by the
inspection that awarded or confirmed it; `latest_visit_*` is the school's
most recent inspection of any kind.
"""
__tablename__ = "fact_ofsted_latest"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
latest_visit_date = Column(Date)
latest_visit_kind = Column(String(20))
latest_visit_outcome = Column(String(100))
current_grade = Column(Integer)
current_grade_date = Column(Date)
current_grade_basis = Column(String(20))
graded_inspection_date = Column(Date)
ungraded_inspection_date = Column(Date)
rc_inspection_date = Column(Date)
inspection_type = Column(String(100))
framework = Column(String(20))
overall_effectiveness = Column(Integer)
quality_of_education = Column(Integer)
behaviour_attitudes = Column(Integer)
personal_development = Column(Integer)
leadership_management = Column(Integer)
early_years_provision = Column(Integer)
sixth_form_provision = Column(Integer)
ungraded_outcome = Column(String(100))
ungraded_grade = Column(Integer)
rc_safeguarding_met = Column(Boolean)
rc_inclusion = Column(Integer)
rc_curriculum_teaching = Column(Integer)
rc_achievement = Column(Integer)
rc_attendance_behaviour = Column(Integer)
rc_personal_development = Column(Integer)
rc_leadership_governance = Column(Integer)
rc_early_years = Column(Integer)
rc_sixth_form = Column(Integer)
report_url = Column(Text)
class FactAdmissions(Base):
"""School admissions — one row per URN per year."""
__tablename__ = "fact_admissions"
+15
View File
@@ -544,6 +544,19 @@ PHASE_GROUPS: dict[str, set[str]] = {
"all-through": {"all-through"},
}
# GIAS phases in the order a child meets them, for the phase filter's options.
# All-through spans the whole path, so it follows the stages. Lowercased, as
# PHASE_GROUPS is, so a change of case in the GIAS label keeps its place.
PHASE_ORDER: list[str] = [
"nursery",
"primary",
"middle deemed primary",
"middle deemed secondary",
"secondary",
"16 plus",
"all-through",
]
# School listing columns
SCHOOL_COLUMNS = [
"urn",
@@ -556,10 +569,12 @@ SCHOOL_COLUMNS = [
"religious_denomination",
"age_range",
"has_sixth_form",
"nursery_provision",
"status",
"gender",
"admissions_policy",
"ofsted_grade",
"ofsted_grade_date",
"ofsted_date",
"ofsted_framework",
"ofsted_rc_date",
+120
View File
@@ -0,0 +1,120 @@
"""Parent-facing groups over GIAS establishment types and religious characters.
The 34 GIAS establishment types describe governance and funding, which for a
mainstream state school barely changes what a parent experiences. The search
filter offers five groups a parent recognises instead, and a faith filter in
place of the faith signal that "Voluntary aided" and "Voluntary controlled"
only half carry. See
docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md.
Groups are defined over GIAS codes and looked up by the translated name,
because the DataFrame the API filters carries names only: the codes are
replaced at load (data_loader.translate_gias_code_columns), the legacy-name
mart fallback never had them, and test fixtures are written in names.
"""
from typing import Optional
import pandas as pd
from .gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
# (key, label, GIAS TypeOfEstablishment codes), in the order shown.
TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Every mainstream state school, academy or council-run: Academy sponsor
# led, Academy converter, Free schools, University technical college,
# Studio schools, City technology college, Community, Voluntary aided,
# Voluntary controlled, Foundation, LA nursery. Academy against council-run
# was two near-halves of one pool, and did not follow the difference a
# parent feels most, admissions: voluntary aided and foundation schools
# set their own, as academies do. Faith, which voluntary aided mostly
# meant, has its own filter.
("state", "State school (free)",
frozenset({28, 34, 35, 40, 41, 6, 1, 2, 3, 5, 15})),
("independent", "Independent (fee-paying)", frozenset({11})),
# Every special type, independent ones included (usually funded by the
# council through an EHCP, so SEND provision to a parent, not private
# school), and Special post 16 institutions.
("special", "Special school (SEND)", frozenset({7, 8, 10, 12, 32, 33, 36, 44})),
# Further education, Sixth form centres, the 16-19 academies and free schools.
("post16", "Sixth form or college", frozenset({18, 31, 39, 45, 46})),
# Pupil referral units and AP academies and free schools. Last: parents do
# not apply to these; the local authority places children there.
("alternative", "Alternative provision", frozenset({14, 38, 42, 43})),
)
# In no group, so reachable only under "Any school type": Secure units,
# Miscellaneous, Higher education institutions, Online provider, Institution
# funded by other government department, Academy secure 16 to 19.
UNOFFERED_TYPE_CODES: frozenset[int] = frozenset({24, 27, 29, 49, 56, 57})
# (key, label, GIAS ReligiousCharacter codes), in the order shown. A joint
# school is in every faith its label names; a generic "Christian" beside a
# named church adds nothing.
FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Does not apply, None, and 99 (a blank label).
("none", "No religious character", frozenset({0, 6, 99})),
("church_of_england", "Church of England",
frozenset({2, 9, 10, 11, 12, 13, 19, 20, 30, 31, 32, 33, 34, 41, 48})),
("roman_catholic", "Roman Catholic", frozenset({3, 11, 13, 35, 48})),
# 28 "Inter- / non- denominational" is how GIAS files Christian schools
# tied to no one church.
("other_christian", "Other Christian",
frozenset({4, 8, 9, 10, 12, 14, 15, 16, 17, 18, 19, 22, 26, 28, 30, 33,
37, 38, 39, 40, 41, 44, 45, 46, 47})),
("jewish", "Jewish", frozenset({5, 36, 43})),
("muslim", "Muslim", frozenset({7, 42, 49})),
("other_faith", "Other faith", frozenset({21, 24, 25, 29})),
)
TYPE_GROUP_KEYS: frozenset[str] = frozenset(k for k, _, _ in TYPE_GROUPS)
# Keys a group was offered under before, so their links keep working: "state"
# was "academy" and "council" until 2026-10-02.
TYPE_GROUP_ALIASES: dict[str, str] = {"academy": "state", "council": "state"}
FAITH_KEYS: frozenset[str] = frozenset(k for k, _, _ in FAITH_GROUPS)
def _key(name: str) -> str:
return name.strip().lower()
_TYPE_GROUP_BY_NAME: dict[str, str] = {
_key(SCHOOL_TYPE[code]): key
for key, _, codes in TYPE_GROUPS
for code in codes
if code in SCHOOL_TYPE
}
_FAITHS_BY_NAME: dict[str, tuple[str, ...]] = {}
for _faith, _, _codes in FAITH_GROUPS:
for _code in sorted(_codes):
if _code in RELIGIOUS_CHARACTER:
_name = _key(RELIGIOUS_CHARACTER[_code])
_FAITHS_BY_NAME[_name] = _FAITHS_BY_NAME.get(_name, ()) + (_faith,)
def type_group_key(value: str) -> Optional[str]:
"""The type group a school_type URL value names, old keys included, or
None when it names no group (an old link's raw GIAS type)."""
v = value.strip().lower()
v = TYPE_GROUP_ALIASES.get(v, v)
return v if v in TYPE_GROUP_KEYS else None
def type_group_for(name: object) -> Optional[str]:
"""The type group of a GIAS establishment type name, or None."""
if not isinstance(name, str):
return None
return _TYPE_GROUP_BY_NAME.get(_key(name))
def faith_groups_for(name: object) -> tuple[str, ...]:
"""The faith groups of a GIAS religious character name.
A missing or blank name is "No religious character". A name the
dictionary does not know has no faith, so it matches no faith option.
"""
if not isinstance(name, str):
return ("none",) if name is None or pd.isna(name) else ()
return _FAITHS_BY_NAME.get(_key(name), ())
+3 -2
View File
@@ -12,7 +12,8 @@ from fastapi.testclient import TestClient
LATEST = 202425
CANNED_SUPPLEMENTARY = {
"ofsted": {"overall_effectiveness": 2, "grade_source": "graded",
"ofsted": {"overall_effectiveness": 2,
"current_grade": {"grade": 2, "date": "2023-01-01", "basis": "graded"},
"report_card": {}, "ofsted_page_url": "https://reports.ofsted.gov.uk/provider/21/100140"},
"census": {"year": 202526, "fsm_pct": 29.8},
"admissions": {"year": 202627, "second_preference_offers": 4},
@@ -86,7 +87,7 @@ def test_each_school_gains_supplementary_blocks(client):
body = client.get("/api/compare?urns=100140,138690").json()
for urn in ("100140", "138690"):
school = body["comparison"][urn]
assert school["ofsted"]["grade_source"] == "graded"
assert school["ofsted"]["current_grade"]["basis"] == "graded"
assert school["census"]["fsm_pct"] == 29.8
assert school["admissions"]["second_preference_offers"] == 4
assert school["admissions_history"][0]["year"] == 202627
@@ -0,0 +1,56 @@
"""The search badge reads ofsted_grade, ofsted_grade_date and ofsted_date from
list rows (nextjs-app/lib/utils.ts buildOfstedListBadge). A field the list
never sends would leave the badge without its year, or worse, fall back to
"Not yet inspected". The school page reads current_grade and latest_visit from
the ofsted block (lib/ofstedStatus.ts)."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from backend.schemas import SCHOOL_COLUMNS
def test_list_columns_include_the_status_fields():
for field in ("ofsted_grade", "ofsted_grade_date", "ofsted_date", "ofsted_rc_date"):
assert field in SCHOOL_COLUMNS
def _df() -> pd.DataFrame:
# Rabbsfarm (102408): latest inspection 17 June 2025 gave no overall grade.
return pd.DataFrame([{
"urn": 102408, "school_name": "Rabbsfarm Primary School", "phase": "Primary",
"school_type": "Community school", "local_authority": "Hillingdon",
"address": "Gordon Road, Yiewsley, UB7 8AH", "postcode": "UB7 8AH",
"latitude": 51.51, "longitude": -0.47, "year": 202425, "rwm_expected_pct": 58.0,
"total_pupils": 60, "gias_total_pupils": 616,
"ofsted_grade": np.nan, "ofsted_grade_date": None, "ofsted_date": "2025-06-17",
"ofsted_framework": "Schools - S5", "ofsted_rc_date": None,
}])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _df)
monkeypatch.setattr(app_module, "load_latest_school_data", _df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_search_rows_carry_the_status_fields(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
row = resp.json()["schools"][0]
assert row["ofsted_grade"] is None
assert row["ofsted_date"] == "2025-06-17"
assert "ofsted_grade_date" in row
def test_a_school_with_only_an_inspection_is_publishable():
from backend.app import _has_publishable_data
assert _has_publishable_data({"rwm_expected_pct": None, "attainment_8_score": None,
"ofsted_grade": None, "ofsted_date": "2025-06-17"})
+22
View File
@@ -82,3 +82,25 @@ def test_grouped_phases_still_take_in_their_related_phases(client):
def test_an_unknown_phase_returns_nothing_rather_than_everything(client):
assert _urns(client, "kindergarten") == []
def test_filters_lists_phases_in_the_order_a_child_meets_them(client):
# Alphabetical put "16 plus" and "All-through" first and Nursery fifth.
# All-through spans the whole path, so it comes after the stages.
assert client.get("/api/filters").json()["phases"] == [
"Nursery",
"Primary",
"Middle deemed primary",
"Middle deemed secondary",
"Secondary",
"16 plus",
"All-through",
]
def test_an_unknown_phase_follows_the_known_ones():
from backend.app import order_phases
assert order_phases(["Secondary", "Zeta", "Alpha", "Nursery"]) == [
"Nursery", "Secondary", "Alpha", "Zeta",
]
+112
View File
@@ -0,0 +1,112 @@
"""Parent-facing groups over GIAS establishment types and religious characters.
Every GIAS code must be accounted for, so a new DfE code fails here instead of
silently vanishing from the filter.
"""
from pathlib import Path
import numpy as np
import pytest
import yaml
from backend.gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
from backend.school_groups import (
FAITH_GROUPS,
TYPE_GROUPS,
UNOFFERED_TYPE_CODES,
faith_groups_for,
type_group_for,
type_group_key,
)
DBT_PROJECT = Path(__file__).resolve().parents[2] / "pipeline" / "transform" / "dbt_project.yml"
def _non_england_codes() -> set[int]:
return set(yaml.safe_load(DBT_PROJECT.read_text())["vars"]["non_england_school_type_codes"])
def test_every_type_code_is_in_exactly_one_place():
places = [codes for _, _, codes in TYPE_GROUPS] + [UNOFFERED_TYPE_CODES, _non_england_codes()]
for code in SCHOOL_TYPE:
homes = sum(code in p for p in places)
assert homes == 1, f"type code {code} ({SCHOOL_TYPE[code]}) is in {homes} places"
def test_every_religion_code_has_a_faith():
for code, name in RELIGIOUS_CHARACTER.items():
assert any(code in codes for _, _, codes in FAITH_GROUPS), f"{code} {name!r}"
def test_type_groups_in_display_order():
assert [k for k, _, _ in TYPE_GROUPS] == [
"state", "independent", "special", "post16", "alternative"]
def test_faiths_in_display_order():
assert [k for k, _, _ in FAITH_GROUPS] == [
"none", "church_of_england", "roman_catholic", "other_christian",
"jewish", "muslim", "other_faith"]
@pytest.mark.parametrize("name, group", [
("Academy converter", "state"),
("University technical college", "state"),
("Voluntary aided school", "state"),
("Local authority nursery school", "state"),
("Other independent school", "independent"),
("Other independent special school", "special"),
("Special post 16 institution", "special"),
("Further education", "post16"),
("Pupil referral unit", "alternative"),
("academy CONVERTER", "state"),
])
def test_type_group_by_name(name, group):
assert type_group_for(name) == group
@pytest.mark.parametrize("value, key", [
("state", "state"),
("Special", "special"),
# The two state groups that preceded "state", kept so their links still work.
("academy", "state"),
("Council", "state"),
("Community school", None),
("", None),
])
def test_type_group_key_resolves_keys_and_old_keys(value, key):
assert type_group_key(value) == key
@pytest.mark.parametrize("name", [
"Higher education institutions", "Miscellaneous", "Unknown (9999)", "Academy", "", None, np.nan,
])
def test_unoffered_or_unknown_types_have_no_group(name):
assert type_group_for(name) is None
@pytest.mark.parametrize("name, faiths", [
("Roman Catholic/Church of England", ("church_of_england", "roman_catholic")),
("Roman Catholic/Anglican", ("church_of_england", "roman_catholic")),
("Church of England/Methodist", ("church_of_england", "other_christian")),
("Church of England/Christian", ("church_of_england",)),
("Catholic", ("roman_catholic",)),
("Inter- / non- denominational", ("other_christian",)),
("Orthodox Jewish", ("jewish",)),
("Sunni Deobandi", ("muslim",)),
("Hindu", ("other_faith",)),
("Does not apply", ("none",)),
("None", ("none",)),
])
def test_faiths_by_name(name, faiths):
assert faith_groups_for(name) == faiths
@pytest.mark.parametrize("missing", [None, np.nan, "", " "])
def test_a_missing_religion_is_no_religious_character(missing):
assert faith_groups_for(missing) == ("none",)
def test_an_unknown_religion_has_no_faith():
assert faith_groups_for("Unknown (77)") == ()
@@ -0,0 +1,55 @@
"""The school page's header flags (nextjs-app/lib/schoolFacts.ts) read seven
school_info fields, so the detail payload must carry every one.
It lacked admissions_policy, so no school page could flag Selective while its
search row did: Tiffin and The Grammar School at Leeds showed the tag in
search and nothing on their own pages.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
# The fields schoolFlags() picks from School (FlagFields in lib/schoolFacts.ts).
FLAG_FIELDS = (
"type_group", "admissions_policy", "gender", "religious_denomination",
"nursery_provision", "has_sixth_form", "phase",
)
def _school_df() -> pd.DataFrame:
return pd.DataFrame([{
"urn": 136910, "school_name": "Tiffin School", "phase": "Secondary",
"school_type": "Academy converter", "admissions_policy": "Selective",
"gender": "Boys", "religious_denomination": "Christian",
"nursery_provision": "Not applicable", "has_sixth_form": True,
"age_range": "11-18", "local_authority": "Kingston upon Thames",
"address": "Queen Elizabeth Road, Kingston upon Thames, KT2 6RL",
"postcode": "KT2 6RL", "latitude": 51.41, "longitude": -0.30,
"year": 202425, "attainment_8_score": 75.0, "total_pupils": 200,
"gias_total_pupils": 1478, "ofsted_grade": np.nan,
}])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _school_df)
monkeypatch.setattr(app_module, "load_latest_school_data", _school_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_school_page_carries_every_field_its_flags_read(client):
resp = client.get("/api/schools/136910")
assert resp.status_code == 200, resp.text
info = resp.json()["school_info"]
assert [f for f in FLAG_FIELDS if f not in info] == []
def test_the_school_page_says_a_selective_school_is_selective(client):
info = client.get("/api/schools/136910").json()["school_info"]
assert info["admissions_policy"] == "Selective"
+10 -8
View File
@@ -85,12 +85,15 @@ def _ofsted_row(urn, date, oe):
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
"personal_development", "leadership_management", "early_years_provision",
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
"ungraded_inspection_date", "rc_inspection_date", "latest_visit_outcome",
"rc_safeguarding_met", "rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
"rc_attendance_behaviour", "rc_personal_development", "rc_leadership_governance",
"rc_early_years", "rc_sixth_form", "report_url",
)}
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
overall_effectiveness=oe, grade_source=None)
when = types.SimpleNamespace(isoformat=lambda: date)
base.update(urn=urn, graded_inspection_date=when, latest_visit_date=when,
latest_visit_kind="graded", overall_effectiveness=oe,
current_grade=oe, current_grade_date=when, current_grade_basis="graded")
return types.SimpleNamespace(**base)
@@ -114,10 +117,9 @@ def _dist_row(urn, year, distance_m, route_count=1):
def test_one_query_per_table_and_latest_row_per_urn():
rows = {
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
"FactOfstedInspection": [
# The mart holds one current Ofsted status per URN.
"FactOfstedLatest": [
_ofsted_row(1, "2023-01-01", 2),
_ofsted_row(1, "2019-01-01", 3),
_ofsted_row(2, "2021-06-01", 1),
],
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
@@ -142,14 +144,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
assert sorted(session.queries) == [
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
"FactFinance", "FactKs4Destinations", "FactKs5Destinations",
"FactOfstedInspection", "FactPupilCharacteristics",
"FactOfstedLatest", "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
# Each URN's current Ofsted status
assert out[1]["ofsted"]["overall_effectiveness"] == 2
assert out[2]["ofsted"]["overall_effectiveness"] == 1
@@ -175,7 +177,7 @@ def test_one_query_per_table_and_latest_row_per_urn():
def test_single_wrapper_matches_batch(monkeypatch):
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
session = _FakeSession({"FactOfstedLatest": [_ofsted_row(5, "2022-01-01", 2)]})
single = data_loader.get_supplementary_data(session, 5)
assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == []
+35 -10
View File
@@ -10,7 +10,10 @@ from backend.data_loader import _admissions_row_dict, _ofsted_block
def _row(**kw):
base = dict(
framework="RC", inspection_date=None, inspection_type=None,
framework="RC", inspection_type=None,
graded_inspection_date=None, ungraded_inspection_date=None, rc_inspection_date=None,
latest_visit_date=None, latest_visit_kind=None, latest_visit_outcome=None,
current_grade=None, current_grade_date=None, current_grade_basis=None,
overall_effectiveness=None, quality_of_education=None,
behaviour_attitudes=None, personal_development=None,
leadership_management=None, early_years_provision=None,
@@ -33,20 +36,41 @@ def test_report_card_block_and_provider_url():
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
def test_grade_source_graded_vs_carried_forward():
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
assert carried["grade_source"] == "ungraded_carried_forward"
assert carried["overall_effectiveness"] == 2
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
def test_no_grade_is_carried_past_a_newer_inspection():
# Rabbsfarm (102408): the 2025 inspection gave no overall grade.
block = _ofsted_block(_row(
graded_inspection_date=date(2025, 6, 17), ungraded_inspection_date=date(2020, 2, 6),
latest_visit_date=date(2025, 6, 17), latest_visit_kind="graded",
ungraded_grade=2, ungraded_outcome="School remains Good", quality_of_education=3,
), urn=102408)
assert block["current_grade"] is None
assert block["overall_effectiveness"] is None
assert block["latest_visit"] == {"date": "2025-06-17", "kind": "graded", "outcome": None}
assert block["inspection_date"] == "2025-06-17"
assert "grade_source" not in block
def test_confirmed_grade_is_dated_by_the_confirming_visit():
block = _ofsted_block(_row(
graded_inspection_date=date(2020, 1, 7), ungraded_inspection_date=date(2024, 7, 18),
latest_visit_date=date(2024, 7, 18), latest_visit_kind="ungraded",
latest_visit_outcome="School remains Good", overall_effectiveness=2,
current_grade=2, current_grade_date=date(2024, 7, 18), current_grade_basis="confirmed",
), urn=104762)
assert block["current_grade"] == {"grade": 2, "date": "2024-07-18", "basis": "confirmed"}
assert block["overall_effectiveness"] == 2
assert block["inspection_date"] == "2020-01-07"
def test_overall_sentinel_is_not_served_as_a_grade():
assert _ofsted_block(_row(overall_effectiveness=9), urn=1)["overall_effectiveness"] is None
def test_ofsted_block_carries_rc_inspection_date():
o = _row(
ungraded_grade=2,
rc_achievement=1,
rc_inspection_date=date(2026, 2, 3),
inspection_date=date(2021, 10, 7),
graded_inspection_date=date(2021, 10, 7),
)
block = _ofsted_block(o, urn=138690)
assert block["rc_inspection_date"] == "2026-02-03"
@@ -55,7 +79,7 @@ def test_ofsted_block_carries_rc_inspection_date():
def test_ofsted_block_rc_inspection_date_none_when_absent():
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
o = _row(overall_effectiveness=1, graded_inspection_date=date(2021, 10, 13))
block = _ofsted_block(o, urn=136276)
assert block["rc_inspection_date"] is None
@@ -63,6 +87,7 @@ def test_ofsted_block_rc_inspection_date_none_when_absent():
def test_ofsted_block_keeps_existing_keys():
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
for key in ("framework", "inspection_date", "overall_effectiveness",
"current_grade", "latest_visit",
"quality_of_education", "rc_inclusion", "report_url"):
assert key in block
@@ -0,0 +1,100 @@
"""/api/schools school-type groups and faith filter, and their /api/filters lists."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
# urn -> (GIAS school type, GIAS religious character)
SCHOOLS = {
100001: ("Community school", "Does not apply"),
100002: ("Voluntary aided school", "Roman Catholic"),
100003: ("Academy converter", "Roman Catholic/Church of England"),
100004: ("Community special school", None),
100005: ("Academy special converter", "Church of England"),
100006: ("Other independent school", "Jewish"),
100007: ("Miscellaneous", ""),
}
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire", "address": "1 Test Street", "town": "Testtown",
"postcode": "TS1 1AA", "age_range": "4-11", "has_sixth_form": None,
"gender": "Mixed", "admissions_policy": None, "ofsted_grade": np.nan,
"ofsted_date": None, "ofsted_framework": None, "latitude": 51.5,
"longitude": -0.1, "year": 202425, "total_pupils": 300,
"rwm_expected_pct": np.nan, "attainment_8_score": np.nan, "phase": "Primary",
}
return pd.DataFrame([
{**base, "urn": urn, "school_name": f"School {urn}",
"school_type": t, "religious_denomination": r}
for urn, (t, r) in SCHOOLS.items()
])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(client, **params):
resp = client.get("/api/schools", params={"page_size": 50, **params})
assert resp.status_code == 200, resp.text
return sorted(s["urn"] for s in resp.json()["schools"])
@pytest.mark.parametrize("key, urns", [
("state", [100001, 100002, 100003]),
("academy", [100001, 100002, 100003]),
("council", [100001, 100002, 100003]),
("special", [100004, 100005]),
("independent", [100006]),
("Special", [100004, 100005]),
])
def test_a_type_group_key_filters_to_its_group(client, key, urns):
assert _urns(client, school_type=key) == urns
def test_a_raw_type_label_still_filters_exactly(client):
assert _urns(client, school_type="Community school") == [100001]
@pytest.mark.parametrize("key, urns", [
("roman_catholic", [100002, 100003]),
("church_of_england", [100003, 100005]),
("none", [100001, 100004, 100007]),
("jewish", [100006]),
("Roman_Catholic", [100002, 100003]),
])
def test_faith_filters_to_its_group_joint_schools_included(client, key, urns):
assert _urns(client, faith=key) == urns
def test_an_unknown_faith_returns_nothing(client):
assert _urns(client, faith="nonsense") == []
def test_type_and_faith_combine(client):
assert _urns(client, school_type="special", faith="church_of_england") == [100005]
def test_filters_lists_only_groups_present_in_order(client):
body = client.get("/api/filters").json()
assert body["school_type_groups"] == [
{"value": "state", "label": "State school (free)"},
{"value": "independent", "label": "Independent (fee-paying)"},
{"value": "special", "label": "Special school (SEND)"},
]
assert body["faiths"] == [
{"value": "none", "label": "No religious character"},
{"value": "church_of_england", "label": "Church of England"},
{"value": "roman_catholic", "label": "Roman Catholic"},
{"value": "jewish", "label": "Jewish"},
]
# The raw list is still there for anything that reads it.
assert "Community school" in body["school_types"]
+81
View File
@@ -0,0 +1,81 @@
"""Payloads name each school's type group.
The school page and the search rows say "State school" or "Independent
school" in the search filter's own terms, not GIAS's 34 establishment types,
and an independent school gets a Fee-paying flag. A type in no group keeps a
null group, and the page prints the register's own name for it. Search rows
also carry nursery_provision, for their "Nursery class" flag.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "phase": "Primary", "year": 202425,
"ofsted_grade": 2.0, "ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
"rwm_expected_pct": 60.0, "nursery_provision": "No Nursery Classes",
}
rows = [
{**base, "urn": 100001, "school_name": "Alpha Academy",
"school_type": "Academy converter", "nursery_provision": "Has Nursery Classes"},
{**base, "urn": 100002, "school_name": "Beta Prep",
"school_type": "Other independent school"},
{**base, "urn": 100003, "school_name": "Gamma Unit",
"school_type": "Secure units"},
]
# Enough schools in one town for it to have a place page.
rows += [
{**base, "urn": 100010 + i, "school_name": f"Delta Primary {i}",
"school_type": "Community school"}
for i in range(3)
]
return pd.DataFrame(rows)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def _by_urn(schools: list[dict]) -> dict[int, dict]:
return {s["urn"]: s for s in schools}
def test_the_list_names_each_type_group(client):
resp = client.get("/api/schools?page_size=50")
assert resp.status_code == 200, resp.text
schools = _by_urn(resp.json()["schools"])
assert schools[100001]["type_group"] == "state"
assert schools[100002]["type_group"] == "independent"
assert schools[100003]["type_group"] is None
def test_the_list_carries_nursery_provision(client):
schools = _by_urn(client.get("/api/schools?page_size=50").json()["schools"])
assert schools[100001]["nursery_provision"] == "Has Nursery Classes"
def test_the_school_page_names_its_type_group(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["type_group"] == "independent"
def test_a_place_page_names_each_type_group(client):
resp = client.get("/api/places/town/brentwood")
assert resp.status_code == 200, resp.text
schools = _by_urn(resp.json()["schools"])
assert schools[100001]["type_group"] == "state"
assert schools[100003]["type_group"] is None
+67
View File
@@ -0,0 +1,67 @@
"""Cards, map popups and place rows label total_pupils "pupils".
fact_performance's total_pupils is the cohort a year's results were measured
on. For a secondary that is the GCSE year group alone: Burntwood showed 245 in
search against 1,462 on roll. The list and place payloads therefore carry the
register's whole-school count, and nothing when the register has none.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy converter",
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
"gender": "Mixed", "rwm_expected_pct": np.nan, "attainment_8_score": 50.0,
}
rows = [
# Secondary: results cohort 245, register 1,462.
{**base, "urn": 100001, "school_name": "Alpha High", "phase": "Secondary",
"total_pupils": 245, "gias_total_pupils": 1462},
# Register count missing: no count, never the cohort.
{**base, "urn": 100002, "school_name": "Beta High", "phase": "Secondary",
"total_pupils": 180, "gias_total_pupils": np.nan},
]
# Enough schools in one town for it to have a place page.
rows += [
{**base, "urn": 100010 + i, "school_name": f"Gamma High {i}", "phase": "Secondary",
"total_pupils": 200, "gias_total_pupils": 1000 + i}
for i in range(5)
]
return pd.DataFrame(rows)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def _pupils(schools: list[dict]) -> dict[int, object]:
return {s["urn"]: s.get("total_pupils") for s in schools}
def test_the_list_carries_the_whole_school_count(client):
resp = client.get("/api/schools?page_size=50")
assert resp.status_code == 200, resp.text
pupils = _pupils(resp.json()["schools"])
assert pupils[100001] == 1462
assert pupils[100002] is None
def test_a_place_page_carries_the_whole_school_count(client):
resp = client.get("/api/places/town/brentwood")
assert resp.status_code == 200, resp.text
pupils = _pupils(resp.json()["schools"])
assert pupils[100001] == 1462
assert pupils[100002] is None
+1
View File
@@ -44,6 +44,7 @@ Their previous implementations remain recoverable from Git history.
| `nextjs-app/components/SchoolCard.tsx` and its CSS | Imported by its own tests, not application code. HomeView uses SchoolRow/SecondarySchoolRow. | Decide whether to retire the card design; if removed, remove its dedicated tests as well. Passing tests do not establish runtime use. |
| `backend/database.py: get_db`, `get_db_session` | No remaining callers after removing the importer. Current code creates SessionLocal directly. | Either adopt these helpers during session-lifecycle cleanup or remove them; do not rewrite active sessions in a documentation change. |
| `backend/schemas.py: COLUMN_MAPPINGS`, `NULL_VALUES`, `LA_CODE_TO_NAME` | No remaining Python consumers found after importer removal. Other constants in this module are active. | Remove individual constants after checking external data utilities; retain the module. |
| `backend/app.py: result_filters` keys `school_types`, `phases`, `genders`, `admissions_policies` | Since 2026-10-02 FilterBar offers these from `/api/filters`, because options scoped to the results left only the chosen value on offer. Only `local_authorities` is still read. | Stop computing the four keys in a focused API change; keep `local_authorities`. |
| `backend/config.py: data_dir`, `max_page_size`, `rate_limit_burst` | No active consumers found. `default_page_size` appears only in a branch that expects None, although the route supplies a concrete default. | Reconcile settings with route validation in a focused API change. |
## Legacy/manual paths requiring operational verification
@@ -0,0 +1,911 @@
# School Type Groups and a Faith Filter 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:** Replace the School type filter's 34 GIAS types with six parent-facing groups, and add a Faith filter with eight options.
**Architecture:** A new backend module `backend/school_groups.py` holds both groupings as GIAS code sets and exposes lookups by the translated *name* (the DataFrame the API filters only carries names). `/api/schools` accepts a group key for `school_type` and a new `faith` key; `/api/filters` gains `school_type_groups` and `faiths` as `{value, label}` lists. The frontend's FilterBar reads those lists for its School type select and a new Faith select.
**Tech Stack:** FastAPI + pandas (backend, pytest via uv), Next.js 15 / React (frontend, Jest + Testing Library), Playwright (E2E against staging).
**Spec:** `docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md`
## Global Constraints
- Type group keys, labels and order, verbatim: `academy` "State school: academy or free school", `council` "State school: council-run", `independent` "Independent (fee-paying)", `special` "Special school (SEND)", `post16` "Sixth form or college", `alternative` "Alternative provision".
- Faith keys, labels and order, verbatim: `none` "No religious character", `church_of_england` "Church of England", `roman_catholic` "Roman Catholic", `other_christian` "Other Christian", `jewish` "Jewish", `muslim` "Muslim", `other_faith` "Other faith".
- Select defaults, verbatim: "Any school type", "Any faith or none". Faith select `aria-label="Faith"`.
- `/api/filters` keeps `school_types` (raw list) unchanged; the new keys are additions.
- An old raw-label `school_type` value (e.g. `Community school`) still filters by that label exactly.
- An unknown `faith` key returns no schools.
- Do not change `backend/gias_codes.py` or `pipeline/scripts/gias_codes.py` (their parity is tested).
- No copy may use an em dash (`__tests__/components/noEmDashCopy.test.ts`).
- Backend tests run from the repo root with: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`
- Frontend checks run from `nextjs-app/`: `npx jest` and `npx tsc --noEmit`.
- Do not start a local server. E2E journeys are verified against staging after merge.
## Review Focus
- A school whose `religious_denomination` is `None`/NaN or `""` (GIAS code 99) must count as "No religious character", not drop out of every faith option. Pinned in Task 1.
- A type or religion label the dictionaries do not know (e.g. `"Unknown (9999)"`, or a test fixture's `"Academy"`) must map to no group without raising. Pinned in Task 1.
- `/api/filters` returning no `school_type_groups`/`faiths` (API failed, or an older backend) must leave the selects out rather than render "Any…" alone. Pinned in Task 3.
- An old bookmarked URL with `school_type=Community+school` must still return community schools and show a chip with its raw label. Pinned in Tasks 2 and 3.
- Mixed case in a key (`?faith=Roman_Catholic`, `?school_type=Special`) must still match. Pinned in Task 2.
---
## File Structure
| File | Responsibility |
|---|---|
| `backend/school_groups.py` (new) | The two groupings as code sets, and name-based lookups. Nothing else. |
| `backend/tests/test_school_groups.py` (new) | Coverage of every GIAS code, joint faiths, unknown/blank names. |
| `backend/app.py` (modify) | `faith` param; group-key branch for `school_type`; two new `/api/filters` keys. |
| `backend/tests/test_type_and_faith_filters.py` (new) | API behaviour of both filters and the new `/api/filters` keys. |
| `nextjs-app/lib/types.ts` (modify) | `FilterOption`; optional `school_type_groups`/`faiths` on `Filters`; `faith` on `SchoolSearchParams`. |
| `nextjs-app/app/(frontend)/page.tsx` (modify) | Read and forward `faith`. |
| `nextjs-app/components/FilterBar.tsx` (modify) | Grouped School type options; Faith select; chips, counts, analytics. |
| `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx` (new) | Frontend behaviour of both selects. |
| Existing FilterBar tests (modify) | Fixtures move from raw types to groups. |
| `e2e/tests/journeys.spec.ts` (modify) | A journey for both filters. |
| Spec (modify) | Architecture section: grouping is by name at filter time. |
---
### Task 1: The groupings module
**Files:**
- Create: `backend/school_groups.py`
- Test: `backend/tests/test_school_groups.py`
- Modify: `docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md` (Architecture)
**Interfaces:**
- Consumes: `backend.gias_codes.SCHOOL_TYPE: dict[int, str]`, `backend.gias_codes.RELIGIOUS_CHARACTER: dict[int, str]` (code 99 maps to `""`).
- Produces:
- `TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...]` (key, label, codes), in display order
- `UNOFFERED_TYPE_CODES: frozenset[int]`
- `FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...]`, in display order
- `TYPE_GROUP_KEYS: frozenset[str]`, `FAITH_KEYS: frozenset[str]`
- `type_group_for(name: object) -> str | None`
- `faith_groups_for(name: object) -> tuple[str, ...]`
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_school_groups.py`:
```python
"""Parent-facing groups over GIAS establishment types and religious characters.
Every GIAS code must be accounted for, so a new DfE code fails here instead of
silently vanishing from the filter.
"""
from pathlib import Path
import numpy as np
import pytest
import yaml
from backend.gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
from backend.school_groups import (
FAITH_GROUPS,
TYPE_GROUPS,
UNOFFERED_TYPE_CODES,
faith_groups_for,
type_group_for,
)
DBT_PROJECT = Path(__file__).resolve().parents[2] / "pipeline" / "transform" / "dbt_project.yml"
def _non_england_codes() -> set[int]:
return set(yaml.safe_load(DBT_PROJECT.read_text())["vars"]["non_england_school_type_codes"])
def test_every_type_code_is_in_exactly_one_place():
places = [codes for _, _, codes in TYPE_GROUPS] + [UNOFFERED_TYPE_CODES, _non_england_codes()]
for code in SCHOOL_TYPE:
homes = sum(code in p for p in places)
assert homes == 1, f"type code {code} ({SCHOOL_TYPE[code]}) is in {homes} places"
def test_every_religion_code_has_a_faith():
for code, name in RELIGIOUS_CHARACTER.items():
assert any(code in codes for _, _, codes in FAITH_GROUPS), f"{code} {name!r}"
def test_type_groups_in_display_order():
assert [k for k, _, _ in TYPE_GROUPS] == [
"academy", "council", "independent", "special", "post16", "alternative"]
def test_faiths_in_display_order():
assert [k for k, _, _ in FAITH_GROUPS] == [
"none", "church_of_england", "roman_catholic", "other_christian",
"jewish", "muslim", "other_faith"]
@pytest.mark.parametrize("name, group", [
("Academy converter", "academy"),
("University technical college", "academy"),
("Voluntary aided school", "council"),
("Local authority nursery school", "council"),
("Other independent school", "independent"),
("Other independent special school", "special"),
("Special post 16 institution", "special"),
("Further education", "post16"),
("Pupil referral unit", "alternative"),
("academy CONVERTER", "academy"),
])
def test_type_group_by_name(name, group):
assert type_group_for(name) == group
@pytest.mark.parametrize("name", [
"Higher education institutions", "Miscellaneous", "Unknown (9999)", "Academy", "", None, np.nan,
])
def test_unoffered_or_unknown_types_have_no_group(name):
assert type_group_for(name) is None
@pytest.mark.parametrize("name, faiths", [
("Roman Catholic/Church of England", ("church_of_england", "roman_catholic")),
("Roman Catholic/Anglican", ("church_of_england", "roman_catholic")),
("Church of England/Methodist", ("church_of_england", "other_christian")),
("Church of England/Christian", ("church_of_england",)),
("Catholic", ("roman_catholic",)),
("Inter- / non- denominational", ("other_christian",)),
("Orthodox Jewish", ("jewish",)),
("Sunni Deobandi", ("muslim",)),
("Hindu", ("other_faith",)),
("Does not apply", ("none",)),
("None", ("none",)),
])
def test_faiths_by_name(name, faiths):
assert faith_groups_for(name) == faiths
@pytest.mark.parametrize("missing", [None, np.nan, "", " "])
def test_a_missing_religion_is_no_religious_character(missing):
assert faith_groups_for(missing) == ("none",)
def test_an_unknown_religion_has_no_faith():
assert faith_groups_for("Unknown (77)") == ()
```
- [ ] **Step 2: Run them to see them fail**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_school_groups.py -q`
Expected: collection error, `ModuleNotFoundError: No module named 'backend.school_groups'`.
- [ ] **Step 3: Write the module**
Create `backend/school_groups.py`:
```python
"""Parent-facing groups over GIAS establishment types and religious characters.
The 34 GIAS establishment types describe governance and funding, which for a
mainstream state school barely changes what a parent experiences. The search
filter offers six groups a parent recognises instead, and a faith filter in
place of the faith signal that "Voluntary aided" and "Voluntary controlled"
only half carry. See
docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md.
Groups are defined over GIAS codes and looked up by the translated name,
because the DataFrame the API filters carries names only: the codes are
replaced at load (data_loader.translate_gias_code_columns), the legacy-name
mart fallback never had them, and test fixtures are written in names.
"""
from typing import Optional
import pandas as pd
from .gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE
# (key, label, GIAS TypeOfEstablishment codes), in the order shown.
TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Academy sponsor led, Academy converter, Free schools, University technical
# college, Studio schools, City technology college. UTCs and studio schools
# are legally academies, and a family considering one searches by name.
("academy", "State school: academy or free school", frozenset({28, 34, 35, 40, 41, 6})),
# Community, Voluntary aided, Voluntary controlled, Foundation, LA nursery.
("council", "State school: council-run", frozenset({1, 2, 3, 5, 15})),
("independent", "Independent (fee-paying)", frozenset({11})),
# Every special type, independent ones included (usually funded by the
# council through an EHCP, so SEND provision to a parent, not private
# school), and Special post 16 institutions.
("special", "Special school (SEND)", frozenset({7, 8, 10, 12, 32, 33, 36, 44})),
# Further education, Sixth form centres, the 16-19 academies and free schools.
("post16", "Sixth form or college", frozenset({18, 31, 39, 45, 46})),
# Pupil referral units and AP academies and free schools. Last: parents do
# not apply to these; the local authority places children there.
("alternative", "Alternative provision", frozenset({14, 38, 42, 43})),
)
# In no group, so reachable only under "Any school type": Secure units,
# Miscellaneous, Higher education institutions, Online provider, Institution
# funded by other government department, Academy secure 16 to 19.
UNOFFERED_TYPE_CODES: frozenset[int] = frozenset({24, 27, 29, 49, 56, 57})
# (key, label, GIAS ReligiousCharacter codes), in the order shown. A joint
# school is in every faith its label names; a generic "Christian" beside a
# named church adds nothing.
FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = (
# Does not apply, None, and 99 (a blank label).
("none", "No religious character", frozenset({0, 6, 99})),
("church_of_england", "Church of England",
frozenset({2, 9, 10, 11, 12, 13, 19, 20, 30, 31, 32, 33, 34, 41, 48})),
("roman_catholic", "Roman Catholic", frozenset({3, 11, 13, 35, 48})),
# 28 "Inter- / non- denominational" is how GIAS files Christian schools
# tied to no one church.
("other_christian", "Other Christian",
frozenset({4, 8, 9, 10, 12, 14, 15, 16, 17, 18, 19, 22, 26, 28, 30, 33,
37, 38, 39, 40, 41, 44, 45, 46, 47})),
("jewish", "Jewish", frozenset({5, 36, 43})),
("muslim", "Muslim", frozenset({7, 42, 49})),
("other_faith", "Other faith", frozenset({21, 24, 25, 29})),
)
TYPE_GROUP_KEYS: frozenset[str] = frozenset(k for k, _, _ in TYPE_GROUPS)
FAITH_KEYS: frozenset[str] = frozenset(k for k, _, _ in FAITH_GROUPS)
def _key(name: str) -> str:
return name.strip().lower()
_TYPE_GROUP_BY_NAME: dict[str, str] = {
_key(SCHOOL_TYPE[code]): key
for key, _, codes in TYPE_GROUPS
for code in codes
if code in SCHOOL_TYPE
}
_FAITHS_BY_NAME: dict[str, tuple[str, ...]] = {}
for _faith, _, _codes in FAITH_GROUPS:
for _code in sorted(_codes):
if _code in RELIGIOUS_CHARACTER:
_name = _key(RELIGIOUS_CHARACTER[_code])
_FAITHS_BY_NAME[_name] = _FAITHS_BY_NAME.get(_name, ()) + (_faith,)
def type_group_for(name: object) -> Optional[str]:
"""The type group of a GIAS establishment type name, or None."""
if not isinstance(name, str):
return None
return _TYPE_GROUP_BY_NAME.get(_key(name))
def faith_groups_for(name: object) -> tuple[str, ...]:
"""The faith groups of a GIAS religious character name.
A missing or blank name is "No religious character". A name the
dictionary does not know has no faith, so it matches no faith option.
"""
if not isinstance(name, str):
return ("none",) if name is None or pd.isna(name) else ()
return _FAITHS_BY_NAME.get(_key(name), ())
```
- [ ] **Step 4: Run the tests to see them pass**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_school_groups.py -q`
Expected: all pass.
- [ ] **Step 5: Correct the spec's architecture**
In the spec, replace the `### backend/data_loader.py` subsection with:
```markdown
### Grouping by name, at filter time
The groups are defined over codes but looked up by the translated name
(`type_group_for(name)`, `faith_groups_for(name)`), when `/api/schools` and
`/api/filters` filter. The DataFrame those endpoints read carries names only:
`translate_gias_code_columns` replaces the codes at load, the legacy-name
mart fallback never had codes, and the API test fixtures are written in
names. The names come from the same dictionaries, so the lookup is exact.
`data_loader.py` is unchanged.
```
and in `### backend/school_groups.py (new)` replace the `type_group_for(code)` / `faith_groups_for(code)` bullet with `type_group_for(name) -> str | None` and `faith_groups_for(name) -> tuple[str, ...]` (a missing or blank name gives `("none",)`).
- [ ] **Step 6: Commit**
```bash
git add backend/school_groups.py backend/tests/test_school_groups.py docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md
git commit -m "feat(api): group GIAS school types and religions for parents"
```
---
### Task 2: The API filters
**Files:**
- Modify: `backend/app.py` (imports near line 43; `get_schools` params ~738-747, sanitising ~755-759, secondary filters ~782-786, `school_type` filter ~886-889; `get_filter_options` ~1155-1180)
- Test: `backend/tests/test_type_and_faith_filters.py`
**Interfaces:**
- Consumes: from Task 1, `TYPE_GROUPS`, `FAITH_GROUPS`, `TYPE_GROUP_KEYS`, `FAITH_KEYS`, `type_group_for`, `faith_groups_for`.
- Produces:
- `GET /api/schools?school_type=<group key | raw label>&faith=<faith key>`
- `GET /api/filters` adds `"school_type_groups": [{"value": str, "label": str}]` and `"faiths": [{"value": str, "label": str}]`, in group order, groups with no school left out.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_type_and_faith_filters.py`:
```python
"""/api/schools school-type groups and faith filter, and their /api/filters lists."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
# urn -> (GIAS school type, GIAS religious character)
SCHOOLS = {
100001: ("Community school", "Does not apply"),
100002: ("Voluntary aided school", "Roman Catholic"),
100003: ("Academy converter", "Roman Catholic/Church of England"),
100004: ("Community special school", None),
100005: ("Academy special converter", "Church of England"),
100006: ("Other independent school", "Jewish"),
100007: ("Miscellaneous", ""),
}
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire", "address": "1 Test Street", "town": "Testtown",
"postcode": "TS1 1AA", "age_range": "4-11", "has_sixth_form": None,
"gender": "Mixed", "admissions_policy": None, "ofsted_grade": np.nan,
"ofsted_date": None, "ofsted_framework": None, "latitude": 51.5,
"longitude": -0.1, "year": 202425, "total_pupils": 300,
"rwm_expected_pct": np.nan, "attainment_8_score": np.nan, "phase": "Primary",
}
return pd.DataFrame([
{**base, "urn": urn, "school_name": f"School {urn}",
"school_type": t, "religious_denomination": r}
for urn, (t, r) in SCHOOLS.items()
])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(client, **params):
resp = client.get("/api/schools", params={"page_size": 50, **params})
assert resp.status_code == 200, resp.text
return sorted(s["urn"] for s in resp.json()["schools"])
@pytest.mark.parametrize("key, urns", [
("council", [100001, 100002]),
("academy", [100003]),
("special", [100004, 100005]),
("independent", [100006]),
("Special", [100004, 100005]),
])
def test_a_type_group_key_filters_to_its_group(client, key, urns):
assert _urns(client, school_type=key) == urns
def test_a_raw_type_label_still_filters_exactly(client):
assert _urns(client, school_type="Community school") == [100001]
@pytest.mark.parametrize("key, urns", [
("roman_catholic", [100002, 100003]),
("church_of_england", [100003, 100005]),
("none", [100001, 100004, 100007]),
("jewish", [100006]),
("Roman_Catholic", [100002, 100003]),
])
def test_faith_filters_to_its_group_joint_schools_included(client, key, urns):
assert _urns(client, faith=key) == urns
def test_an_unknown_faith_returns_nothing(client):
assert _urns(client, faith="nonsense") == []
def test_type_and_faith_combine(client):
assert _urns(client, school_type="special", faith="church_of_england") == [100005]
def test_filters_lists_only_groups_present_in_order(client):
body = client.get("/api/filters").json()
assert body["school_type_groups"] == [
{"value": "academy", "label": "State school: academy or free school"},
{"value": "council", "label": "State school: council-run"},
{"value": "independent", "label": "Independent (fee-paying)"},
{"value": "special", "label": "Special school (SEND)"},
]
assert body["faiths"] == [
{"value": "none", "label": "No religious character"},
{"value": "church_of_england", "label": "Church of England"},
{"value": "roman_catholic", "label": "Roman Catholic"},
{"value": "jewish", "label": "Jewish"},
]
# The raw list is still there for anything that reads it.
assert "Community school" in body["school_types"]
```
- [ ] **Step 2: Run them to see them fail**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_type_and_faith_filters.py -q`
Expected: the group-key, faith and `/api/filters` tests fail (`KeyError: 'school_type_groups'`, empty URN lists); `test_a_raw_type_label_still_filters_exactly` passes.
- [ ] **Step 3: Implement**
In `backend/app.py`, add beside the other local imports (after the `from .schemas import ...` line):
```python
from .school_groups import (
FAITH_GROUPS,
FAITH_KEYS,
TYPE_GROUP_KEYS,
TYPE_GROUPS,
faith_groups_for,
type_group_for,
)
```
Add a helper just above `def sanitize_search_input`:
```python
def _names_in_group(names: pd.Series, in_group) -> set:
"""The distinct names in a column that a group predicate accepts.
Evaluated once per distinct name rather than per row, so a filter over
every school costs a few dozen lookups.
"""
return {n for n in names.dropna().unique() if in_group(n)}
```
In `get_schools`, add the parameter after `has_sixth_form`:
```python
faith: Optional[str] = Query(None, description="Filter by faith group key", max_length=40),
```
and after `phase = sanitize_search_input(phase)`:
```python
faith = sanitize_search_input(faith)
```
After the `has_sixth_form` filter block (the one ending `df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]`), add:
```python
# Faith group (backend/school_groups.py). A joint school is in every faith
# its label names; a missing religious character is "none". An unknown key
# matches nothing rather than being ignored, so a typo cannot show all.
if faith:
faith_key = faith.lower()
if faith_key in FAITH_KEYS and "religious_denomination" in df_latest.columns:
column = df_latest["religious_denomination"]
matches = column.isin(_names_in_group(column, lambda n: faith_key in faith_groups_for(n)))
# _names_in_group skips missing names; a missing religious
# character is "No religious character".
if faith_key == "none":
matches = matches | column.isna()
df_latest = df_latest[matches]
else:
df_latest = df_latest.iloc[0:0]
```
Replace the existing `school_type` filter:
```python
if school_type:
schools_df = schools_df[
schools_df["school_type"].str.lower() == school_type.lower()
]
```
with:
```python
# A type group key (backend/school_groups.py), or for an old link a raw
# GIAS type label, matched exactly as before.
if school_type:
type_key = school_type.lower()
if type_key in TYPE_GROUP_KEYS:
column = schools_df["school_type"]
schools_df = schools_df[
column.isin(_names_in_group(column, lambda n: type_group_for(n) == type_key))
]
else:
schools_df = schools_df[schools_df["school_type"].str.lower() == type_key]
```
In `get_filter_options`, add to the early `if df.empty:` return dict:
```python
"school_type_groups": [],
"faiths": [],
```
and before the final `return {`:
```python
def offered(groups, present):
return [{"value": key, "label": label} for key, label, _ in groups if key in present]
type_groups_present = (
{type_group_for(n) for n in df["school_type"].dropna().unique()} - {None}
if "school_type" in df.columns else set()
)
faiths_present = (
{f for n in df["religious_denomination"].unique() for f in faith_groups_for(n)}
if "religious_denomination" in df.columns else set()
)
```
and add to the returned dict:
```python
"school_type_groups": offered(TYPE_GROUPS, type_groups_present),
"faiths": offered(FAITH_GROUPS, faiths_present),
```
- [ ] **Step 4: Run the new tests, then the whole backend suite**
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_type_and_faith_filters.py -q`
Expected: all pass.
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests pipeline/tests scripts/ci/tests -q`
Expected: all pass (the CI command; `pyyaml` comes from requirements or add `--with pyyaml` if collection fails on `import yaml`).
- [ ] **Step 5: Commit**
```bash
git add backend/app.py backend/tests/test_type_and_faith_filters.py
git commit -m "feat(api): filter schools by type group and by faith"
```
---
### Task 3: The frontend filters
**Files:**
- Modify: `nextjs-app/lib/types.ts` (`Filters` ~487-494, `SchoolSearchParams` ~558-570)
- Modify: `nextjs-app/app/(frontend)/page.tsx` (props type ~15-29, `hasSearchParams` ~79-88, `fetchSchools` call ~96-109)
- Modify: `nextjs-app/components/FilterBar.tsx`
- Test: `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx` (new)
- Modify tests: `__tests__/components/ResultsToolbar.test.tsx`, `FilterSheet.test.tsx`, `FilterSheetPending.test.tsx`, `FilterBarOptions.test.tsx`
**Interfaces:**
- Consumes: `/api/filters` keys `school_type_groups`, `faiths` (Task 2); URL params `school_type` (group key) and `faith`.
- Produces: `export interface FilterOption { value: string; label: string }`; `Filters.school_type_groups?: FilterOption[]`; `Filters.faiths?: FilterOption[]`; `SchoolSearchParams.faith?: string`. FilterBar selects named "School type" and "Faith".
- [ ] **Step 1: Write the failing tests**
Create `nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx`:
```tsx
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import { track } from '@/lib/analytics';
/*
* School type offers six groups a parent recognises, not GIAS's 34 types, and
* a Faith filter sits beside it (spec 2026-10-02-school-type-groups-and-faith-
* filter-design.md). Both lists come from /api/filters.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
{ value: 'special', label: 'Special school (SEND)' },
],
faiths: [
{ value: 'none', label: 'No religious character' },
{ value: 'roman_catholic', label: 'Roman Catholic' },
],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
const optionsOf = (scope: HTMLElement, name: string) =>
within(within(scope).getByRole('combobox', { name })).getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
jest.mocked(track).mockClear();
});
describe('School type', () => {
it('offers the groups, not the GIAS types, on desktop and in the sheet', () => {
render(<FilterBar filters={filters} />);
const groups = ['Any school type', 'State school: academy or free school',
'State school: council-run', 'Special school (SEND)'];
expect(optionsOf(screen.getByRole('group', { name: 'Filters' }), 'School type')).toEqual(groups);
expect(optionsOf(openSheet(), 'School type')).toEqual(groups);
});
it('puts the group key in the URL and names the chip by its label', () => {
const view = render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'special' } });
expect(pushedParams().get('school_type')).toBe('special');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=special');
view.rerender(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
});
it('names an old raw-label link\'s chip by that label', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument();
});
it('is left out when the API sends no groups', () => {
render(<FilterBar filters={{ ...filters, school_type_groups: undefined }} />);
expect(screen.queryByRole('combobox', { name: 'School type' })).not.toBeInTheDocument();
});
});
describe('Faith', () => {
it('offers its options in the More filters panel and in the sheet', () => {
render(<FilterBar filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: /More filters/ }));
const faiths = ['Any faith or none', 'No religious character', 'Roman Catholic'];
expect(optionsOf(document.body, 'Faith')).toEqual(faiths);
expect(optionsOf(openSheet(), 'Faith')).toEqual(faiths);
});
it('shows as a chip, counts on both buttons, and clears with Clear all', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&faith=roman_catholic');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
expect(pushedParams().get('faith')).toBeNull();
expect(pushedParams().get('postcode')).toBe('SW196AR');
});
it('goes into the search analytics event', () => {
params = new URLSearchParams('faith=roman_catholic');
render(<FilterBar filters={filters} />);
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
fireEvent.change(input, { target: { value: 'st marys' } });
fireEvent.submit(input.closest('form')!);
expect(track).toHaveBeenCalledWith('search_submitted',
expect.objectContaining({ filters_active: 'faith=roman_catholic', filters_count: 1 }));
});
it('is left out when the API sends no faiths', () => {
render(<FilterBar filters={{ ...filters, faiths: [] }} />);
expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run them to see them fail**
Run (from `nextjs-app/`): `npx jest __tests__/components/FilterBarTypeFaith.test.tsx`
Expected: failures; School type still lists `Community school`, no `Faith` combobox. (`tsc` also flags `school_type_groups` on `Filters`, which Step 3 fixes.)
- [ ] **Step 3: Types and page plumbing**
In `nextjs-app/lib/types.ts`, above `export interface Filters`:
```ts
/** A filter option whose URL value differs from what a parent reads. */
export interface FilterOption {
value: string;
label: string;
}
```
and add to `Filters` (after `admissions_policies`):
```ts
/** Parent-facing school type groups; the URL's school_type takes their value. */
school_type_groups?: FilterOption[];
faiths?: FilterOption[];
```
and to `SchoolSearchParams` (after `has_sixth_form`):
```ts
faith?: string;
```
In `nextjs-app/app/(frontend)/page.tsx`: add `faith?: string;` to the `searchParams` type after `has_sixth_form?: string;`; add `params.faith ||` to `hasSearchParams` after `params.has_sixth_form`; add `faith: params.faith,` to the `fetchSchools({...})` call after `has_sixth_form: params.has_sixth_form,`.
- [ ] **Step 4: FilterBar**
In `nextjs-app/components/FilterBar.tsx`:
1. Add `"faith"` to `FILTER_KEYS` directly after `"school_type"`.
2. After `const currentHasSixthForm = ...` add:
```tsx
const currentFaith = searchParams.get("faith") || "";
```
3. Add `currentFaith,` to the `activeDropdownFilters` array (after `currentLA,`). Faith lives behind More filters, so it counts there.
4. In `handleSearchSubmit`'s `filters_active` list, after the `type=` line add:
```tsx
currentFaith && `faith=${currentFaith}`,
```
5. Add `currentFaith ||` to `hasActiveFilters` after `currentType ||`.
6. Replace `const typeOptions = filters.school_types;` with:
```tsx
// Six groups a parent recognises, not GIAS's 34 establishment types.
const typeOptions = filters.school_type_groups ?? [];
const faithOptions = filters.faiths ?? [];
```
7. Add `faith: currentFaith,` to the `values` record after `school_type: currentType,`.
8. In `labelFor`, before the `// Phase, gender and admissions values…` comment, add:
```tsx
// An old link may carry a raw GIAS type, which reads as itself.
const labelled = { school_type: typeOptions, faith: faithOptions }[
key as "school_type" | "faith"
];
if (labelled) return labelled.find((o) => o.value === value)?.label ?? value;
```
9. In `typeSelect`, replace the options map with:
```tsx
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
```
10. After `laSelect`, add:
```tsx
const faithSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentFaith}
onChange={(e) => handleFilterChange("faith", e.target.value)}
className={selectClass(look, currentFaith)}
aria-label="Faith"
disabled={isPending && look !== "sheet"}
>
<option value="">Any faith or none</option>
{faithOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectShell>
);
```
11. In the desktop row, change `{typeSelect("pill")}` to `{typeOptions.length > 0 && typeSelect("pill")}`.
12. In the More filters panel, after `{laSelect("panel")}` add `{faithOptions.length > 0 && faithSelect("panel")}`.
13. In the sheet, replace `<SheetField label="School type">{typeSelect("sheet")}</SheetField>` with:
```tsx
{typeOptions.length > 0 && (
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
)}
{faithOptions.length > 0 && (
<SheetField label="Faith">{faithSelect("sheet")}</SheetField>
)}
```
- [ ] **Step 5: Move the existing tests' fixtures to groups**
These tests used raw types as values; give each fixture the groups and use keys:
- `ResultsToolbar.test.tsx`: add to `filters` `school_type_groups: [{ value: 'council', label: 'State school: council-run' }],`; in 'counts only what More filters hides' change `school_type=Community+school` to `school_type=council`.
- `FilterSheet.test.tsx`: add the same `school_type_groups` to `filters`; change both `school_type=Community+school` to `school_type=council`.
- `FilterSheetPending.test.tsx`: add the same `school_type_groups`; change the `{ target: { value: 'Community school' } }` to `{ target: { value: 'council' } }` and `toBe('Community school')` to `toBe('council')`.
- `FilterBarOptions.test.tsx`: add to `filters` `school_type_groups: [{ value: 'academy', label: 'State school: academy or free school' }, { value: 'council', label: 'State school: council-run' }],`; change `school_type=Community+school` (two places) to `school_type=council`; change the expected School type options to `['Any school type', 'State school: academy or free school', 'State school: council-run']`; change `toBe('Community school')` to `toBe('council')`; rename the first test to 'offer every school type group, gender and admissions policy, whatever the results hold'; and in the header comment replace "School type, gender and admissions offer the full lists" with "School type groups, gender and admissions offer the full lists".
- [ ] **Step 6: Run the frontend checks**
Run (from `nextjs-app/`): `npx jest` then `npx tsc --noEmit`
Expected: all suites pass; no type errors.
- [ ] **Step 7: Commit**
```bash
git add nextjs-app/lib/types.ts "nextjs-app/app/(frontend)/page.tsx" nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx nextjs-app/__tests__/components/ResultsToolbar.test.tsx nextjs-app/__tests__/components/FilterSheet.test.tsx nextjs-app/__tests__/components/FilterSheetPending.test.tsx nextjs-app/__tests__/components/FilterBarOptions.test.tsx
git commit -m "feat(search): school type groups and a faith filter"
```
---
### Task 4: E2E journey, build, PR
**Files:**
- Modify: `e2e/tests/journeys.spec.ts` (insert before `test('a phase outside primary/secondary filters to that phase, not to everything'`)
**Interfaces:**
- Consumes: the "School type" and "Faith" selects (Task 3); `/api/schools?school_type=&faith=` (Task 2).
- [ ] **Step 1: Add the journey**
```ts
/*
* School type offers six groups a parent recognises, and Faith sits beside it.
* Data-invariant: asserts what every returned school is, never how many.
*/
test('school type groups and the faith filter narrow to what they name', async ({ page }) => {
await page.goto('/?search=school');
const type = page.getByRole('combobox', { name: 'School type', exact: true });
await expect(type).toBeVisible({ timeout: 15_000 });
await type.selectOption({ label: 'Special school (SEND)' });
await expect(page).toHaveURL(/[?&]school_type=special(&|$)/);
await page.getByRole('button', { name: /^More filters/ }).click();
await page.getByRole('combobox', { name: 'Faith', exact: true }).selectOption({ label: 'Roman Catholic' });
await expect(page).toHaveURL(/[?&]faith=roman_catholic(&|$)/);
const res = await page.request.get(
'/api/schools?search=school&school_type=special&faith=roman_catholic&page_size=100');
expect(res.ok()).toBeTruthy();
for (const s of (await res.json()).schools as { school_type: string; religious_denomination: string }[]) {
expect(s.school_type).toMatch(/special/i);
expect(s.religious_denomination).toMatch(/catholic/i);
}
});
```
- [ ] **Step 2: Confirm it parses, and fails on today's staging**
Run (from `e2e/`): `npx playwright test --list | grep "school type groups"`
Expected: the test is listed.
Run: `BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "school type groups" --retries=0 --reporter=line`
Expected: FAIL at `selectOption({ label: 'Special school (SEND)' })`, because staging still offers the raw types. It can only pass after merge (the staging E2E gate runs post-merge).
- [ ] **Step 3: Build without a database**
Run (from `nextjs-app/`): `env -u DATABASE_URL npm run build`
Expected: build completes.
- [ ] **Step 4: Commit, push, open the PR**
```bash
git add e2e/tests/journeys.spec.ts
git commit -m "test(e2e): school type groups and the faith filter"
git push -u origin feat/school-type-groups-faith
```
Open the PR on Gitea against `main` (credential-helper basic auth, as for PRs #168/#169), listing: the six groups and seven faiths, the old-link compatibility, the backend-by-name decision, test counts, and that the new journey can only pass post-merge.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,233 @@
# School Type Groups and a Faith Filter — Design
**Date:** 2026-10-02
**Status:** shipped in PR #170; revised 2026-10-02 (one state group)
**Scope:** search filters (`/` results toolbar and phone filter sheet), `/api/schools`, `/api/filters`
## Goal
Replace the School type filter's 34 GIAS establishment types with five groups a
parent recognises, and add a Faith filter.
> **Revision, 2026-10-02.** PR #170 shipped six groups, with state schools
> split into "academy or free school" and "council-run". They are now one,
> "State school (free)". The split was two near-halves of the same pool, so it
> rarely narrowed anything, and it did not follow the difference a parent feels
> most, admissions: voluntary aided and foundation schools set their own
> admissions, as academies do, while community and voluntary controlled
> schools have theirs set by the council. Faith, which voluntary aided mostly
> meant, has its own filter. The old keys `academy` and `council` resolve to
> `state`, so their links keep working.
Since PR #169 the School type select offers the full GIAS list rather than the
types in the results. That fixed the trap where choosing a type left only that
type on offer, but it exposed the list itself: "Academy converter", "Academy
sponsor led", "Free schools", "Foundation school", "Voluntary controlled
school" and 29 more. These describe governance and funding. For a mainstream
state school they change almost nothing a parent experiences, and parents
cannot be expected to know the differences.
What parents actually ask is: is it free, is it mainstream, is it for children
with special needs, is it a sixth form or college, and is it a faith school.
The first four are the type groups below. The fifth is the real meaning behind
"Voluntary aided" and "Voluntary controlled", but many academies are faith
schools too, so it gets its own filter rather than hiding inside type.
## Non-goals
- Changing what a school page or result row shows. They keep the precise GIAS
type ("Academy sponsor led"), where it is information, not a choice.
- Removing the "not offered" types from search results. They stay reachable
under "Any school type"; whether parents should see them at all is a
separate decision.
- Reordering the type options by phase (for example "Sixth form or college"
first for 16 plus). Fixed order is simpler.
- The rankings page, which has no type filter.
- Changing `result_filters`. Its `school_types` key is already unread
(docs/LEGACY_CODE.md).
## Type groups
Order is the order shown. Codes are GIAS `TypeOfEstablishment` codes
(`backend/gias_codes.py: SCHOOL_TYPE`). Counts are staging schools on
2026-10-02.
| Key | Label | GIAS codes | Schools |
|---|---|---|---|
| `state` | State school (free) | 28 Academy sponsor led, 34 Academy converter, 35 Free schools, 40 University technical college, 41 Studio schools, 6 City technology college, 1 Community school, 2 Voluntary aided school, 3 Voluntary controlled school, 5 Foundation school, 15 Local authority nursery school | 20,502 |
| `independent` | Independent (fee-paying) | 11 Other independent school | 1,585 |
| `special` | Special school (SEND) | 7 Community special, 12 Foundation special, 44 Academy special converter, 33 Academy special sponsor led, 36 Free schools special, 8 Non-maintained special, 10 Other independent special, 32 Special post 16 institution | 2,227 |
| `post16` | Sixth form or college | 18 Further education, 31 Sixth form centres, 45 Academy 16-19 converter, 46 Academy 16 to 19 sponsor led, 39 Free schools 16 to 19 | 302 |
| `alternative` | Alternative provision | 14 Pupil referral unit, 42 Academy AP converter, 43 Academy AP sponsor led, 38 Free schools AP | 331 |
**Not offered** (no group, reachable only under "Any school type"): 29 Higher
education institutions, 27 Miscellaneous, 24 Secure units, 49 Online provider,
57 Academy secure 16 to 19, 56 Institution funded by other government
department. 238 schools.
**Excluded upstream** (never in the marts): 25, 26, 30, 37, per
`non_england_school_type_codes` in `pipeline/transform/dbt_project.yml`.
### Judgement calls
- **Independent special schools are Special, not Independent.** They are
usually funded by the local authority through a child's EHCP; to a parent
they are SEND provision, not private school.
- **UTCs, studio schools and city technology colleges are state schools.** They
are legally academies, they are few (66 together), and a family considering
one searches for it by name.
- **Academies and council-run schools are one group.** See the revision note
under Goal.
- **Special post 16 institutions are Special, not Sixth form or college.** The
defining fact for a parent is the SEND provision.
- **Alternative provision is last.** Parents do not apply to it; the local
authority places children there.
## Faith groups
Codes are GIAS `ReligiousCharacter` codes
(`backend/gias_codes.py: RELIGIOUS_CHARACTER`). A joint school belongs to every
faith its label names, so "Roman Catholic/Church of England" matches both
Church of England and Roman Catholic. A generic "Christian" alongside a named
denomination adds nothing ("Church of England/Christian" is Church of England
only).
| Key | Label | GIAS codes |
|---|---|---|
| `none` | No religious character | 0 Does not apply, 6 None, 99 (blank), and a missing code |
| `church_of_england` | Church of England | 2, 31 Anglican, 34 Anglican/Church of England, 20 CofE/Christian, 32 Anglican/Christian, and the joint codes 9, 10, 11, 12, 13, 19, 30, 33, 41, 48 |
| `roman_catholic` | Roman Catholic | 3, 35 Catholic, and the joint codes 11, 13, 48 |
| `other_christian` | Other Christian | 4 Methodist, 8 Seventh Day Adventist, 14 Quaker, 15 Christian, 16 United Reformed Church, 17 Congregational Church, 18 Free Church, 22 Greek Orthodox, 26 Moravian, 28 Inter- / non- denominational, 37 Christian/Evangelical, 38 Christian Science, 39 Christian/Methodist, 40 Christian/non-denominational, 44 Plymouth Brethren Christian Church, 45 Protestant, 46 Protestant/Evangelical, 47 Reformed Baptist, and the joint codes 9, 10, 12, 19, 30, 33, 41 |
| `jewish` | Jewish | 5 Jewish, 36 Charadi Jewish, 43 Orthodox Jewish |
| `muslim` | Muslim | 7 Muslim, 42 Islam, 49 Sunni Deobandi |
| `other_faith` | Other faith | 21 Sikh, 24 Buddhist, 25 Hindu, 29 Multi-faith |
The joint codes: 9 CofE/Methodist, 10 Methodist/CofE, 11 CofE/RC, 12 CofE/URC,
13 RC/CofE, 19 CofE/Free Church, 30 CofE/Methodist/URC/Baptist,
33 Anglican/Evangelical, 41 CofE/Evangelical, 48 RC/Anglican.
28 "Inter- / non- denominational" is filed as Other Christian: GIAS uses it for
Christian schools that are not tied to one church.
## Architecture
Grouping lives in the backend, not dbt. The API already translates GIAS codes
to names when it loads the marts (`backend/data_loader.py:
translate_gias_code_columns`), and every filter is applied to that DataFrame.
Grouping there needs no mart change, so there is no Airflow run between merge
and staging showing it.
### `backend/school_groups.py` (new)
- `TYPE_GROUPS`: ordered `(key, label, frozenset[int])` per type group.
- `UNOFFERED_TYPE_CODES`: the not-offered codes, so that "every code is
accounted for" is testable.
- `FAITH_GROUPS`: ordered `(key, label, frozenset[int])` per faith group.
- `type_group_for(name) -> str | None` and
`faith_groups_for(name) -> tuple[str, ...]` (a missing or blank name gives
`("none",)`).
No dependence on the generated GIAS dictionaries beyond their codes, so the
backend/pipeline dictionary parity test is untouched.
### Grouping by name, at filter time
The groups are defined over codes but looked up by the translated name
(`type_group_for(name)`, `faith_groups_for(name)`), when `/api/schools` and
`/api/filters` filter. The DataFrame those endpoints read carries names only:
`translate_gias_code_columns` replaces the codes at load, the legacy-name
mart fallback never had codes, and the API test fixtures are written in
names. The names come from the same dictionaries, so the lookup is exact.
`data_loader.py` is unchanged.
### `/api/schools`
Both filters work on the name columns at request time. `_names_in_group`
collects the distinct `school_type` or `religious_denomination` names the
group accepts, once per distinct name rather than per row, and the rows are
kept with `isin`. No group column is stored.
- `school_type`: if the value is a type group key (any case), keep the rows
whose `school_type` name `type_group_for` puts in that group. Otherwise
filter on the raw label exactly as today, so an old
`?school_type=Community+school` link keeps working.
- `faith` (new, optional, `max_length=40`, sanitised like the others): keep the
rows whose `religious_denomination` name `faith_groups_for` puts in that
faith (any case); for `none`, rows with a missing name too. An unknown key
returns no schools rather than being ignored, so a typo does not silently
show everything.
### `/api/filters`
Two new keys:
- `school_type_groups`: `[{value, label}]` in `TYPE_GROUPS` order, only groups
with at least one school.
- `faiths`: `[{value, label}]` in `FAITH_GROUPS` order, same rule.
`school_types` stays as it is (the raw list), so nothing that reads it breaks.
### Frontend
- `lib/types.ts`: `Filters` gains optional `school_type_groups` and `faiths`
(`{ value: string; label: string }[]`). Optional, so the empty fallbacks in
`app/(frontend)/page.tsx` and `rankings/page.tsx` stay valid.
- `app/(frontend)/page.tsx`: reads `faith` from the search params, counts it
in `hasSearchParams`, and passes it to `fetchSchools`. `SchoolSearchParams`
in `lib/types.ts` gains `faith`. HomeView's load-more already forwards every
URL param. HomeView's `isSearchActive` is left as it is: like phase and
gender, faith narrows a search rather than starting one.
- `components/FilterBar.tsx`:
- The School type select's options become `filters.school_type_groups`;
"Any school type" stays first. With no groups (the API failed), the select
is left out, as Gender and Admissions already are.
- A new **Faith** select (`aria-label="Faith"`, "Any faith or none" first)
from `filters.faiths`. On desktop it goes in the More filters panel, after
Local authority. In the phone sheet it comes after School type.
- `faith` joins `FILTER_KEYS` (chip and Filters count) and the More filters
count, and `faith=` joins `filters_active` in the `search_submitted`
analytics event.
- Chip labels come from the option lists: "Special school (SEND)",
"Roman Catholic". An old raw-label `school_type` shows its raw label.
- `lib/utils.ts: isSpecialSchool` is unchanged. It reads a school's raw
`school_type`, which still arrives.
## Testing
**Backend (pytest):**
- Every `SCHOOL_TYPE` code is in exactly one type group, in
`UNOFFERED_TYPE_CODES`, or in `non_england_school_type_codes`. A new DfE
code fails this test instead of silently vanishing from the filter.
- Every `RELIGIOUS_CHARACTER` code maps to at least one faith group.
- The joint codes map to each faith they name (11 and 48 → CofE and RC;
9 → CofE and Other Christian) and 20 → CofE only.
- `/api/schools?school_type=special` returns only special-group schools;
`?school_type=Community+school` still filters by label.
- `/api/schools?faith=roman_catholic` returns only matching schools, a joint
school included; `?faith=nonsense` returns none.
- `/api/filters` lists the groups in order and leaves out an empty one.
- Run via uv, as the backend tests always are.
**Frontend (Jest):**
- School type offers the six group labels, not raw types.
- Faith offers its options, in the panel and in the sheet.
- A faith filter shows as a chip, counts on Filters and More filters, and
clears with Clear all.
- No groups or faiths in `filters` → the select is left out.
**E2E (`e2e/tests/journeys.spec.ts`):**
- A journey that picks "Special school (SEND)" and "Roman Catholic" from the
selects, then checks through `/api/schools` with the same params that every
returned school's `school_type` is a special type and its
`religious_denomination` names Catholic. Data-invariant: it asserts the
property, not a count.
- It can only pass after merge; the staging E2E gate runs post-merge.
## Rollout
One PR; backend and frontend deploy together on merge. The frontend tolerates
an API without the new keys (the selects are left out), so the order the
containers update in does not matter.
@@ -0,0 +1,257 @@
# Ofsted Current Status — Design
**Date:** 2026-10-05
**Status:** approved design, not yet implemented
**Scope:** `pipeline/transform` Ofsted models, `backend/data_loader.py`, list and
detail API Ofsted fields, search and map badges, school-page Ofsted section,
compare Ofsted rows, Typesense rating, sitemap
**Fixes:** audit findings C1, M1 and (as a side effect) M2 and part of H3,
from the 3 Oct 2026 accuracy audit
## Goal
Never show an Ofsted grade under a date it was not awarded or confirmed on, and
always date "Inspected" by the school's latest visit.
## The problem
Ofsted's management information gives each school at most three inspections:
the latest graded inspection (date G, an overall grade or "Not judged", area
grades), the latest ungraded inspection (date U, an outcome sentence) and the
latest report card (date RC).
The site derives a grade and a date from these with two independent rules:
- grade = the graded inspection's overall grade, or else the grade parsed from
the ungraded outcome ("School remains Good" → 2);
- date = G, or else U.
The two rules can pick different inspections. Every inspection from
September 2024 to November 2025 was graded with "Not judged" overall, so the
grade falls back to an older ungraded visit while the date stays the new one.
- **C1.** Rabbsfarm Primary School (102408) shows "Good · 2025". Ofsted's
17 June 2025 inspection gave no overall grade and rated quality of education,
behaviour and leadership Requires Improvement. The "Good" comes from an
ungraded visit on 6 February 2020. Site-wide, 932 badges pair a
carried-forward grade with a newer inspection's year, and 147 of them say Good
or Outstanding while that inspection rated an area Requires Improvement or
Inadequate. Acre Wood Academy (151783) reads "Good · 2024" though the
October 2024 inspection rated all four areas Inadequate.
- **M1.** When a newer ungraded visit exists, the page shows the older graded
date. Washwood Heath Academy (139888) reads "Inspected 3 Mar 2020 · 4+ years
ago"; Ofsted visited on 21 May 2025. 667 schools.
Ofsted's own provider page for Rabbsfarm leads with the 2025 area judgements and
"From September 2024, Ofsted no longer makes an overall effectiveness
judgement". It shows no overall grade.
The rule is also implemented four times: `dim_school.ofsted_grade` (feeds
Typesense), the list SQL in `data_loader.py`, `_ofsted_block`, and two separate
"latest row" picks over `marts.fact_ofsted_inspection` by `inspection_date`,
which tie arbitrarily on duplicate monthly rows.
## Non-goals
- Predecessor inspections (audit M10): a grade Ofsted attributes to a previous
URN stays unlabelled.
- Post-16 and ISI-inspected schools (H4) and the "Not yet inspected" label.
- The compare page's broken Ofsted link (M4).
- Report-card display, which is unchanged.
## The rule
Computed once per URN in `int_ofsted_latest`.
**Latest visit:** the newest of RC, G and U.
- `latest_visit_date`
- `latest_visit_kind`: `report_card`, `graded` or `ungraded`
- `latest_visit_outcome`: the ungraded outcome text when the kind is `ungraded`,
otherwise null
**Current grade:** the overall grade still in force, if any.
| Situation | `current_grade` | `current_grade_date` | `current_grade_basis` |
|---|---|---|---|
| A report card exists | null | null | null |
| Latest is graded, overall 1–4 | that grade | G | `graded` |
| Latest is graded, "Not judged" | null | null | null |
| Latest is ungraded, outcome "School remains X…" (any qualifier) | X | U | `confirmed` |
| Latest is ungraded, any other outcome ("Standards maintained", "Improved significantly", "Some aspects not as strong") | the graded inspection's overall grade if it is 1–4, else null | G when a grade is kept | `graded` when a grade is kept |
| No inspection | null | null | null |
A report card replaced overall grades, so no legacy grade stays in force beside
one. The latest visit is read from the dates, not assumed: report cards began in
November 2025, after the last legacy graded and ungraded inspections, and in
Ofsted's 31 Aug 2026 data no school has a legacy visit newer than its report
card, but the rule does not depend on that. Ties between G, U and RC on the same
date resolve in the order report card, graded, ungraded.
Invariants: `current_grade` is null or 1–4; `current_grade_date <=
latest_visit_date`; `current_grade_basis` is null exactly when `current_grade`
is null.
### Expected results (Ofsted MI as at 31 Aug 2026)
| URN | School | Ofsted data | Latest visit | Current grade |
|---|---|---|---|---|
| 102408 | Rabbsfarm Primary School | G 17 Jun 2025 Not judged; U 6 Feb 2020 remains Good | graded, 17 Jun 2025 | none |
| 151783 | Acre Wood Academy | G 1 Oct 2024 Not judged; U 14 Mar 2023 remains Good (Concerns) | graded, 1 Oct 2024 | none |
| 139888 | Washwood Heath Academy | G 3 Mar 2020 Good; U 21 May 2025 Standards maintained | ungraded, 21 May 2025, "Standards maintained" | Good, 3 Mar 2020, graded |
| 104762 | Robins Lane Community Primary | G 7 Jan 2020 Good; U 18 Jul 2024 School remains Good | ungraded, 18 Jul 2024 | Good, 18 Jul 2024, confirmed |
| 100094 | Royal Free Hospital Children's School | G 9 Oct 2019 Outstanding; U 5 Feb 2025 Some aspects not as strong | ungraded, 5 Feb 2025 | Outstanding, 9 Oct 2019, graded |
| 136454 | Oakgrove School | U 13 Nov 2024 Standards maintained only | ungraded, 13 Nov 2024 | none |
| 137086 | Bishop Stopford School | G 1 Apr 2025 Not judged | graded, 1 Apr 2025 | none |
| 110048 | The Willink School | U 5 Oct 2023 remains Good; RC 6 May 2026 | report card, 6 May 2026 | none (report card shown) |
| 149612 | St Michael's Catholic School | RC 10 Feb 2026 only | report card, 10 Feb 2026 | none (report card shown) |
## What each page shows
**Search and map badge** (`buildOfstedListBadge`), first match wins:
1. Report card: "Report Card · *RC year*" (unchanged)
2. Current grade: "*Grade* · *year of `current_grade_date`*"
3. Latest visit: "Inspected · *year of `latest_visit_date`*"
4. "Not yet inspected" (unchanged)
**School page** (`OfstedSection`, both phases):
- Title date: "Inspected *latest visit date*".
- Headline: the report card; or the current grade with a source line
("Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection,
14 March 2023"); or "No overall grade" with "Ofsted stopped giving overall
grades in September 2024".
- "Latest visit" line when the latest visit is not the grade's source, e.g.
"Ungraded inspection, 13 Nov 2024: Standards maintained".
- The area grid shows the graded inspection's judgements through
`ofstedLegacyAreas()`, dated by that inspection when it is not the latest
visit. The primary and secondary no-grade branches merge into one; the
secondary branch's four hard-coded areas (audit M2) go with it.
**Compare:** `ofstedDisplay` returns `report_card`, `graded`, `confirmed`,
`no_overall_grade` or `none`. The "Latest Ofsted inspection", "Result" and
"Inspected" rows use the same fields as the school page.
## Delivery
Two pull requests. The mart columns exist before anything reads them, so
neither needs compatibility code.
### PR 1: pipeline (additive)
- `stg_ofsted_inspections`: keep `graded_inspection_date`,
`ungraded_inspection_date` and `rc_inspection_date` as separate typed
columns, with the report-card date's existing guard. Keep `inspection_date`
(graded, else ungraded) for the current backend. Keep a row when any of the
three dates is present, so report-card-only schools are no longer dropped
(part of H3: 123 schools).
- `int_ofsted_latest`: pick one row per URN by `latest_visit_date` descending,
then `rc_inspection_date`, `ungraded_inspection_date` and
`graded_inspection_date` descending (nulls last). A duplicate monthly row that
carries a newer report card therefore always wins. Add the five status
columns.
- New mart `marts.fact_ofsted_latest`: one row per URN from `int_ofsted_latest`
with every column the pages need (status, area grades, report-card grades,
ungraded outcome, report URL). It does not join `dim_school`, so only the
monthly Ofsted DAG builds it.
- `dim_school` is not changed in PR 1: the daily DAG does not rebuild
`int_ofsted_latest`, and reading a column that the monthly DAG has not yet
built would fail the daily run.
- Visible effect: report-card-only schools gain their report card, because the
backend's existing reads of `fact_ofsted_inspection` now see their rows.
Nothing else changes.
### PR 2: backend and UI (after the Ofsted DAG has run on PR 1)
- `data_loader.py`: the list query and the batch query read
`marts.fact_ofsted_latest` instead of picking the latest row of
`fact_ofsted_inspection`. `_ofsted_block` reads the status columns and loses
its fallback to `ungraded_grade`.
- List rows: `ofsted_grade` becomes `current_grade`; `ofsted_date` becomes
`latest_visit_date`; new `ofsted_grade_date`. `ofsted_rc_date` stays.
- `ofsted` block: `overall_effectiveness` and `inspection_date` are the graded
inspection's own result and date (they label the area grid); new
`current_grade` `{grade, date, basis}` (or null) and `latest_visit`
`{date, kind, outcome}`; `grade_source` is removed. Report-card fields are
unchanged.
- `dim_school.ofsted_grade` becomes `current_grade` (Typesense's rating follows
at the next sync); `ofsted_date` becomes `latest_visit_date`.
- Sitemap: `lastmod` from `latest_visit_date`; `_PUBLISHABLE_FIELDS` also counts
a latest visit, so schools that lose a carried grade keep their sitemap entry.
- Front end: `lib/types.ts`, `buildOfstedListBadge`, `OfstedSection`,
`PrimarySchoolSections`, `SecondarySchoolSections`, `compareLogic.ofstedDisplay`,
`CompareAtAGlance`, `CompareOfsted`. Place-page counts need no change.
- Delete `buildOfstedHeroChip` and `buildSchoolSummary` in their own commit:
nothing renders them and they encode the old rule.
## Testing
**PR 1**
- dbt unit tests on `int_ofsted_latest`, one per table row above plus
"report-card only" and "duplicate rows, newer report card wins".
- Schema tests on `fact_ofsted_latest`: unique, not-null `urn`; accepted values
for `latest_visit_kind` and `current_grade_basis`; `current_grade` null or
1–4; `current_grade_date <= latest_visit_date`.
- Run locally against a throwaway Postgres from `pgserver` (no Docker here). If
that fails, they still run in the Ofsted DAG's `dbt build`, which fails on any
broken case.
- `pipeline/tests/test_dag_selectors.py` (PR #181) keeps passing.
**PR 2**
- pytest: contract test for the list and `ofsted` fields (style of
`test_school_page_flag_fields.py`); `_ofsted_block` from a
`fact_ofsted_latest` row; sitemap publishability.
- Jest: a badge case per table row; `OfstedSection` for graded, confirmed, no
grade, the latest-visit line and a sixth-form area; `ofstedDisplay` kinds.
Rewrite tests that assert `carried_forward`.
- E2E (same PR): Rabbsfarm's search row says "Inspected · 2025" and its page
says "No overall grade" with quality of education Requires Improvement; a
confirmed school says "Confirmed at an ungraded inspection". The existing
report-card journey stays.
## Rollout and verification
1. Merge PR 1. On staging, run `school_data_monthly_ofsted`, then:
```sql
-- one row per school
select count(*) = count(distinct urn) from marts.fact_ofsted_latest;
-- the examples above
select urn, latest_visit_date, latest_visit_kind, latest_visit_outcome,
current_grade, current_grade_date, current_grade_basis
from marts.fact_ofsted_latest
where urn in (102408, 151783, 139888, 104762, 100094, 136454, 137086, 110048, 149612);
-- C1: a grade in force although the latest inspection gave none (expect 0)
select count(*) from marts.fact_ofsted_latest
where current_grade is not null and latest_visit_kind = 'graded'
and overall_effectiveness is null;
-- invariant (expect 0)
select count(*) from marts.fact_ofsted_latest where current_grade_date > latest_visit_date;
```
Check through the API that St Michael's Catholic School (149612) shows its
report card.
2. Promote PR 1 to production; run the Ofsted DAG there; repeat the checks.
3. Merge PR 2. Let the daily DAG run (or trigger it) so `dim_school` and
Typesense pick up the change; run the E2E journeys; re-run the audit's C1,
M1 and M2 checks against staging: expect 0.
4. Promote PR 2; repeat the audit checks on production.
## Expected visible change
About 932 schools change from a grade badge dated by a no-grade inspection
("Good · 2025") to "Inspected · 2025". Counts of Good and Outstanding schools
on place pages fall by the same schools, and Typesense's rating changes for
them. Dates beside a grade can move earlier (to the inspection that awarded
it); "Inspected" dates move later (to the latest visit).
## Risks
- Ofsted changes its MI columns most months. Unknown grade text parses to null
(`safe_numeric`), which degrades to "Inspected · year", never to a wrong grade.
- PR 2 depends on the Ofsted DAG having run on the target environment after PR
1. If PR 2 is promoted first, the backend reads a missing table: promote in
order.
+390 -16
View File
@@ -53,7 +53,7 @@ async function settledScrollLeft(scroller: Locator): Promise<number> {
* whatever primaries the environment holds.
*/
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=primary&per_page=50');
const res = await page.request.get('/api/schools?search=primary&page_size=50');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
@@ -66,7 +66,7 @@ async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
}
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=school&per_page=100');
const res = await page.request.get('/api/schools?search=school&page_size=100');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
@@ -270,6 +270,9 @@ test('searching by name returns school results', async ({ page }) => {
test('searching by postcode returns nearby schools', async ({ page }) => {
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
// A new postcode search starts at half a mile.
await expect(page).toHaveURL(/[?&]radius=0\.5(&|$)/);
await expect(page.getByRole('combobox', { name: 'Distance' })).toHaveValue('0.5');
});
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
@@ -283,6 +286,153 @@ test('the phase filter switches straight from secondary to primary', async ({ pa
await expect(phase).toHaveValue('primary');
});
test('the phase filter lists phases in the order a child meets them', async ({ page }) => {
// They were alphabetical, so "16 plus" and "All-through" came before Nursery.
const childPath = ['Nursery', 'Primary', 'Middle deemed primary',
'Middle deemed secondary', 'Secondary', '16 plus', 'All-through'];
await page.goto('/?search=school');
const phase = page.getByRole('combobox', { name: 'Phase' });
await expect(phase).toBeVisible({ timeout: 15_000 });
const offered = (await phase.locator('option').allTextContents())
.filter((o) => childPath.includes(o));
expect(offered, 'no phase on offer').toContain('Primary');
expect(offered).toEqual(childPath.filter((p) => offered.includes(p)));
});
test('school type and gender switch straight to another value', async ({ page }) => {
// Their options came from the result set, which the filter had already
// narrowed, so with one value chosen it was the only one on offer.
await page.goto('/?search=school&gender=girls');
// An applied gender filter opens More filters by itself.
const gender = page.getByRole('combobox', { name: 'Gender', exact: true });
await expect(gender).toHaveValue('girls', { timeout: 15_000 });
await gender.selectOption('boys');
await expect(page).toHaveURL(/[?&]gender=boys(&|$)/);
// Whatever types the data holds, choosing one leaves the same list on offer.
const type = page.getByRole('combobox', { name: 'School type', exact: true });
const offered = await type.locator('option').allTextContents();
expect(offered.length, 'no school type to choose').toBeGreaterThan(1);
await type.selectOption(offered[1]);
await expect(page).toHaveURL(/[?&]school_type=/);
await expect.poll(() => type.locator('option').allTextContents()).toEqual(offered);
});
/*
* School type offers six groups a parent recognises, and Faith sits beside it.
* Data-invariant: asserts what every returned school is, never how many.
*/
// Clear all once dropped the search with the filters, which sent desktop back
// to the home page instead of to the unfiltered results.
test('Clear all removes the filters and keeps the search', async ({ page }) => {
await page.goto('/?search=school&phase=primary');
const row = page.getByRole('group', { name: 'Filters', exact: true });
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
await row.getByRole('button', { name: 'Clear all', exact: true }).click();
await expect(page).not.toHaveURL(/[?&]phase=/);
await expect(page).toHaveURL(/[?&]search=school(&|$)/);
await expect(schoolLinks(page).first()).toBeVisible();
await expect(row.getByRole('button', { name: 'Clear all', exact: true })).toHaveCount(0);
});
test('school type groups and the faith filter narrow to what they name', async ({ page }) => {
await page.goto('/?search=school');
const type = page.getByRole('combobox', { name: 'School type', exact: true });
await expect(type).toBeVisible({ timeout: 15_000 });
await type.selectOption({ label: 'Special school (SEND)' });
await expect(page).toHaveURL(/[?&]school_type=special(&|$)/);
await page.getByRole('button', { name: /^More filters/ }).click();
await page.getByRole('combobox', { name: 'Faith', exact: true }).selectOption({ label: 'Roman Catholic' });
await expect(page).toHaveURL(/[?&]faith=roman_catholic(&|$)/);
const res = await page.request.get(
'/api/schools?search=school&school_type=special&faith=roman_catholic&page_size=100');
expect(res.ok()).toBeTruthy();
for (const s of (await res.json()).schools as { school_type: string; religious_denomination: string }[]) {
expect(s.school_type).toMatch(/special/i);
expect(s.religious_denomination).toMatch(/catholic/i);
}
});
/*
* Search rows printed tags the register does not hold. Every non-selective
* secondary was "Selective" ("non-selective" contains "selective"), and a
* school with no religious character got "Faith priority" or a bare "None"
* chip, because only "Does not apply" was excluded. Data-invariant: each test
* picks its school from the API and reads only that school's row.
*/
async function rowTags(page: Page, school: { urn: number; school_name: string }) {
await searchByName(page, school.school_name);
const link = page.locator(`a[href^="/school/${school.urn}-"]`).first();
await expect(link).toBeVisible({ timeout: 15_000 });
return link.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
.locator('[class*="__line2"]');
}
test('a non-selective secondary is not tagged Selective in search', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&admissions_policy=non-selective&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no non-selective secondary in this environment');
expect(school.admissions_policy, 'the admissions filter was ignored').toBe('Non-selective');
const tags = await rowTags(page, school);
await expect(tags).toBeVisible();
await expect(tags.getByText('Selective', { exact: true })).toHaveCount(0);
});
test('a school with no religious character carries no faith tag in search', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&faith=none&page_size=100');
expect(res.ok()).toBeTruthy();
// Not a selective school: the Selective tag would win and hide the bug.
const school = ((await res.json()).schools ?? []).find(
(s: { religious_denomination?: string; admissions_policy?: string }) =>
s.religious_denomination === 'None' && !/selective/i.test(s.admissions_policy ?? ''));
test.skip(!school, 'no school recorded with religious character "None" here');
const tags = await rowTags(page, school);
await expect(tags).toBeVisible();
await expect(tags.getByText('Faith priority', { exact: true })).toHaveCount(0);
await expect(tags.getByText('None', { exact: true })).toHaveCount(0);
});
test('search and the school page agree on how many pupils a secondary has', async ({ page }) => {
// Search showed the GCSE year group as "pupils": Burntwood had 245 in
// search and 1,462 on its page. Both now carry the register's count.
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=20');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { total_pupils?: number | null }) => s.total_pupils != null);
test.skip(!school, 'no secondary with a pupil count in this environment');
const detail = await (await page.request.get(`/api/schools/${school.urn}`)).json();
expect(school.total_pupils).toBe(detail.school_info.total_pupils);
});
test('a secondary search row compares its Attainment 8 with the LA average', async ({ page }) => {
// The comparison vanished unnoticed: the averages were fetched with
// force-cache, so one stored failure hid it in that browser for good.
// Playwright disables the HTTP cache when it intercepts requests, so this
// guards the comparison itself; the unit test pins the cache mode.
const la = await (await page.request.get('/api/la-averages')).json();
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) =>
s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? ''));
test.skip(!school, 'no mainstream secondary with an LA average here');
await searchByName(page, school.school_name);
const link = page.locator(`a[href^="/school/${school.urn}-"]`).first();
await expect(link).toBeVisible({ timeout: 15_000 });
const stats = link.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
.locator('[class*="__line3"]');
await expect(stats.getByText(/vs LA avg/)).toBeVisible();
});
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
// The search page offers every GIAS phase, but the API only knew the grouped
// ones and silently dropped the rest — so "Nursery" returned primaries.
@@ -324,6 +474,37 @@ test('a report-card school shows a Report Card badge in search results, not its
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
});
test('a school whose latest inspection gave no grade is not shown with an older grade', async ({ page }) => {
// Rabbsfarm (102408): Ofsted's 17 June 2025 inspection gave no overall
// grade and rated three areas Requires Improvement. The site used to carry
// a 2020 "remains Good" forward and print "Good · 2025" (audit C1).
const URN = 102408;
const res = await page.request.get(`/api/schools/${URN}`);
expect(res.ok()).toBeTruthy();
const ofsted = (await res.json()).ofsted;
expect(ofsted.current_grade).toBeNull();
expect(ofsted.latest_visit.date).toBe('2025-06-17');
await searchByName(page, 'Rabbsfarm Primary');
const row = page.locator(`a[href*="${URN}"]`).first();
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Inspected · 2025').first()).toBeVisible();
await expect(page.getByText(/Good · 2025/)).toHaveCount(0);
await page.goto(`/school/${URN}`);
const section = page.locator('#ofsted');
await expect(section.getByText('No overall grade')).toBeVisible();
await expect(section.getByText('Requires Improvement').first()).toBeVisible();
});
test('a grade confirmed at an ungraded visit says so and is dated by it', async ({ page }) => {
const URN = 104762; // Robins Lane: graded Good Jan 2020, "School remains Good" 18 July 2024
await page.goto(`/school/${URN}`);
const section = page.locator('#ofsted');
await expect(section).toBeVisible({ timeout: 15_000 });
await expect(section.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeVisible();
});
test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
@@ -360,9 +541,10 @@ test('school detail page shows GIAS identity/contact details and drops the unwir
if (info.telephone) {
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
}
if (info.parliamentary_constituency) {
await expect(page.getByText('Constituency:').first()).toBeVisible();
}
// Constituency and county left the header in the facts-and-flags redesign:
// neither helps a parent decide. The place pages keep both.
await expect(page.getByText('Constituency:')).toHaveCount(0);
await expect(page.getByText('County:')).toHaveCount(0);
});
test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => {
@@ -472,7 +654,7 @@ test('school with no performance data still gets a working detail page', async (
const candidates: number[] = [];
for (const q of ['post 16', 'specialist college', 'sixth form']) {
const resp = await page.request.get(
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
`/api/schools?search=${encodeURIComponent(q)}&page_size=20`
);
if (!resp.ok()) continue;
const body = await resp.json();
@@ -538,6 +720,13 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
await expect(openFs).toBeVisible();
});
/*
* A postcode search with a list long enough to scroll. The default half mile
* finds two schools around B1 1BB, so the page ends before the toolbar has
* anything to stay pinned over; the scroll tests ask for a mile.
*/
const LONG_LIST = '/?postcode=B1%201BB&radius=1';
/*
* The results toolbar: search, filters and the List/Map switch pinned under
* the header, so none of them scroll away with the results.
@@ -548,7 +737,7 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
*/
test('the results toolbar stays pinned with its List/Map switch', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await searchByName(page, 'B1 1BB');
await page.goto(LONG_LIST);
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const view = page.getByRole('group', { name: 'Results view' });
@@ -572,6 +761,38 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
.toHaveAttribute('aria-pressed', 'true');
});
/*
* The toolbar's line count is set by the screen width, never by the results.
* It once wrapped wherever it ran out of room, and the List/Map switch only
* appears when there are results — so the same search took two lines with
* results and one without.
*/
test('the results toolbar keeps its line count whether or not there are results', async ({ page }) => {
const withResults = LONG_LIST;
// No school type matches this, so the same search returns nothing.
const without = `${withResults}&school_type=no-such-type`;
const lines = async (url: string) => {
await page.goto(url);
// By label: the input is a combobox when autosuggest is on.
const input = page.getByLabel('School name or postcode', { exact: true });
const filters = page.getByRole('group', { name: 'Filters' });
await expect(filters).toBeVisible({ timeout: 15_000 });
const a = (await input.boundingBox())!;
const b = (await filters.boundingBox())!;
return b.y >= a.y + a.height ? 2 : 1;
};
const view = page.getByRole('group', { name: 'Results view' });
for (const [width, expected] of [[1400, 1], [1100, 2]] as const) {
await page.setViewportSize({ width, height: 800 });
expect(await lines(withResults), `${width}px with results`).toBe(expected);
await expect(view).toBeVisible();
expect(await lines(without), `${width}px without results`).toBe(expected);
await expect(view).toHaveCount(0);
}
});
/*
* Desktop opens a postcode search on the map (mockup B): the list in a pane on
* the left, the map filling the rest of the screen, and a card on the map for
@@ -608,7 +829,15 @@ test('a desktop postcode search opens on the map with the list beside it', async
const name = (await card.locator('a').first().innerText()).trim();
await card.click({ position: { x: 6, y: 6 } });
await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
// Leaflet crossfades popups for 200ms, so two exist until the old one goes.
await expect(page.locator('.sc-popup')).toHaveCount(1);
await expect(page.locator('.sc-popup')).toContainText(name);
// The card's View link is drawn like the list's, not in Leaflet's link blue.
// Polled, because .btn transitions its colour.
const colour = (l: Locator) => l.evaluate(el => getComputedStyle(el).color);
const listViewColour = await colour(card.getByRole('link', { name: 'View', exact: true }));
await expect.poll(() => colour(page.locator('.sc-popup').getByRole('link', { name: 'View', exact: true })))
.toBe(listViewColour);
// And from the keyboard: each row has a "Show … on the map" button that
// appears on focus.
@@ -619,6 +848,7 @@ test('a desktop postcode search opens on the map with the list beside it', async
await expect(show).toBeVisible();
await page.keyboard.press('Enter');
await expect(show).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.sc-popup')).toHaveCount(1);
await expect(page.locator('.sc-popup')).toContainText(secondName);
});
@@ -626,7 +856,7 @@ test('a desktop postcode search opens on the map with the list beside it', async
for (const width of [360, 390, 402, 430]) {
test(`a phone opens on the map, with the list a tap away, at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await searchByName(page, 'B1 1BB');
await page.goto(LONG_LIST);
// On the map, with the floating button offering the list, clear of the
// bottom tab bar.
@@ -661,12 +891,13 @@ for (const width of [360, 390, 402, 430]) {
// scrollWidth alone cannot see this page's overflow: .main clips on x, so
// the search summary ran 40px off an iPhone 17 screen with scrollWidth
// still equal to the viewport. Measure the toolbar's own edges instead;
// the controls row scrolls by design, so only its box is held to the edge.
// the applied-filter chips scroll by design, so only their line's box is
// held to the edge.
const offscreen = await page.evaluate(() => {
const toolbar = document.querySelector('[class*="resultsToolbar"]');
return [...(toolbar?.querySelectorAll('*') ?? [])]
.filter((el) => (el as HTMLElement).offsetParent
&& !el.parentElement?.closest('[class*="controlsRow"]'))
&& !el.parentElement?.closest('[class*="chipsLine"]'))
.map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40),
right: Math.round(el.getBoundingClientRect().right) }))
.filter((o) => o.right > window.innerWidth);
@@ -698,6 +929,67 @@ for (const width of [360, 390, 402, 430]) {
});
}
/*
* Phones filter through one Filters button and a sheet with every filter in
* it. The row it replaced scrolled sideways with "More filters" first, so
* phase showed only in part, school type not at all, and the panel "More
* filters" opened held neither.
*/
test('a phone filters through one sheet, and sees what it applied as chips', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(LONG_LIST);
const trigger = page.getByRole('button', { name: 'Filters', exact: true });
await expect(trigger).toBeInViewport({ timeout: 15_000 });
// The desktop row is gone at this width: no phase select until the sheet.
await expect(page.getByRole('combobox', { name: 'Phase', exact: true })).toHaveCount(0);
await trigger.click();
const sheet = page.getByRole('dialog', { name: 'Filters' });
await expect(sheet).toBeVisible();
await expect(sheet.getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
for (const name of ['Phase', 'School type', 'Local authority']) {
await expect(sheet.getByRole('combobox', { name, exact: true })).toBeVisible();
}
// A change applies at once, and the sheet stays open for the next one.
await sheet.getByRole('combobox', { name: 'Phase', exact: true }).selectOption('primary');
await page.waitForURL(/[?&]phase=primary(&|$)/);
await expect(sheet).toBeVisible();
const show = sheet.getByRole('button', { name: /^(Show [\d,]+ schools?|No schools match)$/ });
await expect(show).toBeVisible();
// MOBILE.md: the sheet clears the bottom tab bar, and its targets are 44px.
// It slides up over 0.3s, so poll for where it comes to rest.
await expect.poll(async () => {
const box = (await show.boundingBox())!;
return Math.round(box.y + box.height);
}).toBeLessThanOrEqual(844);
const small = await sheet.evaluate((el) =>
[...el.querySelectorAll('button, select, label:has(input)')]
.filter((n): n is HTMLElement => !!(n as HTMLElement).offsetParent)
.map((n) => ({ t: n.innerText?.trim().slice(0, 24) || n.getAttribute('aria-label'),
w: n.getBoundingClientRect().width, h: n.getBoundingClientRect().height }))
.filter((o) => o.w < 44 || o.h < 44));
expect(small).toEqual([]);
await show.click();
await expect(sheet).toBeHidden();
// What was applied shows under the search, counted on the button, and comes
// off with a tap, keeping the search.
await expect(page.getByRole('button', { name: 'Filters, 1 applied' })).toBeInViewport();
const chip = page.getByRole('group', { name: 'Applied filters' })
.getByRole('button', { name: 'Remove filter: Primary' });
await expect(chip).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
.toBe(0);
await chip.click();
await expect(page).not.toHaveURL(/[?&]phase=/);
await expect(page).toHaveURL(/[?&]postcode=B1(%20|\+)1BB/);
await expect(page.getByRole('group', { name: 'Applied filters' })).toHaveCount(0);
await expect(trigger).toBeInViewport();
});
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
// Two same-phase (pure primary) schools so both stay on one tab.
const [urn0, urn1] = await twoPrimaryUrns(page);
@@ -941,7 +1233,7 @@ test('compare metric-help popover stays within the mobile viewport', async ({ pa
test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => {
// Find a school with at least two years carrying an offer rate — the toggle
// only appears then. Data-invariant: uses whatever the environment holds.
const res = await page.request.get('/api/schools?search=primary&per_page=50');
const res = await page.request.get('/api/schools?search=primary&page_size=50');
expect(res.ok()).toBeTruthy();
const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn);
@@ -1901,7 +2193,7 @@ test('English schools with Welsh postcodes are kept', async ({ page }) => {
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
// Paired on purpose: the Welsh assertion alone would also pass if the whole
// site were down, which is the failure this test most needs to distinguish.
const english = await page.request.get('/api/schools?search=primary&per_page=1');
const english = await page.request.get('/api/schools?search=primary&page_size=1');
expect(english.ok()).toBeTruthy();
const [first] = (await english.json()).schools ?? [];
expect(first, 'no English school available to compare against').toBeTruthy();
@@ -2011,7 +2303,7 @@ test('a filtered homepage still canonicalises to the bare root', async ({ page }
});
test('a school page canonicalises to its own slug on the www host', async ({ page }) => {
const res = await page.request.get('/api/schools?search=primary&per_page=1');
const res = await page.request.get('/api/schools?search=primary&page_size=1');
expect(res.ok()).toBeTruthy();
const [first] = (await res.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
@@ -2086,7 +2378,7 @@ function blocksEverything(robots: string, agent: string): boolean {
}
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
const list = await page.request.get('/api/schools?search=primary&per_page=1');
const list = await page.request.get('/api/schools?search=primary&page_size=1');
const [first] = (await list.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
@@ -2697,7 +2989,7 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
async function secondaryWithDestinations(page: Page): Promise<{
urn: string; destinations: any;
}> {
const res = await page.request.get('/api/schools?search=school&per_page=100');
const res = await page.request.get('/api/schools?search=school&page_size=100');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
@@ -2793,7 +3085,7 @@ test('switching to disadvantaged pupils never reveals a withheld figure', async
});
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 res = await page.request.get('/api/schools?search=school&page_size=100');
const body = await res.json();
const noSixthForm = (body.schools ?? [])
.filter((s: { phase?: string; has_sixth_form?: boolean }) =>
@@ -3104,3 +3396,85 @@ for (const width of [360, 390, 430]) {
expect(failing).toEqual([]);
});
}
/*
* The header's facts row read GIAS text as booleans: "Not applicable" put a
* "Nursery" chip on secondaries aged 11–18, and "Girls" became "Girls's
* school". Data-invariant: the chip follows whatever the API says.
*/
test('a girls\' secondary header names it properly and shows Nursery only when it has one', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&phase=secondary&gender=girls&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no girls\' secondary in this environment');
expect(school.gender, 'the gender filter was ignored').toBe('Girls');
const detail = await (await page.request.get(`/api/schools/${school.urn}`)).json();
// Without the field, the Nursery assertion below would pass vacuously.
expect(detail.school_info).toHaveProperty('nursery_provision');
await page.goto(`/school/${school.urn}`);
const header = page.locator('header', { has: page.getByRole('heading', { level: 1 }) });
await expect(header.getByText("Girls' school", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(header.getByText(/'s school/)).toHaveCount(0);
await expect(header.getByText('Nursery class', { exact: true }))
.toHaveCount(detail.school_info.nursery_provision === 'Has Nursery Classes' ? 1 : 0);
});
/*
* The header states facts in fixed slots and flags only what applies: a
* selective school is flagged Selective, and its type reads in the search
* filter's words, not as a GIAS establishment type. The search row carries
* the same flag. Data-invariant: the school comes from the API.
*/
const TYPE_GROUP_LABELS: Record<string, string> = {
state: 'State school', independent: 'Independent school', special: 'Special school (SEND)',
post16: 'Sixth form or college', alternative: 'Alternative provision',
};
test('a selective school is flagged Selective, on its page and in search', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&admissions_policy=selective&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no selective secondary in this environment');
expect(school.admissions_policy, 'the admissions filter was ignored').toBe('Selective');
expect(school, 'the list must name the type group').toHaveProperty('type_group');
await page.goto(`/school/${school.urn}`);
const header = page.locator('header', { has: page.getByRole('heading', { level: 1 }) });
const flags = header.getByRole('list', { name: 'Admission and provision' });
await expect(flags.getByText('Selective', { exact: true })).toBeVisible({ timeout: 15_000 });
const typeLabel = TYPE_GROUP_LABELS[school.type_group] ?? school.school_type;
await expect(header.getByText(typeLabel, { exact: true })).toBeVisible();
const tags = await rowTags(page, school);
await expect(tags.getByText('Selective', { exact: true })).toBeVisible();
});
/*
* The Admissions section repeated the search rows' old tag logic: "selective"
* matched inside "Non-selective", and "None" counted as a faith, so Burntwood
* read "this school has a faith-based admissions priority (None)". The
* Selective half stayed hidden only because the school page's API did not
* send admissions_policy. Data-invariant: the school comes from the API, and
* must have an Admissions section to read.
*/
test('a non-selective school with no faith makes neither claim in its Admissions section', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&faith=none&admissions_policy=non-selective&page_size=20');
expect(res.ok()).toBeTruthy();
let urn: number | null = null;
for (const s of ((await res.json()).schools ?? []).slice(0, 8)) {
if (s.religious_denomination !== 'None') continue;
const detail = await (await page.request.get(`/api/schools/${s.urn}`)).json();
if (detail.admissions) { urn = s.urn; break; }
}
test.skip(urn == null, 'no non-selective, no-faith secondary with admissions data here');
await page.goto(`/school/${urn}`);
const admissions = page.locator('section#admissions');
await expect(admissions).toBeVisible({ timeout: 15_000 });
await expect(admissions.getByText('Selective:', { exact: true })).toHaveCount(0);
await expect(admissions.getByText('Faith priority:', { exact: true })).toHaveCount(0);
await expect(admissions.getByText(/\(None\)/)).toHaveCount(0);
});
@@ -33,18 +33,26 @@ function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
};
}
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
const schools = [school(1, 'Graded School'), school(2, 'Confirmed School'), school(3, 'Card School')];
const data: Record<string, ComparisonData> = {
'1': {
school_info: schools[0],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
ofsted: ofsted({
overall_effectiveness: 1,
current_grade: { grade: 1, date: '2021-10-07', basis: 'graded' },
latest_visit: { date: '2021-10-07', kind: 'graded', outcome: null },
}),
},
'2': {
school_info: schools[1],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
ofsted: ofsted({
overall_effectiveness: 2,
current_grade: { grade: 2, date: '2023-03-14', basis: 'confirmed' },
latest_visit: { date: '2023-03-14', kind: 'ungraded', outcome: 'School remains Good' },
}),
},
'3': {
school_info: schools[2],
@@ -65,9 +73,9 @@ describe('CompareOfsted', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText('Outstanding')).toBeInTheDocument();
// Carried-forward grade is shown but marked as such
// A grade confirmed at an ungraded visit says so, with that visit's date
expect(screen.getByText('Good')).toBeInTheDocument();
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
expect(screen.getByText('Confirmed at an ungraded inspection, 14 March 2023')).toBeInTheDocument();
// Report card: label present, no overall-grade badge for that school
expect(screen.getByText('Report card')).toBeInTheDocument();
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
@@ -104,7 +112,7 @@ describe('CompareOfsted', () => {
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
grade_source: 'graded',
current_grade: { grade: 2, date: '2021-10-07', basis: 'graded' },
quality_of_education: 1,
early_years_provision: 9,
sixth_form_provision: 2,
@@ -167,4 +175,26 @@ describe('CompareOfsted', () => {
expect(screen.getAllByText('Graded').length).toBe(4);
expect(screen.getAllByText('Card').length).toBe(4);
});
it('dates "Inspected" by the latest visit, not the graded inspection (Washwood Heath)', () => {
const s1 = school(7, 'Washwood Heath Academy');
render(<CompareOfsted schools={[s1]} data={{ '7': { school_info: s1, yearly_data: [], ofsted: ofsted({
inspection_date: '2020-03-03', overall_effectiveness: 2, quality_of_education: 2,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' },
}) } }} />);
expect(screen.getByText(/21 May 2025/)).toBeInTheDocument();
expect(screen.queryByText('4+ years ago')).not.toBeInTheDocument();
expect(screen.getByText('Graded inspection, 3 March 2020')).toBeInTheDocument();
});
it('shows no overall grade after a no-grade inspection (Rabbsfarm)', () => {
const s1 = school(8, 'Rabbsfarm Primary School');
render(<CompareOfsted schools={[s1]} data={{ '8': { school_info: s1, yearly_data: [], ofsted: ofsted({
inspection_date: '2025-06-17', quality_of_education: 3,
current_grade: null, latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null },
}) } }} />);
expect(screen.getAllByText('No overall grade').length).toBeGreaterThan(0);
expect(screen.queryByText('Good')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,129 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import type { ResultFilters } from '@/lib/types';
/*
* A filter's options must not come from the results it is filtering, or
* choosing one leaves only that one on offer: pick "Girls" and "Boys" is gone
* until the filter is cleared. School type groups, gender and admissions offer the
* full lists, as phase already did. Local authority stays scoped to the
* results, so a postcode search offers the councils nearby rather than 153.
*
* Gender, sixth form and admissions show unless the phase chosen is a primary
* one, so what the results happen to contain never decides which filters
* there are.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Merton', 'Wandsworth'],
school_types: ['Academy converter', 'Community school'], years: [],
phases: ['Middle deemed primary', 'Nursery', 'Primary', 'Secondary', 'All-through'],
genders: ['Boys', 'Girls', 'Mixed'],
admissions_policies: ['Non-selective', 'Selective'],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
],
};
// What the results came back with once narrowed by the chosen filters.
const narrowed: ResultFilters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'],
phases: ['Secondary'], genders: ['Girls'], admissions_policies: ['Non-selective'],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
const optionsOf = (sheet: HTMLElement, name: string) =>
within(within(sheet).getByRole('combobox', { name }))
.getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
});
describe('filter options', () => {
it('offer every school type group, gender and admissions policy, whatever the results hold', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=council&gender=girls');
render(<FilterBar filters={filters} resultFilters={narrowed} />);
const sheet = openSheet();
expect(optionsOf(sheet, 'School type')).toEqual(['Any school type', 'State school: academy or free school', 'State school: council-run']);
expect(optionsOf(sheet, 'Gender')).toEqual(['Boys, Girls & Mixed', 'Boys', 'Girls', 'Mixed']);
expect(optionsOf(sheet, 'Admissions')).toEqual(['All admissions types', 'Non-selective', 'Selective']);
});
it('keep local authority to the councils in the results', () => {
render(<FilterBar filters={filters} resultFilters={narrowed} />);
expect(optionsOf(openSheet(), 'Local authority')).toEqual(['All Local Authorities', 'Wandsworth']);
});
it('offer the full school types in the desktop row too', () => {
render(<FilterBar filters={filters} resultFilters={narrowed} />);
const row = screen.getByRole('group', { name: 'Filters' });
expect(within(within(row).getByRole('combobox', { name: 'School type' }))
.getAllByRole('option')).toHaveLength(3);
});
});
describe('the secondary-only filters', () => {
const secondaryOnly = ['Gender', 'Sixth form', 'Admissions'];
const shown = (sheet: HTMLElement) =>
secondaryOnly.filter((name) => within(sheet).queryByRole('combobox', { name }));
it('show with any phase, even when no secondary school is in the results', () => {
const primariesOnly = { ...narrowed, phases: ['Primary'], genders: [], admissions_policies: [] };
render(<FilterBar filters={filters} resultFilters={primariesOnly} />);
expect(shown(openSheet())).toEqual(secondaryOnly);
});
it.each(['primary', 'nursery', 'middle deemed primary', 'Middle-deemed Primary'])(
'hide for the %s phase', (phase) => {
params = new URLSearchParams(`postcode=SW196AR&radius=1&phase=${encodeURIComponent(phase)}`);
render(<FilterBar filters={filters} />);
expect(shown(openSheet())).toEqual([]);
});
it('leave out a filter with no options, rather than show only its "any"', () => {
render(<FilterBar filters={{ ...filters, genders: [], admissions_policies: [] }} />);
expect(shown(openSheet())).toEqual(['Sixth form']);
});
it.each(['secondary', 'all-through', 'middle deemed secondary', '16 plus'])('show for the %s phase', (phase) => {
params = new URLSearchParams(`postcode=SW196AR&radius=1&phase=${phase}`);
render(<FilterBar filters={filters} />);
expect(shown(openSheet())).toEqual(secondaryOnly);
});
it('are cleared by choosing a primary phase, rather than left applied and hidden', () => {
params = new URLSearchParams(
'postcode=SW196AR&radius=1&phase=secondary&gender=girls&has_sixth_form=yes&admissions_policy=selective&school_type=council');
render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
for (const key of ['gender', 'has_sixth_form', 'admissions_policy']) expect(next.get(key)).toBeNull();
expect(next.get('school_type')).toBe('council');
});
it('are kept when the new phase still has them', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=secondary&gender=girls');
render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'Phase' }), { target: { value: 'all-through' } });
expect(pushedParams().get('gender')).toBe('girls');
});
});
@@ -0,0 +1,51 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterBar } from '@/components/FilterBar';
const push = jest.fn();
let searchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => searchParams,
}));
const FILTERS = {
local_authorities: [], school_types: [], years: [], phases: [],
genders: [], admissions_policies: [],
};
beforeEach(() => {
push.mockClear();
searchParams = new URLSearchParams();
});
describe('FilterBar default distance', () => {
it('searches a new postcode within half a mile', async () => {
render(<FilterBar filters={FILTERS} />);
await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i), 'SW19 6AR{Enter}');
await waitFor(() => expect(push).toHaveBeenCalledWith(expect.stringContaining('radius=0.5')));
});
it('shows half a mile when the URL carries a postcode but no radius', () => {
searchParams = new URLSearchParams('postcode=SW196AR');
render(<FilterBar filters={FILTERS} />);
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveValue('0.5');
});
it('keeps a distance the user already chose', async () => {
searchParams = new URLSearchParams('postcode=SW196AR&radius=3');
render(<FilterBar filters={FILTERS} />);
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveValue('3');
});
});
describe('FilterBar distance options', () => {
it('offers a quarter mile without making it the default', () => {
searchParams = new URLSearchParams('postcode=SW196AR');
render(<FilterBar filters={FILTERS} />);
const distance = screen.getByRole('combobox', { name: 'Distance' });
expect([...(distance as HTMLSelectElement).options].map(o => o.value)).toEqual(['0.25', '0.5', '1', '3', '5']);
expect(distance).toHaveValue('0.5');
});
});
@@ -0,0 +1,137 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import { track } from '@/lib/analytics';
/*
* School type offers six groups a parent recognises, not GIAS's 34 types, and
* a Faith filter sits beside it (spec 2026-10-02-school-type-groups-and-faith-
* filter-design.md). Both lists come from /api/filters.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
{ value: 'special', label: 'Special school (SEND)' },
],
faiths: [
{ value: 'none', label: 'No religious character' },
{ value: 'roman_catholic', label: 'Roman Catholic' },
],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
const optionsOf = (scope: HTMLElement, name: string) =>
within(within(scope).getByRole('combobox', { name })).getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
jest.mocked(track).mockClear();
});
describe('School type', () => {
it('offers the groups, not the GIAS types, on desktop and in the sheet', () => {
render(<FilterBar filters={filters} />);
const groups = ['Any school type', 'State school: academy or free school',
'State school: council-run', 'Special school (SEND)'];
expect(optionsOf(screen.getByRole('group', { name: 'Filters' }), 'School type')).toEqual(groups);
expect(optionsOf(openSheet(), 'School type')).toEqual(groups);
});
it('puts the group key in the URL and names the chip by its label', () => {
const view = render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'special' } });
expect(pushedParams().get('school_type')).toBe('special');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=special');
view.rerender(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
});
it('names an old raw-label link\'s chip by that label', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument();
});
it('is left out when the API sends no groups', () => {
render(<FilterBar filters={{ ...filters, school_type_groups: undefined }} />);
expect(screen.queryByRole('combobox', { name: 'School type' })).not.toBeInTheDocument();
});
});
describe('Faith', () => {
it('offers its options in the More filters panel and in the sheet', () => {
render(<FilterBar filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: /More filters/ }));
const faiths = ['Any faith or none', 'No religious character', 'Roman Catholic'];
expect(optionsOf(document.body, 'Faith')).toEqual(faiths);
expect(optionsOf(openSheet(), 'Faith')).toEqual(faiths);
});
it('shows as a chip, counts on both buttons, and clears with Clear all', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&faith=roman_catholic');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
expect(pushedParams().get('faith')).toBeNull();
expect(pushedParams().get('postcode')).toBe('SW196AR');
});
it('goes into the search analytics event', () => {
params = new URLSearchParams('faith=roman_catholic');
render(<FilterBar filters={filters} />);
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
fireEvent.change(input, { target: { value: 'st marys' } });
fireEvent.submit(input.closest('form')!);
expect(track).toHaveBeenCalledWith('search_submitted',
expect.objectContaining({ filters_active: 'faith=roman_catholic', filters_count: 1 }));
});
it('is left out when the API sends no faiths', () => {
render(<FilterBar filters={{ ...filters, faiths: [] }} />);
expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
});
});
describe('a URL value the options do not spell the same way', () => {
const row = () => screen.getByRole('group', { name: 'Filters' });
it('shows an old raw-label type in the select, and lets "Any" clear it', () => {
params = new URLSearchParams('search=school&school_type=Community+school');
render(<FilterBar filters={filters} />);
const type = within(row()).getByRole('combobox', { name: 'School type' });
expect(type).toHaveValue('Community school');
expect(within(type).getByRole('option', { name: 'Community school' })).toBeInTheDocument();
fireEvent.change(type, { target: { value: '' } });
expect(pushedParams().get('school_type')).toBeNull();
});
it('matches a key in another case, in the select and the chip', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Special&faith=Roman_Catholic');
render(<FilterBar filters={filters} />);
expect(within(row()).getByRole('combobox', { name: 'School type' })).toHaveValue('special');
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Faith' })).toHaveValue('roman_catholic');
});
});
@@ -0,0 +1,157 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* Phones filter through one "Filters" button and a bottom sheet holding every
* filter, rather than a sideways-scrolling row whose later chips (phase, type)
* sat off-screen beside a "More filters" panel that did not contain them.
* Which markup shows at which width is CSS and invisible to jsdom; these pin
* the behaviour and the accessible names.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth', 'Merton'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: ['Girls', 'Mixed'], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
});
describe('the phone Filters button', () => {
it('sits beside the folded search summary', () => {
render(<FilterBar filters={filters} />);
const summary = screen.getByRole('button', { name: /Edit search/ });
expect(summary.parentElement).toContainElement(screen.getByRole('button', { name: 'Filters' }));
});
it('counts every applied filter, phase and type included', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=council');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 2 applied' })).toBeInTheDocument();
});
it('is still offered before anything has been searched', () => {
params = new URLSearchParams('local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
});
it('stays out of the hero', () => {
render(<FilterBar filters={filters} isHero />);
expect(screen.queryByRole('button', { name: /^Filters/ })).not.toBeInTheDocument();
});
});
describe('the filter sheet', () => {
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
it('holds every filter in one place', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
expect(within(sheet).getByRole('radiogroup', { name: 'Distance' })).toBeInTheDocument();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('shows the secondary-only filters once they apply', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=secondary');
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Gender', 'Sixth form']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('offers distance only for a postcode search', () => {
params = new URLSearchParams('search=southmead');
render(<FilterBar filters={filters} />);
expect(within(openSheet()).queryByRole('radiogroup', { name: 'Distance' })).not.toBeInTheDocument();
});
it('changes the distance', () => {
render(<FilterBar filters={filters} />);
const distance = within(openSheet()).getByRole('radiogroup', { name: 'Distance' });
expect(within(distance).getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
fireEvent.click(within(distance).getByRole('radio', { name: 'Within 3 miles' }));
expect(pushedParams().get('radius')).toBe('3');
});
it('applies a change straight away and stays open for the next one', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
expect(pushedParams().get('phase')).toBe('primary');
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
});
it('closes on the results button, which gives the count', () => {
render(<FilterBar filters={filters} resultCount={12} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Show 12 schools' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('says so when nothing matches', () => {
render(<FilterBar filters={filters} resultCount={0} />);
expect(within(openSheet()).getByRole('button', { name: 'No schools match' })).toBeInTheDocument();
});
it('clears the filters but keeps the search', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('local_authority')).toBeNull();
expect(next.get('postcode')).toBe('SW196AR');
expect(next.get('radius')).toBe('1');
});
});
describe('the applied-filter chips', () => {
it('appear only when something is applied', () => {
render(<FilterBar filters={filters} />);
expect(screen.queryByRole('group', { name: 'Applied filters' })).not.toBeInTheDocument();
});
it('name each filter by its label and remove it on tap', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&gender=girls&has_sixth_form=no');
render(<FilterBar filters={filters} />);
const chips = screen.getByRole('group', { name: 'Applied filters' });
for (const label of ['Primary', 'Girls', 'Without sixth form']) {
expect(within(chips).getByRole('button', { name: `Remove filter: ${label}` })).toBeInTheDocument();
}
fireEvent.click(within(chips).getByRole('button', { name: 'Remove filter: Primary' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('gender')).toBe('girls');
expect(next.get('postcode')).toBe('SW196AR');
});
it('carry a Clear all that keeps the search', () => {
params = new URLSearchParams('search=southmead&school_type=council');
render(<FilterBar filters={filters} />);
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('school_type')).toBeNull();
expect(next.get('search')).toBe('southmead');
});
});
@@ -0,0 +1,81 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* While a filter change is navigating, the sheet's controls stay enabled: a
* control disabled under the user's focus drops it to <body>, and a keyboard or
* screen-reader user is thrown out of the sheet after every change. A second
* change made before the first lands must build on the first, not on the URL
* useSearchParams still reports.
*/
// Every transition stays pending, as a slow server render would.
jest.mock('react', () => ({
...jest.requireActual('react'),
useTransition: () => [true, (fn: () => void) => fn()],
}));
const params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
beforeEach(() => push.mockClear());
it('keeps the sheet usable, and says it is busy, while a change lands', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeEnabled();
}
expect(within(sheet).getByRole('radio', { name: 'Within 3 miles' })).toBeEnabled();
expect(sheet.querySelector('[aria-busy="true"]')).not.toBeNull();
});
it('builds a second change on the first, not on the URL it has not reached', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
fireEvent.change(within(sheet).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'council' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
expect(next.get('school_type')).toBe('council');
expect(next.get('postcode')).toBe('SW196AR');
});
describe('a screen that widens past phone width', () => {
let listeners: ((e: { matches: boolean }) => void)[] = [];
beforeEach(() => {
listeners = [];
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: true, media: query,
addEventListener: (_: string, l: (e: { matches: boolean }) => void) => listeners.push(l),
removeEventListener: jest.fn(),
}));
});
it('closes the sheet, leaving the desktop row as the only filters', () => {
render(<FilterBar filters={filters} />);
openSheet();
act(() => listeners.forEach((l) => l({ matches: false })));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { HomeView } from '@/components/HomeView';
import { fetchSchools } from '@/lib/api';
import { fetchLAaverages, fetchSchools } from '@/lib/api';
import { primaryFixture } from '../support/schoolFixtures';
import type { SchoolsResponse, School } from '@/lib/types';
@@ -18,7 +18,10 @@ jest.mock('@/lib/api', () => ({
fetchNationalAverages: jest.fn(async () => ({})),
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
}));
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
// Renders only the List/Map switch HomeView hands it, which lives in its row.
jest.mock('@/components/FilterBar', () => ({
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null,
}));
jest.mock('@/components/SchoolRow', () => ({ SchoolRow: ({ school }: {school: School}) => <div>{school.school_name}</div> }));
jest.mock('@/components/SchoolMap', () => ({ SchoolMap: ({ schools }: {schools: School[]}) => <div data-testid="map">{schools.map(s => s.school_name).join(',')}</div> }));
@@ -81,3 +84,23 @@ test('failed map requests can be retried by reopening the map', async () => {
expect(fetchSchools).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('map')).toHaveTextContent('Retry result');
});
test('LA averages are not fetched with force-cache, so one failure is not replayed for good', async () => {
// force-cache serves any stored response, however old, without asking the
// server. A request that failed once (a staging deploy restart, the July
// proxy outage) was stored and replayed on every later visit, and the
// "vs LA avg" delta vanished from every secondary row in that browser.
// The default mode honours the API's Cache-Control and never reuses an
// error.
params = new URLSearchParams('search=high');
const secondary: SchoolsResponse = {
...response('Alpha High'),
schools: [{ ...primaryFixture.schoolInfo, school_name: 'Alpha High', phase: 'Secondary', attainment_8_score: 50 }],
};
render(<HomeView initialSchools={secondary} filters={filters} />);
await act(async () => {});
expect(fetchLAaverages).toHaveBeenCalled();
for (const [options] of jest.mocked(fetchLAaverages).mock.calls) {
expect(options?.cache).not.toBe('force-cache');
}
});
@@ -8,7 +8,6 @@
import { render, screen } from '@testing-library/react';
import {
nearbyNoun,
NearbySchoolsSection,
shouldRenderNearby,
} from '@/components/school/NearbySchoolsSection';
@@ -43,13 +42,7 @@ function school(overrides: Partial<NearbySchool> = {}): NearbySchool {
function renderSection(nearby: NearbySchool[]) {
return render(
<NearbySchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
nearby={nearby}
/>,
<NearbySchoolsSection urn={100001} thisMetricValue={72} nearby={nearby} />,
);
}
@@ -90,15 +83,10 @@ describe('what the section claims', () => {
});
it('shows no chips at all when nothing is shared, rather than inventing one', () => {
const { container } = render(
<NearbySchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
nearby={[school({ shared: [] }), school({ urn: 100003, shared: [] })]}
/>,
);
const { container } = renderSection([
school({ shared: [] }),
school({ urn: 100003, shared: [] }),
]);
// The card still carries its distance, name, type and figure — just no
// claim of likeness.
expect(container.querySelectorAll('li ul').length).toBe(0);
@@ -106,37 +94,6 @@ describe('what the section claims', () => {
});
});
describe('what the lede calls the set', () => {
it.each([
['Primary', 'primary schools'],
['Middle deemed primary', 'primary schools'],
['Secondary', 'secondary schools'],
['Middle deemed secondary', 'secondary schools'],
['All-through', 'all-through schools'],
// GIAS phase 6. Its candidates span the whole secondary group, so no
// single noun fits and it takes the honest general one.
['16 plus', 'schools and colleges'],
['', 'schools'],
[null, 'schools'],
])('calls a %s school\'s neighbours "%s"', (phase, expected) => {
expect(nearbyNoun(phase)).toBe(expected);
});
it('never calls a sixth form college\'s neighbours primary schools', () => {
render(
<NearbySchoolsSection
urn={100001}
schoolName="Barnet Sixth Form College"
phase="16 plus"
thisMetricValue={null}
nearby={[school(), school({ urn: 100003 })]}
/>,
);
expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument();
expect(screen.queryByText(/primary schools/)).not.toBeInTheDocument();
});
});
describe('cards', () => {
it('links each school to its canonical slug', () => {
renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { OfstedSection } from '@/components/school/OfstedSection';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { OfstedInspection } from '@/lib/types';
const empty = {
framework: null, inspection_date: null, inspection_type: null, overall_effectiveness: null,
quality_of_education: null, behaviour_attitudes: null, personal_development: null,
leadership_management: null, early_years_provision: null, sixth_form_provision: null,
previous_overall: null, rc_safeguarding_met: null, rc_inclusion: null, rc_curriculum_teaching: null,
rc_achievement: null, rc_attendance_behaviour: null, rc_personal_development: null,
rc_leadership_governance: null, rc_early_years: null, rc_sixth_form: null, report_url: null,
report_card: {},
} as OfstedInspection;
// `page` mimics how each school page used to mount the section; the secondary
// page's branch hard-coded four areas and dropped the sixth form (audit M2).
function renderSection(o: Partial<OfstedInspection>, page: Record<string, unknown> = {}) {
const ofsted = { ...empty, ...o } as OfstedInspection;
render(
<OfstedSection
{...page}
ofsted={ofsted}
urn={1}
isReportCard={false}
ofstedInspectedDate={ofsted.latest_visit?.date ?? null}
oeifAllSameGrade={false}
oeifAreas={ofstedLegacyAreas(ofsted)}
/>,
);
}
it('shows no overall grade, not an older one, after a no-grade inspection (Rabbsfarm)', () => {
renderSection({
inspection_date: '2025-06-17', quality_of_education: 3, behaviour_attitudes: 3,
personal_development: 2, leadership_management: 3, early_years_provision: 2,
current_grade: null, latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null },
});
expect(screen.getByText(/Inspected 17 June 2025/)).toBeInTheDocument();
expect(screen.getByText('No overall grade')).toBeInTheDocument();
expect(screen.queryByText('Good', { selector: 'span' })).not.toBeInTheDocument();
expect(screen.queryByText('Not rated')).not.toBeInTheDocument();
expect(screen.getAllByText('Requires Improvement')).toHaveLength(3);
});
it('keeps the sixth-form judgement when there is no overall grade (audit M2)', () => {
renderSection({
inspection_date: '2025-04-01', quality_of_education: 1, behaviour_attitudes: 1,
personal_development: 1, leadership_management: 1, sixth_form_provision: 2,
current_grade: null, latest_visit: { date: '2025-04-01', kind: 'graded', outcome: null },
}, { variant: 'secondary' });
expect(screen.getByText(/sixth form/i)).toBeInTheDocument();
expect(screen.getByText('Good')).toBeInTheDocument();
});
it('dates a grade by its source and prints a later visit separately (Washwood Heath)', () => {
renderSection({
inspection_date: '2020-03-03', overall_effectiveness: 2, quality_of_education: 2,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' },
});
expect(screen.getByText(/Inspected 21 May 2025/)).toBeInTheDocument();
expect(screen.getByText('Graded inspection, 3 March 2020')).toBeInTheDocument();
expect(screen.getByText(/Latest visit: Ungraded inspection, 21 May 2025: Standards maintained/)).toBeInTheDocument();
});
it('says a grade was confirmed at an ungraded visit (Robins Lane)', () => {
renderSection({
inspection_date: '2020-01-07', overall_effectiveness: 2,
current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' },
});
expect(screen.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeInTheDocument();
expect(screen.queryByText(/Latest visit/)).not.toBeInTheDocument();
});
it('prints an ungraded outcome when there is no grade (Oakgrove)', () => {
renderSection({
current_grade: null,
latest_visit: { date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' },
});
expect(screen.getByText('No overall grade')).toBeInTheDocument();
expect(screen.getByText(/Ungraded inspection, 13 November 2024: Standards maintained/)).toBeInTheDocument();
});
@@ -361,12 +361,12 @@ describe('PlaceView school attributes', () => {
{ 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,
nursery_provision: 'Has Nursery Classes',
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,
nursery_provision: 'No Nursery Classes',
parliamentary_constituency: 'Witham' } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
@@ -459,6 +459,18 @@ describe('PlaceView school attributes', () => {
expect(cells.slice(2)).toEqual(['—', '—', '—', '—']);
});
it('reads "Not applicable" as no nursery, not as a yes', () => {
// GIAS sends text. Tested for truthiness, every value was a "Yes".
const notApplicable: PlaceDetail = {
...withAttributes,
schools: [{ urn: 5, school_name: 'Epsilon Primary', phase: 'Primary',
rwm_expected_pct: 70, nursery_provision: 'Not applicable' } as never],
};
const { container } = render(<PlaceView detail={notApplicable} phase="primary"
englandAverage={61} neighbours={[]} />);
expect(container.querySelector('tbody')!.textContent).not.toContain('Yes');
});
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.
@@ -467,7 +479,7 @@ describe('PlaceView school attributes', () => {
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,
nursery_provision: 'Has Nursery Classes',
parliamentary_constituency: 'Chelmsford' } as never],
};
const { container } = render(<PlaceView detail={allThrough}
@@ -24,7 +24,10 @@ jest.mock('@/lib/api', () => ({
fetchNationalAverages: jest.fn(),
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
}));
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
// Renders only the List/Map switch HomeView hands it, which lives in its row.
jest.mock('@/components/FilterBar', () => ({
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null,
}));
jest.mock('@/components/SchoolMap', () => ({
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
selectedUrn: number | null; radiusMiles?: number;
@@ -177,5 +180,16 @@ it('lets a keyboard pick a pin from the list, with a real button', async () => {
it('keeps the postcode in the heading in list view, where there is room', async () => {
await renderMap();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 1.0 miles of SW196AR' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '3 schools within 1 mile of SW196AR' })).toBeInTheDocument();
});
it('draws and names a quarter-mile search as 0.25, not rounded to 0.3', async () => {
params = new URLSearchParams('postcode=SW196AR&radius=0.25');
const quarter = { ...results(), location_info: { postcode: 'SW196AR', radius: 0.25 * 1.60934, coordinates: [51.42, -0.21] } } as SchoolsResponse;
render(<HomeView initialSchools={quarter} filters={filters} />);
await act(async () => {});
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '0.25');
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles of SW196AR' })).toBeInTheDocument();
});
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { HomeView } from '@/components/HomeView';
import { FilterBar } from '@/components/FilterBar';
import { fetchSchools } from '@/lib/api';
@@ -35,6 +35,7 @@ jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () => <div data-testid="
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
function results(): SchoolsResponse {
@@ -99,17 +100,27 @@ describe('the toolbar filters', () => {
});
it('counts only what More filters hides', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&local_authority=Wandsworth');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=council&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
});
});
describe('the phone filter row', () => {
describe('the phone chips line', () => {
it('drops its "more this way" fade when nothing is left to scroll', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary');
render(<FilterBar filters={filters} />);
// jsdom lays nothing out, so the row reads as not overflowing at all.
expect(screen.getByRole('group', { name: 'Filters' }).className).toMatch(/controlsAtEnd/);
// jsdom lays nothing out, so the line reads as not overflowing at all.
const line = screen.getByRole('group', { name: 'Applied filters' }).parentElement!;
expect(line.className).toMatch(/controlsAtEnd/);
});
});
describe('the phone filter sheet', () => {
it('offers the results total', () => {
render(<HomeView initialSchools={results()} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getByRole('button', { name: 'Show 1 school' })).toBeInTheDocument();
});
});
@@ -155,3 +166,33 @@ describe('the folded search', () => {
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
});
});
describe('the results list', () => {
it('repeats no applied filters above the results; the filter bar shows them', () => {
params = new URLSearchParams('search=southmead&school_type=council&local_authority=Wandsworth');
const { container } = render(<HomeView initialSchools={results()} filters={filters} />);
expect(container.querySelector('[class*="activeFilters"]')).toBeNull();
expect(screen.queryByText('Search: southmead')).not.toBeInTheDocument();
});
});
describe('the desktop Clear all', () => {
const row = () => screen.getByRole('group', { name: 'Filters' });
it('removes the filters and keeps the search, rather than going home', () => {
params = new URLSearchParams('postcode=SW196AR&radius=3&phase=primary&school_type=council&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
fireEvent.click(within(row()).getByRole('button', { name: 'Clear all' }));
const pushed = push.mock.calls.at(-1)![0] as string;
const next = new URLSearchParams(pushed.split('?')[1]);
expect(next.get('postcode')).toBe('SW196AR');
expect(next.get('radius')).toBe('3');
for (const key of ['phase', 'school_type', 'local_authority']) expect(next.get(key)).toBeNull();
});
it('is not offered when only a search is applied', () => {
params = new URLSearchParams('search=southmead');
render(<FilterBar filters={filters} />);
expect(within(row()).queryByRole('button', { name: /^Clear/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
/**
* SchoolRow (primary search results): line 2 prints the religious character
* only when the school has one. The register's "None" was printed as a chip.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SchoolRow } from '@/components/SchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100001,
school_name: 'Alpha Primary School',
local_authority: 'Testshire',
school_type: 'Free schools',
phase: 'Primary',
gender: 'Mixed',
age_range: '4-11',
rwm_expected_pct: 70,
} as unknown as School;
describe('SchoolRow religious character', () => {
it.each(['None', 'Does not apply', ''])(
'prints nothing when the register says %p',
(religious_denomination) => {
render(<SchoolRow school={{ ...base, religious_denomination }} />);
expect(screen.queryByText('None')).not.toBeInTheDocument();
expect(screen.queryByText('Does not apply')).not.toBeInTheDocument();
},
);
it('prints a religious character the school has', () => {
render(<SchoolRow school={{ ...base, religious_denomination: 'Church of England' }} />);
expect(screen.getByText('Church of England')).toBeInTheDocument();
});
});
describe('SchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Free schools')).not.toBeInTheDocument();
});
it('flags a nursery class', () => {
render(<SchoolRow school={{ ...base, nursery_provision: 'Has Nursery Classes' }} />);
expect(screen.getByText('Nursery class')).toBeInTheDocument();
});
it("flags a boys' school", () => {
render(<SchoolRow school={{ ...base, gender: 'Boys' }} />);
expect(screen.getByText("Boys' school")).toBeInTheDocument();
});
});
@@ -65,3 +65,54 @@ describe('SecondarySchoolRow proposed-to-close tag', () => {
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow admissions tag', () => {
it('tags a selective school', () => {
render(<SecondarySchoolRow school={{ ...base, admissions_policy: 'Selective' }} />);
expect(screen.getByText('Selective')).toBeInTheDocument();
});
it('does not tag a non-selective school as selective', () => {
// "Non-selective" contains "selective": a substring test tagged every
// comprehensive (Burntwood, Graveney) as Selective.
render(<SecondarySchoolRow school={{ ...base, admissions_policy: 'Non-selective' }} />);
expect(screen.queryByText('Selective')).not.toBeInTheDocument();
});
it.each(['None', 'Does not apply'])(
'gives no faith tag when the religious character is %p',
(religious_denomination) => {
render(
<SecondarySchoolRow
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination }}
/>,
);
expect(screen.queryByText(religious_denomination)).not.toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
},
);
it('tags the religious character the register records, not "Faith priority"', () => {
render(
<SecondarySchoolRow
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination: 'Church of England' }}
/>,
);
expect(screen.getByText('Church of England')).toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Academy')).not.toBeInTheDocument();
});
it("flags a girls' school and fees", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'independent', gender: 'Girls' }} />);
expect(screen.getByText("Girls' school")).toBeInTheDocument();
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
/**
* The school header: one fact line (phase, ages, type, pupils), then flags
* only for what applies, then the address and the details.
*
* nursery_provision is GIAS text, not a boolean: "Has Nursery Classes",
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
* of those read as a nursery, so secondaries aged 11–18 showed "Nursery".
*/
import { screen, within } from '@testing-library/react';
import type { School } from '@/lib/types';
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/SatsChart', () => ({
__esModule: true,
default: () => <div data-testid="sats-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function withSchool<T extends { schoolInfo: School }>(fixture: T, info: Partial<School>): T {
return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
}
const flagList = () => screen.queryByRole('list', { name: 'Admission and provision' });
const flagLabels = () => within(flagList()!).getAllByRole('listitem').map((li) => li.textContent);
describe('school header fact line', () => {
it('states phase, ages, type and pupils, in that order', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
const line = screen.getByText('Ages 4–11').parentElement!;
expect(line).toHaveTextContent(/^PrimaryAges 4–11State school420 pupils$/);
});
it("prints the register's type name for a type in no group", () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: null, school_type: 'Secure units' }));
expect(screen.getByText('Secure units')).toBeInTheDocument();
});
it('no longer prints the GIAS establishment type', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { type_group: 'state' }));
expect(screen.queryByText('Academy converter')).not.toBeInTheDocument();
});
});
describe('school header flags', () => {
it('shows no flag list for a school with nothing to flag', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
expect(flagList()).not.toBeInTheDocument();
});
it('lists who-can-apply flags, then what the school offers', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, {
type_group: 'independent', admissions_policy: 'Selective', gender: 'Girls',
religious_denomination: 'Church of England', has_sixth_form: true,
}));
expect(flagLabels()).toEqual(['Fee-paying', 'Selective', "Girls' school", 'Church of England', 'Sixth form']);
});
it('shows Nursery class when GIAS says the school has nursery classes', () => {
renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
expect(flagLabels()).toEqual(['Nursery class']);
});
it.each(['No Nursery Classes', 'Not applicable', null])(
'shows no nursery flag when GIAS says %p',
(value) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
expect(screen.queryByText(/^Nursery/)).not.toBeInTheDocument();
},
);
it.each([['Girls', "Girls' school"], ['Boys', "Boys' school"]])(
'labels a %s school with a plural possessive',
(gender, label) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender }));
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.queryByText(/'s school/)).not.toBeInTheDocument();
},
);
it('says nothing for a mixed school', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender: 'Mixed' }));
expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
});
});
describe('school header address', () => {
it('names the council after the postcode', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText(/TE1 1ST · Westshire/)).toBeInTheDocument();
});
it('leaves the council out when the address already names it', () => {
renderSchoolDetail(withSchool(primaryFixture, { local_authority: 'Testville' }));
expect(screen.queryByText(/· Testville/)).not.toBeInTheDocument();
});
});
describe('school header details', () => {
const detailed = {
headteacher_name: 'Mrs A Head', capacity: 426, county: 'Surrey',
parliamentary_constituency: 'Putney', religious_denomination: 'Church of England',
};
it('drops county, constituency and religious character', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.queryByText('County:')).not.toBeInTheDocument();
expect(screen.queryByText('Constituency:')).not.toBeInTheDocument();
expect(screen.queryByText('Religious character:')).not.toBeInTheDocument();
});
it('shows the capacity', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.getByText('Capacity:').parentElement).toHaveTextContent('Capacity: 426');
});
it('names an academy trust', () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'BURNTWOOD TRUST' }));
expect(screen.getByText('Academy trust:').parentElement).toHaveTextContent('BURNTWOOD TRUST');
});
it("hides a trust that has the school's own name", () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'TEST PRIMARY SCHOOL' }));
expect(screen.queryByText('Academy trust:')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
/**
* "Pupils" on a school page is the size of the school.
*
* A year's results row carries the cohort its figures were measured on. For a
* secondary that is the GCSE year group alone (Burntwood: 245, against 1,462
* on roll), so it must never stand in for the whole-school count.
*/
import { screen } from '@testing-library/react';
import { secondaryFixture } from '../support/schoolFixtures';
import { renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/SatsChart', () => ({
__esModule: true,
default: () => <div data-testid="sats-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function withoutCensus(schoolTotal: number | null) {
const yearlyData = secondaryFixture.yearlyData.map((r) => ({ ...r, total_pupils: 245 }));
return {
...secondaryFixture,
census: null,
yearlyData,
schoolInfo: { ...secondaryFixture.schoolInfo, total_pupils: schoolTotal },
};
}
describe('pupil count without a census record', () => {
it('uses the register count in the header, not the results cohort', () => {
renderSecondarySchoolDetail(withoutCensus(1462));
expect(screen.getByText('1,462 pupils')).toBeInTheDocument();
expect(screen.queryByText('245 pupils')).not.toBeInTheDocument();
});
it('shows no count rather than the results cohort when the register has none', () => {
renderSecondarySchoolDetail(withoutCensus(null));
expect(screen.queryByText(/^[\d,]+ pupils$/)).not.toBeInTheDocument();
expect(screen.queryByText('Total pupils')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
/**
* The Admissions section's Selective and faith notes.
*
* It tested the policy with includes('selective'), which "Non-selective"
* passes, and excluded only "Does not apply" from the religious character, so
* Burntwood (no religious character, recorded as "None") read "this school
* has a faith-based admissions priority (None)". The Selective half never
* fired only because the school page's API did not send admissions_policy.
*/
import { screen, within } from '@testing-library/react';
import type { School } from '@/lib/types';
import { secondaryFixture } from '../support/schoolFixtures';
import { renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function admissionsOf(info: Partial<School>) {
renderSecondarySchoolDetail({ ...secondaryFixture, schoolInfo: { ...secondaryFixture.schoolInfo, ...info } });
return within(screen.getByRole('heading', { name: 'Admissions' }).closest('section')!);
}
describe('Admissions section notes', () => {
it('notes the entrance test for a selective school', () => {
const section = admissionsOf({ admissions_policy: 'Selective', religious_denomination: 'None' });
expect(section.getByText('Selective:')).toBeInTheDocument();
});
it('does not call a non-selective school selective', () => {
const section = admissionsOf({ admissions_policy: 'Non-selective', religious_denomination: 'None' });
expect(section.queryByText('Selective:')).not.toBeInTheDocument();
});
it.each(['None', 'Does not apply'])('claims no faith priority when the register says %p', (religious_denomination) => {
// Not "Non-selective": a misread Selective would win and hide this case.
const section = admissionsOf({ admissions_policy: 'Not applicable', religious_denomination });
expect(section.queryByText('Faith priority:')).not.toBeInTheDocument();
});
it('notes the religious character of a faith school', () => {
const section = admissionsOf({ admissions_policy: 'Non-selective', religious_denomination: 'Church of England' });
expect(section.getByText('Faith priority:')).toBeInTheDocument();
});
});
+12 -15
View File
@@ -114,16 +114,13 @@ describe('ofstedDisplay', () => {
expect(d.kind).toBe('report_card');
});
it('distinguishes graded from carried-forward grades', () => {
const graded = ofstedDisplay(
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
);
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
const carried = ofstedDisplay(
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
);
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
it('distinguishes a graded grade from a confirmed one', () => {
expect(ofstedDisplay(ofsted({ current_grade: { grade: 1, date: '2019-10-09', basis: 'graded' },
latest_visit: { date: '2025-02-05', kind: 'ungraded', outcome: 'Some aspects not as strong' } })))
.toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', gradeDate: '2019-10-09' });
expect(ofstedDisplay(ofsted({ current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })))
.toMatchObject({ kind: 'confirmed', gradeLabel: 'Good' });
});
it('handles missing data', () => {
@@ -131,11 +128,11 @@ describe('ofstedDisplay', () => {
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
});
it('identifies transitional inspections without overall grades', () => {
const transitional = ofstedDisplay(
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
);
expect(transitional.kind).toBe('transitional');
it('has no overall grade when the latest inspection gave none', () => {
// Rabbsfarm: the graded inspection's own overall is null, so no grade is
// in force even though an older ungraded visit said "remains Good".
expect(ofstedDisplay(ofsted({ current_grade: null, overall_effectiveness: null,
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).kind).toBe('no_overall_grade');
});
it('uses the four legacy grade words', () => {
@@ -157,6 +157,13 @@ describe('compareToCutoff', () => {
});
describe('describeCutoffAbsence', () => {
it('does not read "Non-selective" as selective', () => {
// A substring test matched "selective" inside "Non-selective".
const s = describeCutoffAbsence({ localAuthority: 'Wandsworth', admissionsPolicy: 'Non-selective' });
expect(s).not.toMatch(/entrance test/);
expect(s).toMatch(/^Wandsworth has not published/);
});
it('explains a selective school by how it admits, not as missing data', () => {
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
expect(s).toContain('entrance test');
@@ -0,0 +1,47 @@
import { gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
const base = { report_card: {} } as unknown as OfstedInspection;
describe('gradeSourceLine', () => {
it('names the graded inspection and its date', () => {
expect(gradeSourceLine({ grade: 2, date: '2016-07-06', basis: 'graded' }))
.toBe('Graded inspection, 6 July 2016');
});
it('names the ungraded visit that confirmed it', () => {
expect(gradeSourceLine({ grade: 2, date: '2023-03-14', basis: 'confirmed' }))
.toBe('Confirmed at an ungraded inspection, 14 March 2023');
});
});
describe('latestVisitLine', () => {
it('prints the ungraded outcome', () => {
expect(latestVisitLine({ date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' }))
.toBe('Ungraded inspection, 13 November 2024: Standards maintained');
});
it('prints a graded visit without an outcome', () => {
expect(latestVisitLine({ date: '2025-06-17', kind: 'graded', outcome: null }))
.toBe('Graded inspection, 17 June 2025');
});
});
describe('showLatestVisitLine', () => {
it('shows a later visit than the one the grade came from', () => {
expect(showLatestVisitLine({ ...base,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true);
});
it('hides it when the visit is the grade’s own source', () => {
expect(showLatestVisitLine({ ...base,
current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })).toBe(false);
});
it('shows an ungraded outcome when there is no grade', () => {
expect(showLatestVisitLine({ ...base, current_grade: null,
latest_visit: { date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true);
});
it('hides it for a graded visit with no grade (the title already dates it)', () => {
expect(showLatestVisitLine({ ...base, current_grade: null,
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false);
});
});
@@ -0,0 +1,74 @@
/**
* The facts the school header and the search rows print: a type in the search
* filter's terms, and flags only for what applies.
*/
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { School } from '@/lib/types';
const school = (over: Partial<School>): School =>
({ urn: 1, school_name: 'Test School', ...over }) as School;
const labels = (over: Partial<School>) => schoolFlags(school(over)).map((f) => f.label);
describe('schoolTypeLabel', () => {
it.each([
['state', 'State school'],
['independent', 'Independent school'],
['special', 'Special school (SEND)'],
['post16', 'Sixth form or college'],
['alternative', 'Alternative provision'],
])("names the %s group in the search filter's terms", (type_group, label) => {
expect(schoolTypeLabel(school({ type_group, school_type: 'Academy converter' }))).toBe(label);
});
it("prints the register's own name for a type in no group", () => {
expect(schoolTypeLabel(school({ type_group: null, school_type: 'Secure units' }))).toBe('Secure units');
});
it('returns null when there is no type at all', () => {
expect(schoolTypeLabel(school({}))).toBeNull();
});
});
describe('schoolFlags', () => {
it('flags nothing for a mixed, non-faith, non-selective state school', () => {
expect(labels({
type_group: 'state', gender: 'Mixed', admissions_policy: 'Non-selective',
religious_denomination: 'None', nursery_provision: 'No Nursery Classes',
has_sixth_form: false, phase: 'Secondary',
})).toEqual([]);
});
it('lists who-can-apply flags first, then what the school offers, in a fixed order', () => {
expect(labels({
type_group: 'independent', admissions_policy: 'Selective', gender: 'Boys',
religious_denomination: 'Christian', nursery_provision: 'Has Nursery Classes',
has_sixth_form: true, phase: 'All-through',
})).toEqual(['Fee-paying', 'Selective', "Boys' school", 'Christian', 'Nursery class', 'Sixth form']);
});
it('marks who-can-apply flags as conditions and offers as provision', () => {
const kinds = Object.fromEntries(
schoolFlags(school({ admissions_policy: 'Selective', has_sixth_form: true, phase: 'Secondary' }))
.map((f) => [f.label, f.kind]),
);
expect(kinds).toEqual({ Selective: 'condition', 'Sixth form': 'provision' });
});
it('never prints Non-selective, and never a no-faith value', () => {
expect(labels({ admissions_policy: 'Non-selective', religious_denomination: 'Does not apply' })).toEqual([]);
expect(labels({ admissions_policy: 'Not applicable', religious_denomination: 'None' })).toEqual([]);
// PlaceView's rule: GIAS sometimes says "Not applicable" for no faith too.
expect(labels({ religious_denomination: 'Not applicable' })).toEqual([]);
});
it('prints the religious character as the register records it', () => {
expect(labels({ religious_denomination: 'Church of England/Methodist' })).toEqual(['Church of England/Methodist']);
});
it('does not flag a nursery class on a nursery school, or a sixth form on a post-16 one', () => {
expect(labels({ phase: 'Nursery', nursery_provision: 'Has Nursery Classes' })).toEqual([]);
expect(labels({ phase: '16 plus', has_sixth_form: true })).toEqual([]);
});
});
+51 -6
View File
@@ -15,6 +15,8 @@ import {
computeYBounds,
formatAgeRange,
formatAgeSpan,
hasNurseryClasses,
singleSexLabel,
} from '@/lib/utils';
describe('formatPercentage', () => {
@@ -145,23 +147,37 @@ describe('ofstedLegacyAreas', () => {
describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Outstanding · 2023');
expect(badge.cssClass).toBe('ofsted1');
});
it('returns grade word for each OEIF grade', () => {
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_grade_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_grade_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
});
it('returns grade word without year when date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null });
it('prints a grade without a year when its date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: null, ofsted_date: '2025-01-01' });
expect(badge.label).toBe('Good');
expect(badge.cssClass).toBe('ofsted2');
});
it('dates a grade by the inspection that awarded it, not the latest visit', () => {
// Washwood Heath: Good from a March 2020 graded inspection; latest visit
// an ungraded one in May 2025 (audit M1).
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2020-03-03', ofsted_date: '2025-05-21' });
expect(badge.label).toBe('Good · 2020');
});
it('shows Inspected for a latest inspection that gave no grade (Rabbsfarm)', () => {
// Audit C1: this used to read "Good · 2025", from a 2020 ungraded visit.
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_grade_date: null, ofsted_date: '2025-06-17' });
expect(badge.label).toBe('Inspected · 2025');
expect(badge.cssClass).toBe('ofstedInspected');
});
it('returns a Report Card badge when ofsted_rc_date is present', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
expect(badge.label).toBe('Report Card · 2026');
@@ -346,3 +362,32 @@ describe('formatAgeRange', () => {
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
});
});
describe('hasNurseryClasses', () => {
it('is true only for the GIAS value that means it', () => {
// GIAS sends text, and two of its three values mean no nursery.
expect(hasNurseryClasses('Has Nursery Classes')).toBe(true);
expect(hasNurseryClasses('No Nursery Classes')).toBe(false);
expect(hasNurseryClasses('Not applicable')).toBe(false);
expect(hasNurseryClasses(null)).toBe(false);
expect(hasNurseryClasses(undefined)).toBe(false);
});
});
describe('singleSexLabel', () => {
it('uses the plural possessive GIAS values need', () => {
expect(singleSexLabel('Girls')).toBe("Girls' school");
expect(singleSexLabel('Boys')).toBe("Boys' school");
});
it('ignores case, as hasNurseryClasses does', () => {
expect(singleSexLabel(' girls ')).toBe("Girls' school");
expect(singleSexLabel('BOYS')).toBe("Boys' school");
});
it('returns null for a mixed or unknown school', () => {
expect(singleSexLabel('Mixed')).toBeNull();
expect(singleSexLabel(null)).toBeNull();
expect(singleSexLabel(undefined)).toBeNull();
});
});
@@ -39,7 +39,6 @@ export function renderSchoolDetail(fixture: any) {
withProviders(
<SchoolDetailShell
schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census}
navItems={navItems}
>
@@ -67,7 +66,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
withProviders(
<SchoolDetailShell
schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census}
navItems={navItems}
>
@@ -191,7 +191,8 @@ export const primaryFixture = {
personal_development: 2,
leadership_management: 2,
previous_overall: 3,
grade_source: 'graded',
current_grade: { grade: 2, date: '2023-05-17', basis: 'graded' },
latest_visit: { date: '2023-05-17', kind: 'graded', outcome: null },
}),
census,
admissions: makeAdmissions({ year: 2024 }),
@@ -295,7 +296,8 @@ export const allThroughFixture = {
behaviour_attitudes: 1,
personal_development: 1,
leadership_management: 1,
grade_source: 'graded',
current_grade: { grade: 1, date: '2022-10-04', basis: 'graded' },
latest_visit: { date: '2022-10-04', kind: 'graded', outcome: null },
}),
census,
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
+7
View File
@@ -645,6 +645,13 @@ html .leaflet-container a.leaflet-popup-close-button:focus {
color: var(--text-primary);
}
/* The popup card's View link is a .btn-tertiary, which leaflet.css's
`.leaflet-container a` (0,1,1) paints #0078A8 over its 0,1,0. Its hover
rule, at 0,3,0, still wins over this. */
html .leaflet-container a.btn-tertiary {
color: var(--text-secondary);
}
/* Main content column */
.main {
max-width: 1400px;
+8 -3
View File
@@ -6,7 +6,7 @@
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { formatAcademicYear } from '@/lib/utils';
import { DEFAULT_RADIUS_MILES, formatAcademicYear } from '@/lib/utils';
import { HomeView } from '@/components/HomeView';
import { getFlags } from '@/lib/flags';
import { HowItWorksSection } from '@/components/HowItWorksSection';
@@ -25,6 +25,7 @@ interface HomePageProps {
gender?: string;
admissions_policy?: string;
has_sixth_form?: string;
faith?: string;
}>;
}
@@ -71,7 +72,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// Parse search params
const page = parseInt(params.page || '1');
const radius = params.radius ? parseFloat(params.radius) : undefined;
// Without this the API applies its own 5-mile default while the Distance
// control shows DEFAULT_RADIUS_MILES.
const radius = params.radius ? parseFloat(params.radius) : DEFAULT_RADIUS_MILES;
// Check if user has performed a search
const hasSearchParams = !!(
@@ -82,7 +85,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
params.postcode ||
params.gender ||
params.admissions_policy ||
params.has_sixth_form
params.has_sixth_form ||
params.faith
);
// Failures propagate to the retryable error boundary.
@@ -103,6 +107,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
gender: params.gender,
admissions_policy: params.admissions_policy,
has_sixth_form: params.has_sixth_form,
faith: params.faith,
});
} else {
// Empty state by default
@@ -249,7 +249,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
{isSecondary ? (
<SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={secondaryNavItems}
>
@@ -273,7 +272,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
) : (
<SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={primaryNavItems}
>
+163 -18
View File
@@ -40,8 +40,19 @@
margin: 0 auto 1.5rem;
}
/* One row where it fits: the search takes what the controls leave, and the
"More filters" panel breaks onto its own line below both. */
/*
* One row on wide screens, two below 1340px — decided by the width alone,
* never by what the search returned.
*
* The row once wrapped wherever it ran out of room, and its contents change
* with the results: the List/Map switch beside it, the distance chip and Clear
* all come and go. So the same search folded onto two lines when it had
* results and sat on one when it had none. Now the controls never wrap away
* from the search on a wide screen; the search box takes what they leave, and
* 1340px is where the fullest toolbar (distance, phase, type, More filters,
* Clear and the switch) still leaves it 12rem. The "More filters" panel breaks
* onto its own line below both.
*/
.filterBar:not(.heroMode) {
display: flex;
flex-wrap: wrap;
@@ -56,12 +67,38 @@
}
.filterBar:not(.heroMode) .searchSection {
flex: 1 1 320px;
flex: 1 1 0;
min-width: 0;
}
/* Only phones fold the form away; see the 640px block. */
.searchSummary {
.filterBar:not(.heroMode) .controlsRow {
flex: 0 0 auto;
flex-wrap: nowrap;
}
/* The List/Map switch closes the line; the "More filters" panel follows it. */
.viewSwitchSlot {
flex: 0 0 auto;
order: 1;
}
/* Below that, the switch stays up beside the search and the controls always
take a full-width line of their own, results or not. */
@media (min-width: 641px) and (max-width: 1339px) {
.viewSwitchSlot {
order: 0;
}
.filterBar:not(.heroMode) .controlsRow {
flex-basis: 100%;
flex-wrap: wrap;
}
}
/* Only phones fold the form away and filter through the sheet; see the
640px block. */
.summaryRow,
.chipsLine {
display: none;
}
@@ -313,6 +350,11 @@
font-weight: 500;
}
/* In the phone sheet a select is a full-width field and a whole touch target. */
.sheetSelect {
min-height: 2.75rem;
}
/* A pill, 44px tall: these are the page's main controls now, not fine print,
and a phone needs the full touch target. */
.controlSelect {
@@ -324,8 +366,9 @@
font-weight: 500;
white-space: nowrap;
/* A select is as wide as its longest option, and a school type can run to
"Academy special sponsor led". Cap it; the chosen value truncates. */
max-width: 14rem;
"Academy special sponsor led". Cap it; the chosen value truncates. The cap
is part of the one-line budget above. */
max-width: 11rem;
text-overflow: ellipsis;
}
@@ -363,6 +406,7 @@
pushing the results off a short screen. The 3px gutter keeps the selects'
focus rings clear of the scroll clip. */
.filters {
order: 2;
flex-basis: 100%;
display: flex;
gap: 0.625rem;
@@ -516,16 +560,16 @@
/*
* Phones: the results toolbar is pinned, so it is held to two short lines.
*
* After a search the form folds into a one-line summary ("SW196AR · within
* 1 mile Edit") and the controls become a single row that scrolls sideways.
* "More filters" leads the row there: it is the one control that opens
* everything else, so it must never be the chip scrolled out of sight.
* After a search the form folds into a one-line summary ("SW196AR · 1 mi
* Edit") with the Filters button beside it, and every filter lives in
* the sheet that button opens. A second line, of the applied filters as
* chips, appears only once something is applied.
*/
@media (max-width: 640px) {
/*
* nowrap matters as much as column. The desktop rule wraps, and in a
* wrapping flex container each line is as wide as its widest item's content,
* not the container: the search summary ("SW196AR · within 1 mile Edit",
* not the container: the search summary (then "SW196AR · within 1 mile Edit",
* about 410px) stretched the line, and the controls row with it, 40px past a
* 402px iPhone 17 screen. Single-line, stretch means the container's width.
*/
@@ -579,13 +623,79 @@
display: none;
}
/* Phones switch views with the floating button (HomeView, .mobileDock). An
empty slot would still take a gap in this column. */
.viewSwitchSlot {
display: none;
}
/* The desktop row and its panel give way to the Filters button, the
applied-filter chips and the sheet. */
.filterBar:not(.heroMode) .controlsRow,
.filterBar:not(.heroMode) .filters {
display: none;
}
.summaryRow {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.summaryRow .searchSummary {
flex: 1 1 auto;
min-width: 0;
}
.sheetTrigger {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.875rem;
background: var(--bg-card);
border: 1px solid var(--text-secondary);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
/* The same brand chip as "More filters" on desktop: the list is narrowed. */
.sheetTriggerActive {
border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand);
}
.sheetTriggerCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--brand);
color: var(--bg-card);
font-size: 0.75rem;
font-weight: 700;
}
/* Bleeds to the screen edge so a chip scrolls out from under it, rather than
being cut off at the toolbar's padding. The toolbar's inline padding is
1rem at this width (HomeView.module.css, .resultsToolbar). The 4px of
block padding is room for focus rings, which the scroll clip would
otherwise cut off above and below the chips. */
.controlsRow {
flex-wrap: nowrap;
.chipsLine {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
margin: -4px -1rem;
padding: 4px 1rem;
@@ -597,15 +707,50 @@
mask-image: none;
}
.controlsRow::-webkit-scrollbar {
.chipsLine::-webkit-scrollbar {
display: none;
}
.controlsRow > * {
.chipsLine > *,
.chips > * {
flex: 0 0 auto;
}
.controlsRow .advancedToggle {
order: -1;
.chips {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.75rem 0 1rem;
background: rgba(var(--sage-rgb), 0.38);
border: 1px solid var(--brand);
border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--brand-strong);
cursor: pointer;
white-space: nowrap;
}
/* A school type can run to "Academy special sponsor led". */
.chipLabel {
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.chipsClear {
min-height: 2.75rem;
padding: 0 0.75rem;
font-size: var(--step--1);
font-weight: 500;
}
}
+471 -178
View File
@@ -3,12 +3,13 @@
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode, schoolUrl } from "@/lib/utils";
import { DEFAULT_RADIUS_MILES, isValidPostcode, schoolUrl } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import { FilterSheet, SheetField, RADIUS_OPTIONS, radiusLabel as milesLabel } from "./FilterSheet";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import type { FilterOption, Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
interface FilterBarProps {
@@ -22,6 +23,14 @@ interface FilterBarProps {
geoError?: string | null;
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
autosuggest?: boolean;
/**
* The results page's List/Map switch. It sits in this bar's own row rather
* than beside it, so that when the bar takes two lines the filters' line
* runs the full width instead of stopping short of the switch.
*/
viewSwitch?: ReactNode;
/** The results' total, for the phone filter sheet's "Show N schools". */
resultCount?: number;
}
/**
@@ -46,6 +55,76 @@ function SelectShell({
);
}
/** The filters a phone sees as chips and counts on its Filters button. */
const FILTER_KEYS = [
"phase",
"school_type",
"faith",
"local_authority",
"gender",
"has_sixth_form",
"admissions_policy",
] as const;
type FilterKey = (typeof FILTER_KEYS)[number];
/** Filters that only mean something for schools teaching beyond primary. */
const SECONDARY_ONLY_KEYS = ["gender", "has_sixth_form", "admissions_policy"] as const;
/**
* False for a phase with no secondary-age pupils, for which those filters are
* hidden: Primary, Nursery and Middle deemed primary. Matched on the words,
* not exact labels, so a change of case, hyphen or spacing in the GIAS label
* cannot leave them showing. The same reading of "primary" as
* compareGroups in lib/compareLogic.ts. The API's PHASE_GROUPS answers a
* different question (which phases a phase filter returns: primary includes
* all-through), so it is not this list.
*/
function hasSecondaryFilters(phase: string): boolean {
const p = phase.toLowerCase().replace(/[^a-z]+/g, " ").trim();
return !(p === "nursery" || (p.includes("primary") && !p.includes("secondary")));
}
/**
* The option a URL value names, ignoring case as the API does. An old link's
* raw GIAS type names none, and is shown as itself.
*/
function optionFor(options: FilterOption[], value: string) {
const v = value.toLowerCase();
return options.find((o) => o.value.toLowerCase() === v);
}
/**
* A select's value and, when the URL holds something no option spells, an
* extra option for it, so the select shows what is applied and "Any" can be
* chosen to clear it. Without one, React shows the first option, "Any",
* while the list is still filtered, and choosing it changes nothing.
*/
function selectedOf(options: FilterOption[], value: string) {
if (!value) return { value, unlisted: null };
const match = optionFor(options, value);
return match ? { value: match.value, unlisted: null } : { value, unlisted: value };
}
function SlidersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
);
}
export function FilterBar({
filters,
isHero,
@@ -54,6 +133,8 @@ export function FilterBar({
geoState = "idle",
geoError,
autosuggest = false,
viewSwitch,
resultCount,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
@@ -63,7 +144,7 @@ export function FilterBar({
const currentSearch = searchParams.get("search") || "";
const currentPostcode = searchParams.get("postcode") || "";
const currentRadius = searchParams.get("radius") || "1";
const currentRadius = searchParams.get("radius") || String(DEFAULT_RADIUS_MILES);
const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue);
@@ -127,12 +208,14 @@ export function FilterBar({
const currentGender = searchParams.get("gender") || "";
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
const currentFaith = searchParams.get("faith") || "";
// Count the filters behind "More filters". Search, postcode, distance, phase
// and school type each have their own control in the toolbar, so they are
// visible without opening anything and are not counted here.
const activeDropdownFilters = [
currentLA,
currentFaith,
currentGender,
currentAdmissionsPolicy,
currentHasSixthForm,
@@ -165,9 +248,9 @@ export function FilterBar({
setOmniValue(currentQuery);
}
// The phone row's right-edge fade says "more this way"; once there is no
// more, it only dims the last chip. Same rule as the school page's section
// nav (MOBILE.md, "Right-edge scroll-fade").
// The phone chips line's right-edge fade says "more this way"; once there is
// no more, it only dims the last chip. Same rule as the school page's
// section nav (MOBILE.md, "Right-edge scroll-fade").
const controlsRowRef = useRef<HTMLDivElement>(null);
const [controlsAtEnd, setControlsAtEnd] = useState(false);
const updateControlsAtEnd = useCallback(() => {
@@ -180,8 +263,8 @@ export function FilterBar({
window.addEventListener("resize", updateControlsAtEnd);
return () => window.removeEventListener("resize", updateControlsAtEnd);
}, [updateControlsAtEnd]);
// Chips come and go with the search (distance, Clear), so re-measure after
// every render rather than only on resize.
// Chips come and go with the filters, so re-measure after every render
// rather than only on resize.
useEffect(updateControlsAtEnd);
const openSearch = () => {
setSearchOpen(true);
@@ -205,9 +288,21 @@ export function FilterBar({
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
/*
* Where the navigation in flight is headed. useSearchParams only catches up
* once a navigation lands, so a second change made before then (the phone
* sheet stays usable while one lands) would otherwise be built on the old
* URL and undo the first. Dropped once nothing is pending, when
* searchParams is current again.
*/
const pendingQueryRef = useRef<string | null>(null);
useEffect(() => {
if (!isPending) pendingQueryRef.current = null;
}, [isPending]);
const updateURL = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
const params = new URLSearchParams(pendingQueryRef.current ?? searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value && value !== "") {
@@ -218,6 +313,7 @@ export function FilterBar({
});
params.delete("page");
pendingQueryRef.current = params.toString();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
@@ -246,6 +342,7 @@ export function FilterBar({
currentPhase && `phase=${currentPhase}`,
currentLA && `la=${currentLA}`,
currentType && `type=${currentType}`,
currentFaith && `faith=${currentFaith}`,
currentGender && `gender=${currentGender}`,
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
@@ -262,7 +359,7 @@ export function FilterBar({
if (isPostcode) {
updateURL({
postcode: cleaned.toUpperCase(),
radius: currentRadius || "1",
radius: currentRadius,
search: "",
});
} else {
@@ -271,52 +368,280 @@ export function FilterBar({
};
const handleFilterChange = (key: string, value: string) => {
updateURL({ [key]: value });
// A primary phase hides the secondary-only filters, so it clears them
// too: left applied, they would empty the list with no control showing.
const cleared =
key === "phase" && !hasSecondaryFilters(value)
? Object.fromEntries(SECONDARY_ONLY_KEYS.map((k) => [k, ""]))
: {};
updateURL({ ...cleared, [key]: value });
};
const handleClearFilters = () => {
setOmniValue("");
startTransition(() => {
router.push(pathname);
});
// Every filter at once, keeping the search and its distance. The search is
// what the results answer, not a filter on them: clearing it too used to
// send desktop back to the home page.
const handleClearFilterValues = () => {
updateURL(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
};
const hasActiveFilters =
currentSearch ||
currentLA ||
currentType ||
currentPhase ||
currentPostcode ||
currentGender ||
currentAdmissionsPolicy ||
currentHasSixthForm;
// Use result-scoped filter values when available, fall back to global
/*
* A filter's options come from the full lists, not from the results: the
* results have already been narrowed by that filter, so scoping to them
* would leave only the chosen value on offer, and switching (Girls to Boys,
* one school type to another) would need clearing first.
*
* Local authority is the exception, scoped to the results so a postcode
* search offers the councils nearby rather than all of England's.
*/
const laOptions =
resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = resultFilters?.school_types ?? filters.school_types;
// Phase is the exception: always the full list. The result set has already
// been narrowed by the phase filter, so scoping to it would leave only the
// chosen phase on offer and switching phase would need "Any phase" first.
// Five groups a parent recognises, not GIAS's 34 establishment types.
const typeOptions = filters.school_type_groups ?? [];
const faithOptions = filters.faiths ?? [];
const phaseOptions = filters.phases ?? [];
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
const admissionsPolicyOptions =
resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
const genderOptions = filters.genders ?? [];
const admissionsPolicyOptions = filters.admissions_policies ?? [];
const isSecondaryMode =
currentPhase === "secondary" || genderOptions.length > 0;
// Set by the phase chosen, never by what the results happen to contain.
const isSecondaryMode = hasSecondaryFilters(currentPhase);
// A select that is narrowing the results carries a sage tint; the class is
// only ever additive, so the control's behaviour is untouched.
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
const radiusLabel = milesLabel(currentRadius);
/*
* Phones filter through one button and a sheet holding every filter. The
* desktop row's phone version scrolled sideways with "More filters" first,
* so phase showed only in part and school type not at all, and the panel
* "More filters" opened did not contain either of them.
*/
const [sheetOpen, setSheetOpen] = useState(false);
// The sheet is phone furniture: if the screen widens past phone width while
// it is open (rotation, a resized window), close it, or its selects and the
// desktop row's would both be showing.
useEffect(() => {
if (!sheetOpen || typeof window.matchMedia !== "function") return;
const phone = window.matchMedia("(max-width: 640px)");
const onChange = (e: { matches: boolean }) => {
if (!e.matches) setSheetOpen(false);
};
phone.addEventListener("change", onChange);
return () => phone.removeEventListener("change", onChange);
}, [sheetOpen]);
const values: Record<FilterKey, string> = {
phase: currentPhase,
school_type: currentType,
faith: currentFaith,
local_authority: currentLA,
gender: currentGender,
has_sixth_form: currentHasSixthForm,
admissions_policy: currentAdmissionsPolicy,
};
const labelFor = (key: FilterKey, value: string) => {
if (key === "has_sixth_form") {
return value === "yes" ? "With sixth form" : "Without sixth form";
}
// An old link may carry a raw GIAS type, which reads as itself.
const labelled = { school_type: typeOptions, faith: faithOptions }[
key as "school_type" | "faith"
];
if (labelled) return optionFor(labelled, value)?.label ?? value;
// Phase, gender and admissions values are lowercased option names; the
// rest are the names themselves.
const named: Partial<Record<FilterKey, string[]>> = {
phase: phaseOptions,
gender: genderOptions,
admissions_policy: admissionsPolicyOptions,
};
return (named[key] ?? []).find((o) => o.toLowerCase() === value.toLowerCase()) ?? value;
};
const applied = FILTER_KEYS.filter((k) => values[k]).map((key) => ({
key,
label: labelFor(key, values[key]),
}));
const appliedCount = applied.length;
/*
* Each select is built here once and drawn twice: as a pill in the desktop
* row or panel, and as a full-width labelled field in the phone sheet. Only
* one copy is ever showing; the sheet's is mounted only while it is open.
* The sheet's stay enabled while a change lands: disabling the control
* under the user's focus would drop focus to <body>, out of the dialog.
*/
type Look = "pill" | "panel" | "sheet";
const selectClass = (look: Look, value: string) =>
`${look === "pill" ? styles.controlSelect : styles.filterSelect}${look === "sheet" ? ` ${styles.sheetSelect}` : ""}${activeIf(value)}`;
const phaseSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={selectClass(look, currentPhase)}
aria-label="Phase"
disabled={isPending && look !== "sheet"}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const typeSelected = selectedOf(typeOptions, currentType);
const faithSelected = selectedOf(faithOptions, currentFaith);
const typeSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={typeSelected.value}
onChange={(e) => handleFilterChange("school_type", e.target.value)}
className={selectClass(look, currentType)}
aria-label="School type"
disabled={isPending && look !== "sheet"}
>
<option value="">Any school type</option>
{typeSelected.unlisted && (
<option value={typeSelected.unlisted}>{typeSelected.unlisted}</option>
)}
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectShell>
);
const laSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentLA}
onChange={(e) => handleFilterChange("local_authority", e.target.value)}
className={selectClass(look, currentLA)}
aria-label="Local authority"
disabled={isPending && look !== "sheet"}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
);
const faithSelect = (look: Look) => (
<SelectShell wide>
<select
value={faithSelected.value}
onChange={(e) => handleFilterChange("faith", e.target.value)}
className={selectClass(look, currentFaith)}
aria-label="Faith"
disabled={isPending && look !== "sheet"}
>
<option value="">Any faith or none</option>
{faithSelected.unlisted && (
<option value={faithSelected.unlisted}>{faithSelected.unlisted}</option>
)}
{faithOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectShell>
);
const genderSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) => handleFilterChange("gender", e.target.value)}
className={selectClass(look, currentGender)}
aria-label="Gender"
disabled={isPending && look !== "sheet"}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
);
const sixthFormSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange("has_sixth_form", e.target.value)}
className={selectClass(look, currentHasSixthForm)}
aria-label="Sixth form"
disabled={isPending && look !== "sheet"}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
);
const admissionsSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange("admissions_policy", e.target.value)}
className={selectClass(look, currentAdmissionsPolicy)}
aria-label="Admissions"
disabled={isPending && look !== "sheet"}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const sheetTrigger = (
<button
type="button"
className={`${styles.sheetTrigger}${appliedCount ? ` ${styles.sheetTriggerActive}` : ""}`}
onClick={() => setSheetOpen(true)}
aria-haspopup="dialog"
aria-label={appliedCount ? `Filters, ${appliedCount} applied` : "Filters"}
>
<SlidersIcon />
Filters
{appliedCount > 0 && (
<span className={styles.sheetTriggerCount} aria-hidden="true">
{appliedCount}
</span>
)}
</button>
);
// Beside the folded search summary; on the chips line when there is no
// summary to sit beside. While the search is being edited it steps aside,
// and comes back when the form folds.
const triggerBesideSummary = canFold && !searchOpen;
const triggerOnChipsLine = !canFold;
return (
<div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
{canFold && !searchOpen && (
{triggerBesideSummary && (
<div className={styles.summaryRow}>
<button
type="button"
className={styles.searchSummary}
@@ -338,10 +663,14 @@ export function FilterBar({
</svg>
<span className={styles.summaryText}>
<strong>{currentQuery}</strong>
{currentPostcode && <span> · within {radiusLabel}</span>}
{/* Short, so the distance survives beside the Filters button on a
360px screen; the button's name carries the long form. */}
{currentPostcode && <span> · {currentRadius} mi</span>}
</span>
<span className={styles.summaryEdit} aria-hidden="true">Edit</span>
</button>
{sheetTrigger}
</div>
)}
<form
onSubmit={handleSearchSubmit}
@@ -452,16 +781,13 @@ export function FilterBar({
{!isHero && (
<>
{/* Every control here is a real <select> or <button>, drawn as a
pill. On phones the row scrolls sideways rather than wrapping, so
the pinned toolbar stays two lines tall. */}
<div
ref={controlsRowRef}
className={`${styles.controlsRow}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
role="group"
aria-label="Filters"
onScroll={updateControlsAtEnd}
>
{viewSwitch && (
<div className={styles.viewSwitchSlot}>{viewSwitch}</div>
)}
{/* Desktop and tablet. Every control here is a real <select> or
<button>, drawn as a pill. Phones hide the row and use the
Filters button and sheet instead. */}
<div className={styles.controlsRow} role="group" aria-label="Filters">
{currentPostcode && (
<SelectShell>
<select
@@ -471,51 +797,18 @@ export function FilterBar({
aria-label="Distance"
disabled={isPending}
>
<option value="0.5">Within 0.5 miles</option>
<option value="1">Within 1 mile</option>
<option value="3">Within 3 miles</option>
<option value="5">Within 5 miles</option>
</select>
</SelectShell>
)}
{phaseOptions.length > 0 && (
<SelectShell>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
aria-label="Phase"
disabled={isPending}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
{RADIUS_OPTIONS.map((r) => (
<option key={r} value={r}>
Within {milesLabel(r)}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.controlSelect}${activeIf(currentType)}`}
aria-label="School type"
disabled={isPending}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
{phaseOptions.length > 0 && phaseSelect("pill")}
{typeOptions.length > 0 && typeSelect("pill")}
<button
type="button"
@@ -523,21 +816,7 @@ export function FilterBar({
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
<SlidersIcon />
More filters
{hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
@@ -547,99 +826,113 @@ export function FilterBar({
/>
</button>
{hasActiveFilters && (
{appliedCount > 0 && (
<button
onClick={handleClearFilters}
onClick={handleClearFilterValues}
className={`btn btn-tertiary ${styles.clearButton}`}
type="button"
disabled={isPending}
>
Clear
Clear all
</button>
)}
</div>
{filtersOpen && (
<div className={styles.filters}>
<SelectShell wide>
<select
value={currentLA}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentLA)}`}
aria-label="Local authority"
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
{laSelect("panel")}
{faithOptions.length > 0 && faithSelect("panel")}
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentGender)}`}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
{admissionsPolicyOptions.length > 0 && (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
)}
{genderOptions.length > 0 && genderSelect("panel")}
{sixthFormSelect("panel")}
{admissionsPolicyOptions.length > 0 && admissionsSelect("panel")}
</>
)}
</div>
)}
{/* Phones: what is applied, each removable with a tap. The line
scrolls sideways if it overflows, under the same right-edge fade
as the school page's section nav. */}
{(appliedCount > 0 || triggerOnChipsLine) && (
<div
ref={controlsRowRef}
className={`${styles.chipsLine}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
onScroll={updateControlsAtEnd}
>
{triggerOnChipsLine && sheetTrigger}
{appliedCount > 0 && (
<div className={styles.chips} role="group" aria-label="Applied filters">
{applied.map(({ key, label }) => (
<button
key={key}
type="button"
className={styles.chip}
onClick={() => handleFilterChange(key, "")}
aria-label={`Remove filter: ${label}`}
disabled={isPending}
>
<span className={styles.chipLabel}>{label}</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
))}
<button
type="button"
className={`btn btn-tertiary ${styles.chipsClear}`}
onClick={handleClearFilterValues}
disabled={isPending}
>
Clear all
</button>
</div>
)}
</div>
)}
<FilterSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
radius={currentPostcode ? currentRadius : null}
onRadiusChange={(r) => updateURL({ radius: r })}
onClearAll={appliedCount > 0 ? handleClearFilterValues : undefined}
resultCount={resultCount}
isPending={isPending}
>
{phaseOptions.length > 0 && (
<SheetField label="Phase">{phaseSelect("sheet")}</SheetField>
)}
{typeOptions.length > 0 && (
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
)}
{faithOptions.length > 0 && (
<SheetField label="Faith">{faithSelect("sheet")}</SheetField>
)}
<SheetField label="Local authority">{laSelect("sheet")}</SheetField>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SheetField label="Gender">{genderSelect("sheet")}</SheetField>
)}
<SheetField label="Sixth form">{sixthFormSelect("sheet")}</SheetField>
{admissionsPolicyOptions.length > 0 && (
<SheetField label="Admissions">{admissionsSelect("sheet")}</SheetField>
)}
</>
)}
</FilterSheet>
</>
)}
</div>
@@ -0,0 +1,92 @@
/* ── Phone filter sheet ───────────────────────────────────────────── */
.body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.fieldLabel {
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
}
/* Distance: five joined segments, a full-width row. At 360px each is about
65px wide, clear of the 44px minimum. */
.segments {
display: flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
}
.segment {
position: relative;
flex: 1 1 0;
display: flex;
}
.segment + .segment {
border-left: 1px solid var(--border);
}
/* The real radio stays in the tree for keyboard and screen readers; the span
is what is drawn. */
.segment input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.segment span {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-primary);
background: var(--bg-card);
white-space: nowrap;
transition: background-color var(--transition), color var(--transition);
}
.segment input:checked + span {
background: rgba(var(--sage-rgb), 0.38);
color: var(--brand-strong);
font-weight: 700;
}
.segment input:focus-visible + span {
box-shadow: inset 0 0 0 2px var(--brand);
}
.footer {
display: flex;
align-items: center;
gap: 0.75rem;
}
.clearAll {
min-height: 2.75rem;
padding: 0 1rem;
}
.show {
flex: 1;
min-height: 2.75rem;
justify-content: center;
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useId } from "react";
import type { ReactNode } from "react";
import { Modal } from "./Modal";
import styles from "./FilterSheet.module.css";
export const RADIUS_OPTIONS = ["0.25", "0.5", "1", "3", "5"] as const;
export const radiusLabel = (r: string) => `${r} mile${r === "1" ? "" : "s"}`;
interface FilterSheetProps {
open: boolean;
onClose: () => void;
/** The current distance, or null when the search is not by postcode. */
radius: string | null;
onRadiusChange: (radius: string) => void;
/** The filter selects, each already labelled; FilterBar owns their state. */
children: ReactNode;
/** Shown only when something is applied. */
onClearAll?: () => void;
resultCount?: number;
isPending: boolean;
}
/*
* Every filter in one place, for phones. A change applies at once, as it does
* in the desktop toolbar, so the results behind the sheet are already the
* filtered ones and the footer button only has to close it.
*/
export function FilterSheet({
open,
onClose,
radius,
onRadiusChange,
children,
onClearAll,
resultCount,
isPending,
}: FilterSheetProps) {
const distanceId = useId();
const showLabel = isPending
? "Updating…"
: resultCount === undefined
? "Show schools"
: resultCount === 0
? "No schools match"
: `Show ${resultCount.toLocaleString()} school${resultCount === 1 ? "" : "s"}`;
return (
<Modal
isOpen={open}
onClose={onClose}
title="Filters"
size="small"
footer={
<div className={styles.footer}>
{onClearAll && (
<button
type="button"
className={`btn btn-tertiary ${styles.clearAll}`}
onClick={onClearAll}
>
Clear all
</button>
)}
<button
type="button"
className={`btn btn-action ${styles.show}`}
onClick={onClose}
>
{showLabel}
</button>
</div>
}
>
{/* Controls stay enabled while a change lands, so focus is never
dropped out of the dialog; aria-busy says the results are updating. */}
<div className={styles.body} aria-busy={isPending}>
{radius !== null && (
<div className={styles.field}>
<span id={distanceId} className={styles.fieldLabel}>
Distance
</span>
<div
className={styles.segments}
role="radiogroup"
aria-labelledby={distanceId}
>
{RADIUS_OPTIONS.map((r) => (
<label key={r} className={styles.segment}>
<input
type="radio"
name={`${distanceId}-radius`}
value={r}
checked={r === radius}
onChange={() => onRadiusChange(r)}
aria-label={`Within ${radiusLabel(r)}`}
/>
<span>{r} mi</span>
</label>
))}
</div>
</div>
)}
{children}
</div>
</Modal>
);
}
/** One labelled row of the sheet. */
export function SheetField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
{children}
</label>
);
}
-31
View File
@@ -1533,37 +1533,6 @@
cursor: pointer;
}
.activeFilters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filterChip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.chipRemove {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
line-height: 1;
transition: color var(--transition, 0.2s ease);
}
.chipRemove:hover {
color: var(--text-primary);
}
.loadMoreSection {
display: flex;
flex-direction: column;
+36 -36
View File
@@ -17,6 +17,7 @@ import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { track } from '@/lib/analytics';
import { DEFAULT_RADIUS_MILES } from '@/lib/utils';
import styles from './HomeView.module.css';
interface HomeViewProps {
@@ -340,6 +341,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
setIsLoadingMap(true);
const params: Record<string, any> = {};
searchParams.forEach((value, key) => { params[key] = value; });
params.radius ??= DEFAULT_RADIUS_MILES;
params.page = 1;
params.page_size = 500;
fetchSchools(params, { cache: 'no-store', signal: controller.signal })
@@ -356,10 +358,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
return () => controller.abort();
}, [resultsView, searchParams, initialSchools.schools]);
// Fetch LA averages when secondary or mixed schools are visible
// Fetch LA averages when secondary or mixed schools are visible. Default
// cache mode, never force-cache: force-cache replays any stored response
// without asking the server, so one failed request (a deploy restart) hid
// every "vs LA avg" delta in that browser for good. The API's Cache-Control
// already lets the browser reuse a good answer for five minutes.
useEffect(() => {
if (!isSecondaryView && !isMixedView) return;
fetchLAaverages({ cache: 'force-cache' })
fetchLAaverages()
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
.catch(() => {});
}, [isSecondaryView, isMixedView]);
@@ -419,7 +425,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
setGeoState('idle');
track('near_me_used', { outcome: 'granted' });
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=${DEFAULT_RADIUS_MILES}`);
} else {
track('near_me_used', { outcome: 'no_postcode' });
setGeoState('error');
@@ -459,8 +465,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
// open card); only the list beside it is sorted.
const mapResultSchools = mapSchools.length > 0 ? mapSchools : initialSchools.schools;
const mapListSchools = sortSchools(mapResultSchools);
// Two places, so a 0.25-mile search is not drawn or labelled as 0.3.
const radiusMiles = initialSchools.location_info
? Number((initialSchools.location_info.radius / 1.60934).toFixed(1))
? Number((initialSchools.location_info.radius / 1.60934).toFixed(2))
: undefined;
// Empty-results sentinel: track when a search returns nothing.
@@ -594,8 +601,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
drops the postcode, which the search box just above already shows. */}
{isMapView && radiusMiles != null
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'}`
: isLocationSearch && initialSchools.location_info
? `${schoolCount} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
: isLocationSearch && radiusMiles != null && initialSchools.location_info
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'} of ${initialSchools.location_info.postcode}`
: `${schoolCount} found`
}
</h2>
@@ -709,33 +716,34 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
resultCount={initialSchools.total}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
viewSwitch={hasViewSwitch && (
<div className={styles.viewSwitch} role="group" aria-label="Results view">
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'list'}
onClick={() => changeView('list', 'toolbar')}
>
<ListIcon />
List
</button>
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'map'}
onClick={() => changeView('map', 'toolbar')}
>
<MapIcon />
Map
</button>
</div>
)}
/>
{hasViewSwitch && (
<div className={styles.viewSwitch} role="group" aria-label="Results view">
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'list'}
onClick={() => changeView('list', 'toolbar')}
>
<ListIcon />
List
</button>
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'map'}
onClick={() => changeView('map', 'toolbar')}
>
<MapIcon />
Map
</button>
</div>
)}
</div>
)}
@@ -803,14 +811,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
{hasSearch && !isMapView && resultsHeader}
{isSearchActive && !isMapView && (
<div className={styles.activeFilters}>
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
</div>
)}
{initialSchools.schools.length === 0 && isSearchActive ? (
<EmptyState
title="No schools found"
+19 -1
View File
@@ -5,7 +5,9 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* Above the pinned results toolbar (1001), the floating List/Map button
(1002) and the comparison toast (2000): a modal covers the page. */
z-index: 2100;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
@@ -31,6 +33,10 @@
border: 1px solid var(--border);
}
.modal:focus {
outline: none;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
@@ -73,7 +79,11 @@
font-family: var(--font-display);
}
/* 44px, the MOBILE.md touch-target minimum: the 24px icon and its padding
came to 40, and the phone filter sheet journey caught it. */
.closeButton {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
background: transparent;
border: none;
@@ -96,6 +106,14 @@
flex: 1;
}
/* Clears the iPhone home indicator when the modal is a bottom sheet. */
.footer {
flex-shrink: 0;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--border);
background: var(--bg-card);
}
/* Scrollbar styles */
.content::-webkit-scrollbar {
width: 8px;
+27 -4
View File
@@ -5,7 +5,7 @@
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useCallback, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
@@ -15,10 +15,14 @@ interface ModalProps {
children: React.ReactNode;
title?: string;
size?: 'small' | 'medium' | 'large';
/** Pinned below the scrolling content, so its actions stay in reach. */
footer?: React.ReactNode;
}
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -41,6 +45,17 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
};
}, [isOpen, handleEscape]);
// Focus moves into the dialog when it opens and back to whatever opened it
// when it closes. A child that has already taken focus (an autoFocus input)
// keeps it: children's effects run before this one.
useEffect(() => {
if (!isOpen) return;
const opener = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
return () => opener?.focus?.();
}, [isOpen]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
@@ -77,9 +92,16 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
return createPortal(
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}>
<div
ref={dialogRef}
className={`${styles.modal} ${styles[size]}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>}
{title && <h2 id={titleId} className={styles.title}>{title}</h2>}
<button
className={styles.closeButton}
onClick={onClose}
@@ -103,6 +125,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
<div className={styles.content}>
{children}
</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>,
document.body
@@ -100,6 +100,19 @@
color: var(--text-secondary);
}
/* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
a verdict. An inset ring keeps the box the size of its neighbours. */
.conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
/* Line 3: stats */
.line3 {
display: flex;
+13 -8
View File
@@ -3,13 +3,14 @@
* Four-line row for primary school search results
*
* Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender
* Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -33,10 +34,8 @@ export function SchoolRow({
const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination =
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
const typeLabel = schoolTypeLabel(school);
const flags = schoolFlags(school);
// The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value (not the all-zero
@@ -79,10 +78,16 @@ export function SchoolRow({
{phase.label}
</span>
)}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>}
{typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{flags.map((flag) => (
<span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.attr}
>
{flag.label}
</span>
))}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
)}
@@ -183,9 +183,17 @@
white-space: nowrap;
}
.selectiveTag {
background: rgba(var(--status-below-rgb), 0.1);
color: var(--status-below);
/* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
a verdict. An inset ring keeps the box the size of its neighbours. */
.conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
/* ── Ofsted badge ────────────────────────────────────── */
+12 -28
View File
@@ -3,7 +3,7 @@
* Four-line row for secondary school search results
*
* Line 1: School name · Ofsted badge
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag
* Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
* Line 4: LA name · distance
*/
@@ -12,21 +12,9 @@
import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
const policy = school.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = school.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
}
function hasSixthForm(school: School): boolean {
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
interface SecondarySchoolRowProps {
school: School;
isLocationSearch?: boolean;
@@ -64,9 +52,8 @@ export function SecondarySchoolRow({
? att8 - laAvgAttainment8
: null;
const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const typeLabel = schoolTypeLabel(school);
const flags = schoolFlags(school);
return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
@@ -90,19 +77,16 @@ export function SecondarySchoolRow({
{phase.label}
</span>
)}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>}
{typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showGender && (
<span className={styles.provisionTag}>{school.gender}</span>
)}
{sixthForm && (
<span className={styles.provisionTag}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
{flags.map((flag) => (
<span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.provisionTag}
>
{flag.label}
</span>
)}
))}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
)}
@@ -78,21 +78,18 @@ export function CompareAtAGlance({
return (
<Cell key={school.urn} school={school} index={i}>
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
{(display.kind === 'graded' || display.kind === 'confirmed') && (
<>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
{display.kind === 'confirmed' && <span className={s.small}>Confirmed at an ungraded inspection</span>}
</>
)}
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
{display.kind === 'no_overall_grade' && (
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell>
+14 -22
View File
@@ -1,7 +1,8 @@
/**
* Ofsted section — one visual grammar for inspection detail across all
* three regimes (legacy graded, interim carried-forward, renewed-framework
* report card). Copy comes verbatim from the reviewed mockups.
* three regimes (legacy graded, no overall grade, renewed-framework report
* card). The grade shown is the one still in force, with the inspection that
* awarded or confirmed it; "Inspected" is the latest visit.
*/
'use client';
@@ -12,7 +13,8 @@ import {
rcAreaLabel,
type OfstedDisplay,
} from '@/lib/compareLogic';
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
import { gradeSourceLine } from '@/lib/ofstedStatus';
import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
@@ -39,7 +41,7 @@ function yearsSince(iso: string | null): number | null {
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
}
function ResultCell({ display }: { display: OfstedDisplay }) {
function ResultCell({ display, current }: { display: OfstedDisplay; current: OfstedCurrentGrade | null }) {
if (display.kind === 'none') {
return <span className={s.small}>No inspection outcome in our dataset</span>;
}
@@ -51,15 +53,13 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
</>
);
}
if (display.kind === 'transitional') {
if (display.kind === 'no_overall_grade') {
return (
<>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>
Inspected under transitional framework (sub-judgements only)
</span>
<span className={s.small}>Ofsted stopped giving overall grades in September 2024</span>
</>
);
}
@@ -68,11 +68,7 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
<span className={s.small}>
{display.carriedForward
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
: 'Overall grade (older-style inspection)'}
</span>
{current && <span className={s.small}>{gradeSourceLine(current)}</span>}
</>
);
}
@@ -179,7 +175,7 @@ export function CompareOfsted({
<Measure label="Result">
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
<ResultCell display={displays[i]} />
<ResultCell display={displays[i]} current={data[String(school.urn)]?.ofsted?.current_grade ?? null} />
</Cell>
))}
</Measure>
@@ -187,19 +183,15 @@ export function CompareOfsted({
<Measure label="Inspected">
{schools.map((school, i) => {
const ofsted = data[String(school.urn)]?.ofsted;
// A report card is dated by its OWN inspection date. The legacy
// inspection_date belongs to an older inspection and must never
// be shown against a report card (report cards exist only from
// Nov 2025).
const dateIso =
displays[i].kind === 'report_card'
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// The school's latest visit of any kind (a report card's own date
// when there is one), never an older inspection a grade comes from.
const dateIso = ofsted?.latest_visit?.date ?? ofsted?.rc_inspection_date ?? null;
const age = yearsSince(dateIso);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(dateIso)}{' '}
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
{ofsted?.latest_visit?.outcome && <span className={s.small}>{ofsted.latest_visit.outcome}</span>}
</Cell>
);
})}
+5 -4
View File
@@ -13,7 +13,7 @@ import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types';
import { schoolUrl, formatAgeSpan } from '@/lib/utils';
import { schoolUrl, formatAgeSpan, hasNurseryClasses } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site';
import { TrackPlaceView } from './TrackPlaceView';
import styles from './PlaceView.module.css';
@@ -131,9 +131,10 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
{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}
"No Nursery Classes" or "Not applicable" is a school
without one. None is a "Yes", and none is worth a
different word. */}
{hasNurseryClasses(s.nursery_provision) ? 'Yes' : NO_VALUE}
</td>
)}
<td className={styles.attrWide}>
@@ -1,8 +1,7 @@
.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; }
.lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; }
.caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.arrows { display: flex; gap: 0.5rem; flex: none; }
.arrow { width: 44px; height: 44px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); }
.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); }
@@ -15,10 +14,10 @@
.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; }
.scroller::-webkit-scrollbar { display: none; }
@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } }
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and
crush the lede into four lines, for a control swiping already provides. They
go, and the documented right-edge fade carries the affordance — lifting at
the end of the travel, where there is nothing more to hint at. */
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card, for
a control swiping already provides. They go, and the documented right-edge
fade carries the affordance — lifting at the end of the travel, where there
is nothing more to hint at. */
@media (max-width: 640px) {
.top { display: block; }
.arrows { display: none; }
@@ -4,7 +4,7 @@
* The scroller and its arrows.
*
* `children` are the server-rendered cards and `header` the server-rendered
* heading and lede: both stay server components, passed through, so this file
* heading: both stay server components, passed through, so this file
* owns a DOM ref and nothing else. That is what keeps all six links in the
* initial HTML — a carousel that mounted cards on click would put four of the
* six beyond a crawler and beyond a reader with no JavaScript.
@@ -13,8 +13,10 @@
* reached on their behalf.
*
* There is deliberately no "how these are chosen" panel: the method is already
* visible in the lede, the chips and the distances. The single caption line is
* not a method note — it is the one thing a card cannot self-correct.
* visible in the chips and the distances. The single caption line is not a
* method note — it is the one thing a card cannot self-correct.
*
* Nor is there a lede: "Other primary schools near X" only restated the heading.
*/
import Link from 'next/link';
@@ -32,28 +34,6 @@ export function shouldRenderNearby(nearby?: NearbySchool[] | null): boolean {
return (nearby?.length ?? 0) >= MINIMUM;
}
/**
* What the lede calls the set of schools it is showing.
*
* Derived from the school's own GIAS phase rather than the template it renders
* with, because those disagree for "16 plus" (GIAS phase 6): a sixth-form
* college renders the primary template — computeSchoolFlags tests for the
* substring "secondary" — while the backend correctly matches it against the
* secondary group. Taking the noun from the template would print "Other primary
* schools near <sixth form college>" above a row of secondaries.
*
* A 16-plus school's candidates span the whole secondary group, so no single
* noun fits and it gets the honest general one.
*/
export function nearbyNoun(phase: string | null | undefined): string {
const text = (phase ?? '').trim().toLowerCase();
if (text === 'all-through') return 'all-through schools';
if (text === '16 plus') return 'schools and colleges';
if (text.includes('secondary')) return 'secondary schools';
if (text.includes('primary')) return 'primary schools';
return 'schools';
}
function metricLabel(key: string): string {
return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
}
@@ -65,25 +45,16 @@ function formatMetric(value: number | null, key: string): string {
export function NearbySchoolsSection({
urn,
schoolName,
phase,
thisMetricValue,
nearby,
}: {
urn: number;
schoolName: string;
/** The school's own GIAS phase, not the template it renders with. */
phase: string | null | undefined;
thisMetricValue: number | null;
nearby?: NearbySchool[] | null;
}) {
if (!shouldRenderNearby(nearby)) return null;
const schools = nearby as NearbySchool[];
// One card matched on phase alone, so the section may not claim the set
// shares an intake with this school.
const metricKey = schools[0].metric_key;
const noun = nearbyNoun(phase);
return (
<Section id="nearby">
@@ -91,12 +62,9 @@ export function NearbySchoolsSection({
count={schools.length}
labelledBy="nearby-schools-heading"
header={
<div>
<h2 id="nearby-schools-heading" className={styles.heading}>
Other schools nearby
</h2>
<p className={styles.lede}>{`Other ${noun} near ${schoolName}.`}</p>
</div>
<h2 id="nearby-schools-heading" className={styles.heading}>
Other schools nearby
</h2>
}
>
{schools.map((school) => (
+52 -61
View File
@@ -1,16 +1,18 @@
/**
* OfstedSection — shared between the primary and secondary detail pages.
*
* The two versions were ~80% identical, but that figure masked a real fork in
* the no-overall-grade case: the primary page shows a "Not rated" badge, while
* the secondary page shows a four-area OEIF panel. The disclaimer copy also
* differs slightly. Both are preserved exactly via the `variant` prop rather
* than reconciled, because this refactor must not change either page. Merging
* them is a follow-up decision for a human, not a side effect of a move.
* The headline is the school's current Ofsted status (backend:
* fact_ofsted_latest): the report card; or the overall grade still in force,
* with the inspection that awarded or confirmed it; or "No overall grade".
* A later visit that is not the grade's source gets its own line. Both pages
* render the same branches, so neither drops an area judgement (audit M2).
* Rule: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
*
* Server component.
*/
import { ofstedDisplay } from '@/lib/compareLogic';
import { formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
@@ -40,14 +42,13 @@ export interface OfstedSectionProps {
ofstedInspectedDate: string | null;
oeifAllSameGrade: boolean;
oeifAreas: { label: string; value: number }[];
variant?: 'primary' | 'secondary';
}
export function OfstedSection({
ofsted, urn, isReportCard, ofstedInspectedDate,
oeifAllSameGrade, oeifAreas, variant = 'primary',
oeifAllSameGrade, oeifAreas,
}: OfstedSectionProps) {
const isSecondary = variant === 'secondary';
const display = ofstedDisplay(ofsted);
return (
<Section id="ofsted">
@@ -55,7 +56,7 @@ export function OfstedSection({
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && (
<span className={styles.ofstedDate}>
{isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
@@ -98,65 +99,55 @@ export function OfstedSection({
})}
</div>
</>
) : (!isSecondary || ofsted.overall_effectiveness) ? (
/* ── Old OEIF layout ── */
) : (
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
{display.kind === 'graded' || display.kind === 'confirmed' ? (
<>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${display.grade}`]}`}>
{display.gradeLabel}
</span>
{ofsted.previous_overall != null && ofsted.previous_overall !== display.grade && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</>
) : (
<span className={styles.ofstedGrade}>No overall grade</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection. Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: isSecondary
? 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
{ofsted.current_grade
? gradeSourceLine(ofsted.current_grade)
: 'Ofsted stopped giving overall grades in September 2024.'}
</p>
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas: Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
{showLatestVisitLine(ofsted) && ofsted.latest_visit && (
<p className={styles.ofstedDisclaimer}>Latest visit: {latestVisitLine(ofsted.latest_visit)}</p>
)}
</>
) : (
/* ── Secondary only: inspected since Sept 2024, no overall grade ── */
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
{oeifAllSameGrade && (display.kind === 'graded' || display.kind === 'confirmed') ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{display.gradeLabel}</strong> across all inspected areas: Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : oeifAreas.length > 0 ? (
<>
{ofsted.inspection_date && ofsted.inspection_date !== ofsted.latest_visit?.date && (
<p className={styles.ofstedDisclaimer}>
Area judgements from the graded inspection, {formatOfstedDate(ofsted.inspection_date)}.
</p>
)}
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
))}
</div>
</>
) : null}
</>
)}
</Section>
@@ -55,15 +55,17 @@ export function PrimarySchoolSections({
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
// Report cards are dated by their own inspection (rc_inspection_date);
// anything else by the school's latest visit, never the older inspection a
// grade may come from.
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
: ofsted?.latest_visit?.date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
ofsted.overall_effectiveness != null &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
@@ -77,7 +79,6 @@ export function PrimarySchoolSections({
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="primary"
/>
)}
@@ -154,8 +155,6 @@ export function PrimarySchoolSections({
{/* Last: it is where the reader goes next, not part of this school. */}
<NearbySchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phase={schoolInfo.phase}
thisMetricValue={flags.latestResults?.rwm_expected_pct ?? null}
nearby={nearbySchools}
/>
@@ -145,20 +145,90 @@
}
.meta {
/* Fact line: the phase pill, then plain register values joined by dots.
The line starts 1.125rem left of the column and clips that strip, so a
value that wraps to the start of a line loses the dot in front of it
instead of opening the line with one. */
.facts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
row-gap: 0.25rem;
margin: 0 0 0.5rem -1.125rem;
clip-path: inset(0 0 0 1.125rem);
font-size: 0.875rem;
color: var(--text-secondary);
}
.metaItem {
font-size: 0.8125rem;
.facts > * {
margin-left: 1.125rem;
}
.fact {
position: relative;
}
.fact::before {
content: "·";
position: absolute;
left: -0.75rem;
color: var(--text-muted);
}
/* The search rows' phase pill, in the same phase colours. */
.phasePill {
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
}
.phasePillPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.phasePillSecondary { background: var(--phase-secondary-bg); color: var(--phase-secondary-text); }
.phasePillAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
.phasePillPost16 { background: var(--phase-post16-bg); color: var(--phase-post16-text); }
.phasePillNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
/* Flags are facts, not verdicts, so they carry no hue. Outlined changes who
can apply or what it costs; filled is what the school offers. Text may
wrap: the longest religious character runs past a 360px line. */
.flags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0 0 0.625rem;
padding: 0;
list-style: none;
}
.flagCondition,
.flagProvision {
padding: 0.0625rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border: 1px solid transparent;
border-radius: 4px;
}
.flagCondition {
border-color: rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
.flagProvision {
background: rgba(var(--ink-rgb), 0.07);
color: var(--text-secondary);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary);
border-radius: 3px;
}
@@ -703,17 +773,8 @@
word-break: break-word;
}
/* Pills wrap horizontally instead of stacking — short tokens like
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
rows of empty horizontal space. */
.meta {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Secondary header info (headteacher, website, pupil count, trust,
contact, area) isn't needed above the fold on phones/tablets, so it's
/* Secondary header info (headteacher, website, phone, trust, capacity)
isn't needed above the fold on phones/tablets, so it's
collapsed by default and revealed on demand via the "Show all details"
link — reclaiming the vertical space so the metrics surface sooner. */
.detailsToggle {
@@ -20,8 +20,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
import type { School, SchoolCensus } from '@/lib/types';
import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
@@ -34,8 +35,6 @@ import styles from './SchoolDetailShell.module.css';
*/
export interface SchoolDetailShellProps {
schoolInfo: School;
/** Only for the header's pupil-count fallback. */
yearlyData: SchoolResult[];
census: SchoolCensus | null;
/** Section list for the sticky nav, computed on the server. */
navItems: NavItem[];
@@ -43,8 +42,14 @@ export interface SchoolDetailShellProps {
children: ReactNode;
}
/** Equal ignoring case and punctuation: "TIFFIN SCHOOL" is Tiffin School. */
function sameName(a: string, b: string): boolean {
const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
return key(a) === key(b);
}
export function SchoolDetailShell({
schoolInfo, yearlyData, census, navItems, children,
schoolInfo, census, navItems, children,
}: SchoolDetailShellProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
@@ -63,7 +68,7 @@ export function SchoolDetailShell({
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// Header details (headteacher, contact, trust, capacity) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -122,15 +127,34 @@ export function SchoolDetailShell({
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
// The chrome needs only these four. The section-shape flags are computed
// The chrome needs only these few. The section-shape flags are computed
// once on the server (lib/schoolSections) and consumed by the section
// composers; recomputing them here would duplicate that work for values
// this component never renders.
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
// Header facts: each is one register value in a fixed slot (lib/schoolFacts).
const phasePill = getPhaseStyle(schoolInfo.phase);
// Never latestResults.total_pupils: that is the results cohort, which for a
// secondary is the GCSE year group alone.
const pupils = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
const facts = [
formatAgeRange(schoolInfo.age_range),
schoolTypeLabel(schoolInfo),
pupils != null ? `${pupils.toLocaleString()} pupils` : null,
].filter((fact): fact is string => !!fact);
const flags = schoolFlags(schoolInfo);
// The council, unless the address already names it.
const council = schoolInfo.local_authority
&& !(schoolInfo.address ?? '').toLowerCase().includes(schoolInfo.local_authority.toLowerCase())
? schoolInfo.local_authority
: null;
// A single-academy trust carries the school's own name, which says nothing.
const trust = schoolInfo.trust_name && !sameName(schoolInfo.trust_name, schoolInfo.school_name)
? schoolInfo.trust_name
: null;
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
@@ -204,29 +228,31 @@ export function SchoolDetailShell({
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{(phasePill.label || facts.length > 0) && (
<div className={styles.facts}>
{phasePill.label && (
<span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
{phasePill.label}
</span>
)}
{facts.map((fact) => (
<span key={fact} className={styles.fact}>{fact}</span>
))}
</div>
)}
{flags.length > 0 && (
// role="list": list-style: none drops list semantics in Safari.
<ul role="list" className={styles.flags} aria-label="Admission and provision">
{flags.map((flag) => (
<li
key={flag.label}
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
>
{flag.label}
</li>
))}
</ul>
)}
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong>⚠ Proposed to close.</strong> Check with the local authority before
@@ -236,6 +262,7 @@ export function SchoolDetailShell({
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{council && ` · ${council}`}
{hasLocation && (
<>
{' · '}
@@ -282,21 +309,6 @@ export function SchoolDetailShell({
</a>
</span>
)}
{(() => {
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
@@ -305,22 +317,14 @@ export function SchoolDetailShell({
</a>
</span>
)}
{schoolInfo.religious_denomination && (
{trust && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
<strong>Academy trust:</strong> {trust}
</span>
)}
{schoolInfo.county && (
{schoolInfo.capacity != null && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
<strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
</span>
)}
</div>
@@ -7,7 +7,7 @@
*/
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { formatPercentage, hasReligiousCharacter, isSelective } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import {
describeCutoff, describeCutoffAbsence,
@@ -33,10 +33,8 @@ export function SecondaryAdmissionsSection({
const featureOn = admissionDistance !== undefined;
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
if (isSelective(schoolInfo.admissions_policy)) return 'Selective';
if (hasReligiousCharacter(schoolInfo.religious_denomination)) return 'Faith priority';
return null;
})();
@@ -63,11 +63,12 @@ export function SecondarySchoolSections({
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
: ofsted?.latest_visit?.date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
ofsted.overall_effectiveness != null &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
@@ -81,7 +82,6 @@ export function SecondarySchoolSections({
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="secondary"
/>
)}
@@ -148,8 +148,6 @@ export function SecondarySchoolSections({
{/* Last: it is where the reader goes next, not part of this school. */}
<NearbySchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phase={schoolInfo.phase}
thisMetricValue={flags.latestResults?.attainment_8_score ?? null}
nearby={nearbySchools}
/>
@@ -54,7 +54,8 @@ export function WellbeingSection({
</div>
)}
{(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
// Not latestResults.total_pupils: that is the GCSE year group.
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
if (total == null) return null;
const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null;
@@ -16,7 +16,7 @@
*/
import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
import { formatCutoffDistance, formatMiles, formatEntryYear, isSelective } from '@/lib/utils';
export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */
@@ -193,8 +193,7 @@ export function describeCutoffAbsence({
admissionsPolicy,
admissionsHistory = [],
}: AbsenceInput): string {
const policy = (admissionsPolicy ?? '').toLowerCase();
if (policy.includes('selective')) {
if (isSelective(admissionsPolicy)) {
return 'Places at this school are ranked by the entrance test rather than by '
+ 'distance, so no cut-off distance applies.';
}
+14 -15
View File
@@ -100,9 +100,8 @@ export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary
export type OfstedDisplay =
| { kind: 'none' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'transitional' }
| { kind: 'graded' | 'confirmed'; grade: number; gradeLabel: string; gradeDate: string | null }
| { kind: 'no_overall_grade' }
| { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay(
@@ -116,19 +115,19 @@ export function ofstedDisplay(
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
}
const grade = ofsted.overall_effectiveness;
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
if (grade == null || gradeLabel === undefined) {
if (ofsted.inspection_date) {
return { kind: 'transitional' };
}
return { kind: 'none' };
// The grade still in force (backend: fact_ofsted_latest), never one carried
// past a later inspection that gave none.
const current = ofsted.current_grade;
const gradeLabel = current ? OFSTED_LEGACY_GRADES[current.grade] : undefined;
if (current && gradeLabel !== undefined) {
return {
kind: current.basis === 'confirmed' ? 'confirmed' : 'graded',
grade: current.grade,
gradeLabel,
gradeDate: current.date,
};
}
if (ofsted.grade_source === 'ungraded_carried_forward') {
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
}
return { kind: 'graded', grade, gradeLabel, carriedForward: false };
return ofsted.latest_visit ? { kind: 'no_overall_grade' } : { kind: 'none' };
}
// ---------------------------------------------------------------------------
+48
View File
@@ -0,0 +1,48 @@
/**
* The sentences the school page and the compare page print about where an
* Ofsted grade came from and what the latest visit was. One wording, two pages.
* Rule: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
*/
import type { OfstedCurrentGrade, OfstedInspection, OfstedLatestVisit } from './types';
export function formatOfstedDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
}
const VISIT_KIND: Record<OfstedLatestVisit['kind'], string> = {
report_card: 'Report card inspection',
graded: 'Graded inspection',
ungraded: 'Ungraded inspection',
};
/** "Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection, 14 March 2023". */
export function gradeSourceLine(current: OfstedCurrentGrade): string {
const when = formatOfstedDate(current.date);
const what = current.basis === 'confirmed' ? 'Confirmed at an ungraded inspection' : 'Graded inspection';
return when ? `${what}, ${when}` : what;
}
/** "Ungraded inspection, 13 November 2024: Standards maintained". */
export function latestVisitLine(visit: OfstedLatestVisit): string {
const head = `${VISIT_KIND[visit.kind]}, ${formatOfstedDate(visit.date)}`;
return visit.outcome ? `${head}: ${visit.outcome}` : head;
}
/**
* Whether the latest visit needs its own line: when it is not where the grade
* came from, or when there is no grade but an ungraded outcome to report. A
* graded visit without a grade is already dated by the section title.
*/
export function showLatestVisitLine(
ofsted: Pick<OfstedInspection, 'current_grade' | 'latest_visit'>,
): boolean {
const visit = ofsted.latest_visit;
if (!visit || visit.kind === 'report_card') return false;
const current = ofsted.current_grade;
if (current) return current.date !== visit.date;
return visit.kind === 'ungraded';
}
+66
View File
@@ -0,0 +1,66 @@
/**
* The facts the school header and the search rows print, so a parent reads
* the same words in the list and on the page.
*
* Every value comes from one register field. Nothing is inferred or explained
* inline, and a missing field prints nothing.
*/
import type { School } from './types';
import { hasNurseryClasses, hasReligiousCharacter, isSelective, singleSexLabel } from './utils';
/** The search filter's type groups (backend/school_groups.py), without its
* parenthesised notes: fees have a flag of their own. */
const TYPE_GROUP_LABELS: Record<string, string> = {
state: 'State school',
independent: 'Independent school',
special: 'Special school (SEND)',
post16: 'Sixth form or college',
alternative: 'Alternative provision',
};
/** The school's type in the search filter's terms, or the register's own name
* for a type in no group (secure units, online providers). */
export function schoolTypeLabel(school: Pick<School, 'type_group' | 'school_type'>): string | null {
const grouped = school.type_group ? TYPE_GROUP_LABELS[school.type_group] : undefined;
return grouped ?? (school.school_type?.trim() || null);
}
/** "condition": changes who can apply or what it costs.
* "provision": what the school offers. */
export type SchoolFlagKind = 'condition' | 'provision';
export interface SchoolFlag {
label: string;
kind: SchoolFlagKind;
}
type FlagFields = Pick<
School,
'type_group' | 'admissions_policy' | 'gender' | 'religious_denomination'
| 'nursery_provision' | 'has_sixth_form' | 'phase'
>;
/**
* Flags for what applies, in a fixed order: conditions first, then provision.
*
* Selective needs the exact value. The register files a partly selective
* school as "Non-selective", so that value is never printed.
*/
export function schoolFlags(school: FlagFields): SchoolFlag[] {
const flags: SchoolFlag[] = [];
const condition = (label: string) => flags.push({ label, kind: 'condition' });
const provision = (label: string) => flags.push({ label, kind: 'provision' });
const phase = school.phase?.trim().toLowerCase();
if (school.type_group === 'independent') condition('Fee-paying');
if (isSelective(school.admissions_policy)) condition('Selective');
const singleSex = singleSexLabel(school.gender);
if (singleSex) condition(singleSex);
if (hasReligiousCharacter(school.religious_denomination)) {
condition(school.religious_denomination!.trim());
}
if (hasNurseryClasses(school.nursery_provision) && phase !== 'nursery') provision('Nursery class');
if (school.has_sixth_form && phase !== '16 plus') provision('Sixth form');
return flags;
}
+37 -4
View File
@@ -17,10 +17,13 @@ export interface School {
local_authority_code: number | null;
school_type: string | null;
school_type_code: string | null;
/** Search-filter type group ("state", "independent"…), null for a type in none. */
type_group?: string | null;
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
nursery_provision?: boolean | null;
/** GIAS text; read it through hasNurseryClasses(). */
nursery_provision?: string | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
@@ -73,10 +76,14 @@ export interface School {
parliamentary_constituency?: string | null;
// Ofsted (for list view — summary only)
/** The overall grade still in force; null when the latest inspection gave none. */
ofsted_grade?: 1 | 2 | 3 | 4 | null;
/** Date the grade was awarded or confirmed (null without a grade). */
ofsted_grade_date?: string | null;
/** Report-card inspection date (Nov 2025+); non-null identifies a report
* card in the list/map, where the full report_card object isn't available. */
ofsted_rc_date?: string | null;
/** The school's latest inspection of any kind. */
ofsted_date?: string | null;
ofsted_framework?: string | null;
}
@@ -92,6 +99,7 @@ export interface OfstedInspection {
rc_inspection_date?: string | null;
inspection_type: string | null;
// OEIF fields (old framework, pre-Nov 2025)
/** The graded inspection's own overall grade; never carried forward. */
overall_effectiveness: 1 | 2 | 3 | 4 | null;
quality_of_education: number | null;
behaviour_attitudes: number | null;
@@ -112,9 +120,12 @@ export interface OfstedInspection {
rc_leadership_governance: number | null;
rc_early_years: number | null;
rc_sixth_form: number | null;
/** Where the effective overall grade came from: a graded (Section 5)
* inspection, or carried forward from an ungraded (Section 8) outcome. */
grade_source?: 'graded' | 'ungraded_carried_forward' | null;
/** The overall grade still in force, dated by the inspection that awarded
* ("graded") or confirmed ("confirmed", an ungraded visit) it. Null when the
* latest inspection gave no overall grade, or for a report card. */
current_grade?: OfstedCurrentGrade | null;
/** The school's most recent inspection of any kind. */
latest_visit?: OfstedLatestVisit | null;
/** Renewed-framework (Nov 2025) area judgements, coded + labelled by the
* backend from the live-sampled Ofsted vocabulary. Empty when the school
* has no report-card inspection. Safeguarding is never included here. */
@@ -124,6 +135,18 @@ export interface OfstedInspection {
report_url?: string | null;
}
export interface OfstedCurrentGrade {
grade: 1 | 2 | 3 | 4;
date: string | null;
basis: 'graded' | 'confirmed';
}
export interface OfstedLatestVisit {
date: string;
kind: 'report_card' | 'graded' | 'ungraded';
outcome: string | null;
}
export interface ReportCardEntry {
code: number;
label: string;
@@ -484,6 +507,12 @@ export interface RankingsResponse {
local_authority?: string;
}
/** A filter option whose URL value differs from what a parent reads. */
export interface FilterOption {
value: string;
label: string;
}
export interface Filters {
local_authorities: string[];
school_types: string[];
@@ -491,6 +520,9 @@ export interface Filters {
phases: string[];
genders: string[];
admissions_policies: string[];
/** Parent-facing school type groups; the URL's school_type takes their value. */
school_type_groups?: FilterOption[];
faiths?: FilterOption[];
}
export interface NationalAveragesYear {
@@ -567,6 +599,7 @@ export interface SchoolSearchParams {
gender?: string;
admissions_policy?: string;
has_sixth_form?: string;
faith?: string;
}
export interface RankingsParams {
+58 -180
View File
@@ -2,7 +2,7 @@
* Utility functions for SchoolCompare
*/
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
import type { School, MetricDefinition } from './types';
// ============================================================================
// String Utilities
@@ -99,6 +99,26 @@ export function formatAgeRange(ageRange: string | null | undefined): string {
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
}
/**
* GIAS NurseryProvision is text: "Has Nursery Classes", "No Nursery Classes"
* or "Not applicable". Only the first means a nursery, so never test the raw
* value for truthiness.
*/
export function hasNurseryClasses(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === 'has nursery classes';
}
/**
* "Girls' school" / "Boys' school" for a single-sex school, null otherwise.
* GIAS genders are plural, so the possessive is a bare apostrophe.
*/
export function singleSexLabel(gender: string | null | undefined): string | null {
const g = gender?.trim().toLowerCase();
if (g === 'girls') return "Girls' school";
if (g === 'boys') return "Boys' school";
return null;
}
// ============================================================================
// Number Formatting
// ============================================================================
@@ -235,6 +255,9 @@ export function getVariabilityLabel(stdDev: number | null): string {
// Validation
// ============================================================================
/** Radius a postcode search uses until the user picks another. */
export const DEFAULT_RADIUS_MILES = 0.5;
/**
* Validate UK postcode format
*/
@@ -596,172 +619,6 @@ export function getCurrentAcademicYear(): number {
return month >= 8 ? year : year - 1;
}
// ============================================================================
// School Detail Hero Helpers
// ============================================================================
const OFSTED_OEIF_WORDS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
/**
* Format an Ofsted inspection date as "Month YYYY" (e.g. "November 2023").
*/
function formatOfstedMonth(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
export type HeroTone = 'teal' | 'green' | 'gold' | 'coral' | 'neutral';
export interface OfstedHeroChip {
state: 'oeif' | 'reportCard' | 'none';
title: string; // Main label (e.g. "Ofsted Outstanding", "Ofsted Report Card")
subtitle: string; // Context line (e.g. "Inspected November 2023")
detail?: string; // Optional extra line (e.g. "Safeguarding: Met")
tone: HeroTone; // Maps to dedicated hero tone classes (not badge classes)
}
/**
* Build the hero-strip Ofsted chip, branching on the inspection framework.
* Never synthesises a single overall grade for ReportCard schools.
*
* Note: the API may return ``framework`` as a literal string ``"NULL"`` for
* older inspections, so we explicitly only branch into the ReportCard layout
* when the value is exactly ``"ReportCard"``. Anything else with an
* ``overall_effectiveness`` score is treated as OEIF.
*/
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
if (!ofsted) {
return {
state: 'none',
title: 'Ofsted pending',
subtitle: 'No inspection on record',
tone: 'neutral',
};
}
const when = formatOfstedMonth(ofsted.inspection_date);
// ReportCard branch — only if the API explicitly says so
if (ofsted.framework === 'ReportCard') {
const safeguarding = ofsted.rc_safeguarding_met;
return {
state: 'reportCard',
title: 'Ofsted Report Card',
subtitle: when ? `Inspected ${when}` : 'New framework inspection',
detail:
safeguarding == null
? undefined
: safeguarding ? 'Safeguarding: Met' : 'Safeguarding: Not met',
tone: safeguarding === false ? 'coral' : 'green',
};
}
// Otherwise treat as OEIF (covers framework === 'OEIF', null, "NULL", etc.)
const grade = ofsted.overall_effectiveness;
if (grade && OFSTED_OEIF_WORDS[grade]) {
const oeifTone: HeroTone =
grade === 1 ? 'teal' :
grade === 2 ? 'green' :
grade === 3 ? 'gold' :
'coral';
return {
state: 'oeif',
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
subtitle: when ? `Inspected ${when}` : 'Inspected',
tone: oeifTone,
};
}
return {
state: 'oeif',
title: 'Ofsted inspected',
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
tone: 'neutral',
};
}
/**
* Build a one-sentence editorial summary for the school detail hero.
* Branches on Ofsted framework so Report Card schools are never described
* with an overall grade they do not have.
*/
export function buildSchoolSummary(
schoolInfo: School,
ofsted: OfstedInspection | null | undefined,
admissions: SchoolAdmissions | null | undefined,
latestResults: SchoolResult | null | undefined,
): string {
const parts: string[] = [];
// Size descriptor
const pupils = latestResults?.total_pupils ?? schoolInfo.total_pupils ?? null;
const sizeWord =
pupils == null ? '' :
pupils < 200 ? 'Small' :
pupils < 500 ? 'Mid-sized' :
'Large';
// Phase descriptor — avoid the raw code
const phase = (schoolInfo.phase ?? '').toLowerCase();
const phaseWord =
phase.includes('secondary') ? 'secondary' :
phase === 'all-through' ? 'all-through' :
phase.includes('primary') ? 'primary' :
'school';
// Religious character
const religion = schoolInfo.religious_denomination;
const religionWord =
!religion || /none|does not apply/i.test(religion) ? '' :
/roman catholic|catholic/i.test(religion) ? 'Catholic ' :
/church of england|ce|anglican/i.test(religion) ? 'Church of England ' :
/jewish/i.test(religion) ? 'Jewish ' :
/muslim|islam/i.test(religion) ? 'Muslim ' :
/hindu/i.test(religion) ? 'Hindu ' :
/sikh/i.test(religion) ? 'Sikh ' :
'';
// Locality — prefer town from address parsing (fallback to LA)
const locality = schoolInfo.town || schoolInfo.local_authority || '';
const lead = [sizeWord, religionWord + phaseWord].filter(Boolean).join(' ');
let opening = lead || 'School';
if (locality) opening += ` in ${locality}`;
parts.push(opening);
// Ofsted clause (framework-aware)
if (ofsted?.framework === 'OEIF' && ofsted.overall_effectiveness) {
parts.push(`rated ${OFSTED_OEIF_WORDS[ofsted.overall_effectiveness]} by Ofsted`);
} else if (ofsted?.framework === 'ReportCard') {
const when = formatOfstedMonth(ofsted.inspection_date);
parts.push(
when
? `most recently inspected under Ofsted's Report Card framework in ${when}`
: "recently inspected under Ofsted's new Report Card framework",
);
}
// Admissions clause
if (admissions?.oversubscribed) {
if (admissions.first_preference_offer_pct != null) {
const pct = Math.round(admissions.first_preference_offer_pct);
parts.push(
`oversubscribed (${pct}% of first-choice applicants are offered a place)`,
);
} else {
parts.push('oversubscribed');
}
} else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) {
parts.push('most families get their first-choice offer');
}
return parts.join(', ') + '.';
}
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
export interface OfstedLegacyArea {
@@ -814,15 +671,18 @@ export interface OfstedListBadge {
* Checked FIRST so it wins over any carried-forward legacy grade — the
* list has no full report_card object, and ofsted_framework is the raw
* event grouping ("Schools - S5"), never "ReportCard".
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
* detail page's hero chip so a school never reads as both inspected and
* - Current grade (ofsted_grade set): grade word + the year it was awarded
* or confirmed (ofsted_grade_date), colour-keyed. Never the year of a later
* visit: that paired old grades with new inspections (audit C1).
* - Inspected with no grade in force (every inspection Sept 2024 – Nov 2025,
* or an ungraded visit whose outcome names no grade): "Inspected · YYYY",
* dated by the latest visit, so a school never reads as both inspected and
* "Not yet inspected"
* - No inspection on record: "Not yet inspected" in grey
*/
export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_grade_date?: string | null;
ofsted_date?: string | null;
ofsted_framework?: string | null;
ofsted_rc_date?: string | null;
@@ -835,10 +695,7 @@ export function buildOfstedListBadge(school: {
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
}
const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear()
: null;
const yearStr = year ? ` · ${year}` : '';
const yearOf = (iso?: string | null) => (iso ? new Date(iso).getFullYear() : null);
if (school.ofsted_grade) {
const labels: Record<number, string> = {
@@ -847,17 +704,19 @@ export function buildOfstedListBadge(school: {
3: 'Req. Improvement',
4: 'Inadequate',
};
const gradeYear = yearOf(school.ofsted_grade_date);
return {
label: `${labels[school.ofsted_grade]}${yearStr}`,
label: `${labels[school.ofsted_grade]}${gradeYear ? ` · ${gradeYear}` : ''}`,
cssClass: `ofsted${school.ofsted_grade}`,
};
}
// An inspection is on record (date or framework present) but carries no
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
// that has genuinely never been inspected.
// An inspection is on record but no overall grade is in force: every
// inspection from Sept 2024 to Nov 2025, or an ungraded visit whose outcome
// names no grade. Dated by the latest visit.
if (school.ofsted_date != null || school.ofsted_framework != null) {
return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' };
const visitYear = yearOf(school.ofsted_date);
return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' };
}
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
@@ -899,6 +758,25 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
}
/**
* Whether GIAS records a religious character. "None", "Does not apply" and
* "Not applicable" are the register's ways of saying it has none; the place
* table (PlaceView's NO_FAITH) reads the same three.
*/
export function hasReligiousCharacter(value: string | null | undefined): boolean {
const v = value?.trim().toLowerCase() ?? '';
return v !== '' && v !== 'none' && v !== 'does not apply' && v !== 'not applicable';
}
/**
* Whether GIAS records the school as selective. Exact: "Non-selective"
* contains "selective", so a substring test read every comprehensive as
* selective. (The register files a partly selective school as non-selective.)
*/
export function isSelective(admissionsPolicy: string | null | undefined): boolean {
return admissionsPolicy?.trim().toLowerCase() === 'selective';
}
/**
* The school's combined Reading, Writing & Maths figure, or null when there is
* no real one to show.
@@ -1,18 +1,91 @@
-- Intermediate model: Latest Ofsted inspection per URN
-- Picks the most recent inspection for each school
-- Intermediate model: the current Ofsted status per URN
-- One row per school: its latest visit (report card, graded or ungraded
-- inspection) and the overall grade still in force, if any. A grade is dated
-- by the inspection that awarded or confirmed it, never by a later visit.
-- Rule and examples: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
with ranked as (
with inspections as (
select
*,
-- The newest of the three inspections, read from the dates. (Report
-- cards began in Nov 2025, after the last legacy inspections, so today
-- a report card is always the latest; nothing below relies on that.)
greatest(rc_inspection_date, graded_inspection_date, ungraded_inspection_date)
as latest_visit_date
from {{ ref('stg_ofsted_inspections') }}
),
ranked as (
select
*,
-- Monthly loads can leave several rows per school. The newest visit
-- wins; the tie-breaks keep the choice deterministic.
row_number() over (
partition by urn
order by inspection_date desc
order by latest_visit_date desc,
rc_inspection_date desc nulls last,
graded_inspection_date desc nulls last,
ungraded_inspection_date desc nulls last
) as rn
from {{ ref('stg_ofsted_inspections') }}
from inspections
),
latest as (
select
*,
-- Same-day ties resolve report card, then graded, then ungraded.
case
when rc_inspection_date = latest_visit_date then 'report_card'
when graded_inspection_date = latest_visit_date then 'graded'
else 'ungraded'
end as latest_visit_kind
from ranked
where rn = 1
),
graded as (
select
*,
-- The grade still in force. A report card replaced overall grades, so
-- none survives it. Otherwise: a graded inspection's own overall grade
-- (1-4; "Not judged" and the sentinel 9 are no grade); else an
-- ungraded visit's "School remains X"; else, after an ungraded visit
-- that names no grade, the graded inspection's grade.
case
when rc_inspection_date is not null
then null
when latest_visit_kind = 'graded' and overall_effectiveness between 1 and 4
then 'graded_latest'
when latest_visit_kind = 'ungraded' and ungraded_grade is not null
then 'confirmed'
when latest_visit_kind = 'ungraded' and overall_effectiveness between 1 and 4
then 'graded_earlier'
end as grade_case
from latest
)
select
urn,
latest_visit_date,
latest_visit_kind,
case when latest_visit_kind = 'ungraded' then ungraded_outcome end as latest_visit_outcome,
case grade_case
when 'confirmed' then ungraded_grade
when 'graded_latest' then overall_effectiveness
when 'graded_earlier' then overall_effectiveness
end as current_grade,
case grade_case
when 'confirmed' then ungraded_inspection_date
when 'graded_latest' then graded_inspection_date
when 'graded_earlier' then graded_inspection_date
end as current_grade_date,
case grade_case
when 'confirmed' then 'confirmed'
when 'graded_latest' then 'graded'
when 'graded_earlier' then 'graded'
end as current_grade_basis,
graded_inspection_date,
ungraded_inspection_date,
inspection_date,
inspection_type,
framework,
@@ -36,5 +109,4 @@ select
rc_sixth_form,
rc_inspection_date,
report_url
from ranked
where rn = 1
from graded
@@ -0,0 +1,113 @@
version: 2
unit_tests:
- name: graded_not_judged_has_no_grade
description: Rabbsfarm (102408). The 2025 inspection gave no overall grade, so the 2020 "remains Good" is not carried forward (audit C1).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 102408, graded_inspection_date: '2025-06-17', ungraded_inspection_date: '2020-02-06', overall_effectiveness: null, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 102408, latest_visit_date: '2025-06-17', latest_visit_kind: graded, latest_visit_outcome: null, current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: graded_with_overall_grade
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 1, graded_inspection_date: '2019-06-01', overall_effectiveness: 2}
expect:
rows:
- {urn: 1, latest_visit_date: '2019-06-01', latest_visit_kind: graded, current_grade: 2, current_grade_date: '2019-06-01', current_grade_basis: graded}
- name: ungraded_remains_good_confirms_the_grade
description: Robins Lane (104762). Graded Good 2020, "School remains Good" July 2024 — Good, dated by the confirming visit.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 104762, graded_inspection_date: '2020-01-07', ungraded_inspection_date: '2024-07-18', overall_effectiveness: 2, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 104762, latest_visit_date: '2024-07-18', latest_visit_kind: ungraded, latest_visit_outcome: 'School remains Good', current_grade: 2, current_grade_date: '2024-07-18', current_grade_basis: confirmed}
- name: post_2024_ungraded_keeps_graded_grade_with_its_own_date
description: Washwood Heath (139888). Graded Good 2020, "Standards maintained" May 2025 — Good, dated 2020; latest visit May 2025 (audit M1).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 139888, graded_inspection_date: '2020-03-03', ungraded_inspection_date: '2025-05-21', overall_effectiveness: 2, ungraded_grade: null, ungraded_outcome: 'Standards maintained'}
expect:
rows:
- {urn: 139888, latest_visit_date: '2025-05-21', latest_visit_kind: ungraded, latest_visit_outcome: 'Standards maintained', current_grade: 2, current_grade_date: '2020-03-03', current_grade_basis: graded}
- name: ungraded_only_standards_maintained
description: Oakgrove (136454). Only an ungraded visit, outcome names no grade.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 136454, ungraded_inspection_date: '2024-11-13', ungraded_grade: null, ungraded_outcome: 'Standards maintained'}
expect:
rows:
- {urn: 136454, latest_visit_date: '2024-11-13', latest_visit_kind: ungraded, latest_visit_outcome: 'Standards maintained', current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: report_card_wins
description: The Willink School (110048). A report card is the latest visit and no legacy grade stays in force.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 110048, ungraded_inspection_date: '2023-10-05', ungraded_grade: 2, ungraded_outcome: 'School remains Good', rc_inspection_date: '2026-05-06', rc_inclusion: 3}
expect:
rows:
- {urn: 110048, latest_visit_date: '2026-05-06', latest_visit_kind: report_card, latest_visit_outcome: null, current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: duplicate_rows_newer_report_card_wins
description: Monthly loads can leave an older row beside a newer one for the same graded date; the row with the report card must win (audit H3).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 138186, graded_inspection_date: '2023-06-13', overall_effectiveness: 3}
- {urn: 138186, graded_inspection_date: '2023-06-13', overall_effectiveness: 3, rc_inspection_date: '2026-06-02', rc_inclusion: 3}
expect:
rows:
- {urn: 138186, latest_visit_date: '2026-06-02', latest_visit_kind: report_card, current_grade: null}
- name: same_day_graded_wins
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 2, graded_inspection_date: '2024-03-01', ungraded_inspection_date: '2024-03-01', overall_effectiveness: 1, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 2, latest_visit_kind: graded, current_grade: 1, current_grade_basis: graded}
- name: overall_sentinel_is_not_a_grade
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 3, graded_inspection_date: '2018-05-01', overall_effectiveness: 9}
expect:
rows:
- {urn: 3, latest_visit_kind: graded, current_grade: null}
- name: newer_legacy_visit_after_a_report_card
description: >
Not in Ofsted's data today (0 of 2,451 report-card schools in the 31 Aug
2026 MI), but the latest visit is read from the dates, not assumed. The
report card still leaves no legacy grade in force.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 4, ungraded_inspection_date: '2026-03-02', ungraded_grade: 2, ungraded_outcome: 'School remains Good', rc_inspection_date: '2025-12-01', rc_inclusion: 3}
expect:
rows:
- {urn: 4, latest_visit_date: '2026-03-02', latest_visit_kind: ungraded, latest_visit_outcome: 'School remains Good', current_grade: null, current_grade_date: null, current_grade_basis: null}
@@ -125,6 +125,30 @@ models:
- name: inspection_date
tests: [not_null]
- name: fact_ofsted_latest
description: >
Current Ofsted status, one row per URN: the latest visit and the overall
grade still in force. See int_ofsted_latest for the rule.
columns:
- name: urn
tests: [not_null, unique]
- name: latest_visit_date
tests: [not_null]
- name: latest_visit_kind
tests:
- not_null
- accepted_values:
values: ['report_card', 'graded', 'ungraded']
- name: current_grade
tests:
- accepted_values:
values: [1, 2, 3, 4]
quote: false
- name: current_grade_basis
tests:
- accepted_values:
values: ['graded', 'confirmed']
- name: fact_pupil_characteristics
description: Pupil demographics — one row per URN per year
columns:
@@ -71,12 +71,12 @@ select
s.nursery_provision,
s.admissions_policy_code,
-- Latest Ofsted (populated after monthly Ofsted pipeline runs)
-- Latest Ofsted (populated after monthly Ofsted pipeline runs). The grade
-- still in force and the latest visit, from int_ofsted_latest — never a
-- grade carried past a newer inspection.
{% if ofsted_relation is not none %}
-- Prefer the graded overall effectiveness; fall back to the grade parsed
-- from the latest ungraded (Section 8) outcome when no graded grade exists.
coalesce(o.overall_effectiveness, o.ungraded_grade) as ofsted_grade,
o.inspection_date as ofsted_date,
o.current_grade as ofsted_grade,
o.latest_visit_date as ofsted_date,
o.framework as ofsted_framework
{% else %}
null::text as ofsted_grade,
@@ -0,0 +1,35 @@
version: 2
unit_tests:
- name: dim_school_ofsted_grade_is_the_one_still_in_force
description: >
Rabbsfarm (102408). Its 2025 inspection gave no overall grade, so the 2020
"School remains Good" must not reach dim_school (which feeds Typesense's
rating), and ofsted_date is the latest visit (audit C1, M1).
model: dim_school
given:
- input: ref('stg_gias_establishments')
rows:
- {urn: 102408, school_name: 'Rabbsfarm Primary School', status_code: 1, school_type_code: 1, local_authority_code: 312, phase_code: 2}
- input: ref('int_ofsted_latest')
rows:
- {urn: 102408, inspection_date: '2025-06-17', overall_effectiveness: null, ungraded_grade: 2, framework: 'Schools - S5', latest_visit_date: '2025-06-17', current_grade: null}
expect:
rows:
- {urn: 102408, ofsted_grade: null, ofsted_date: '2025-06-17', ofsted_framework: 'Schools - S5'}
- name: dim_school_ofsted_grade_keeps_its_own_date_out_of_ofsted_date
description: >
Washwood Heath (139888). Good from a 2020 graded inspection; latest visit
an ungraded one in May 2025. ofsted_date is the latest visit.
model: dim_school
given:
- input: ref('stg_gias_establishments')
rows:
- {urn: 139888, school_name: 'Washwood Heath Academy', status_code: 1, school_type_code: 28, local_authority_code: 330, phase_code: 7}
- input: ref('int_ofsted_latest')
rows:
- {urn: 139888, inspection_date: '2020-03-03', overall_effectiveness: 2, ungraded_grade: null, framework: 'Schools - S5', latest_visit_date: '2025-05-21', current_grade: 2}
expect:
rows:
- {urn: 139888, ofsted_grade: 2, ofsted_date: '2025-05-21'}
@@ -0,0 +1,37 @@
-- Mart: current Ofsted status — one row per URN
-- The backend reads this instead of choosing the latest row of
-- fact_ofsted_inspection itself. The rule lives in int_ofsted_latest.
select
urn,
latest_visit_date,
latest_visit_kind,
latest_visit_outcome,
current_grade,
current_grade_date,
current_grade_basis,
graded_inspection_date,
ungraded_inspection_date,
rc_inspection_date,
inspection_type,
framework,
overall_effectiveness,
quality_of_education,
behaviour_attitudes,
personal_development,
leadership_management,
early_years_provision,
sixth_form_provision,
ungraded_outcome,
ungraded_grade,
rc_safeguarding_met,
rc_inclusion,
rc_curriculum_teaching,
rc_achievement,
rc_attendance_behaviour,
rc_personal_development,
rc_leadership_governance,
rc_early_years,
rc_sixth_form,
report_url
from {{ ref('int_ofsted_latest') }}
@@ -1,5 +1,10 @@
-- Staging model: Ofsted inspection records
-- Handles both OEIF (pre-Nov 2025) and Report Card (post-Nov 2025) frameworks
-- Handles both OEIF (pre-Nov 2025) and Report Card (post-Nov 2025) frameworks.
--
-- Ofsted's MI carries up to three inspections per school: the latest graded
-- one, the latest ungraded one and the latest report card. Their dates stay
-- separate here so int_ofsted_latest can tell which came last and which one a
-- grade belongs to (docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md).
with source as (
select * from {{ source('raw', 'ofsted_inspections') }}
@@ -8,17 +13,12 @@ with source as (
renamed as (
select
cast(urn as integer) as urn,
-- Inspection event date: the graded inspection when present, otherwise the
-- ungraded (Section 8) inspection so schools with only an ungraded
-- inspection are still retained.
coalesce(
to_date(nullif(trim(inspection_date), 'NULL'), 'DD/MM/YYYY'),
to_date(nullif(trim(ungraded_inspection_date), 'NULL'), 'DD/MM/YYYY')
) as inspection_date,
to_date(nullif(trim(inspection_date), 'NULL'), 'DD/MM/YYYY') as graded_inspection_date,
to_date(nullif(trim(ungraded_inspection_date), 'NULL'), 'DD/MM/YYYY') as ungraded_inspection_date,
inspection_type,
event_type_grouping as framework,
-- OEIF grades (1-4 scale)
-- OEIF grades (1-4 scale; 9 = not applicable)
{{ safe_numeric('overall_effectiveness') }}::integer as overall_effectiveness,
{{ safe_numeric('quality_of_education') }}::integer as quality_of_education,
{{ safe_numeric('behaviour_and_attitudes') }}::integer as behaviour_attitudes,
@@ -27,9 +27,9 @@ renamed as (
{{ safe_numeric('early_years_provision') }}::integer as early_years_provision,
{{ safe_numeric('sixth_form_provision') }}::integer as sixth_form_provision,
-- Ungraded (Section 8) inspection outcome — free text, plus a grade
-- parsed from it (1/2/null) used as a last-resort fallback for schools
-- with no graded overall effectiveness.
-- Ungraded (Section 8) inspection outcome — free text, plus the grade
-- it confirms ("School remains Good" → 2); null for outcomes that
-- name no grade.
nullif(trim(ungraded_outcome), 'NULL') as ungraded_outcome,
{{ parse_ungraded_outcome('ungraded_outcome') }}::integer as ungraded_grade,
@@ -50,31 +50,36 @@ renamed as (
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
-- Start date of the latest FULL inspection (the report-card
-- inspection in the renewed framework). Guarded in the final select:
-- only kept when the row actually carries report-card grades, because
-- in legacy-format files this column is the legacy inspection date.
-- inspection in the renewed framework). Only kept when the row
-- carries report-card grades, because in legacy-format files this
-- column is the legacy inspection date.
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
nullif(trim(report_url), 'NULL') as report_url
from source
where urn is not null
and (
nullif(trim(inspection_date), 'NULL') is not null
or nullif(trim(ungraded_inspection_date), 'NULL') is not null
)
),
dated as (
select
*,
case
when rc_safeguarding_met is not null
or rc_inclusion is not null
or rc_curriculum_teaching is not null
or rc_achievement is not null
or rc_attendance_behaviour is not null
or rc_personal_development is not null
or rc_leadership_governance is not null
then rc_inspection_date_raw
end as rc_inspection_date
from renamed
)
select
*,
case
when rc_safeguarding_met is not null
or rc_inclusion is not null
or rc_curriculum_teaching is not null
or rc_achievement is not null
or rc_attendance_behaviour is not null
or rc_personal_development is not null
or rc_leadership_governance is not null
then rc_inspection_date_raw
end as rc_inspection_date
from renamed
where inspection_date is not null
-- For readers that predate the separate dates (fact_ofsted_inspection and
-- the backend until it reads fact_ofsted_latest). Never null below.
coalesce(graded_inspection_date, ungraded_inspection_date, rc_inspection_date) as inspection_date
from dated
where coalesce(graded_inspection_date, ungraded_inspection_date, rc_inspection_date) is not null
@@ -0,0 +1,25 @@
version: 2
unit_tests:
- name: stg_ofsted_keeps_the_three_dates_apart
description: The graded and ungraded dates must stay separate so the latest visit can be found.
model: stg_ofsted_inspections
given:
- input: source('raw', 'ofsted_inspections')
rows:
- {urn: 102408, inspection_date: '17/06/2025', ungraded_inspection_date: '06/02/2020', overall_effectiveness: 'Not judged', ungraded_outcome: 'School remains Good', rc_inspection_date: '17/06/2025', rc_inclusion: 'NULL'}
expect:
rows:
- {urn: 102408, graded_inspection_date: '2025-06-17', ungraded_inspection_date: '2020-02-06', rc_inspection_date: null, inspection_date: '2025-06-17', ungraded_grade: 2}
- name: stg_ofsted_keeps_report_card_only_rows
description: A school whose only inspection is a report card used to be dropped (audit H3).
model: stg_ofsted_inspections
given:
- input: source('raw', 'ofsted_inspections')
rows:
- {urn: 149612, inspection_date: 'NULL', ungraded_inspection_date: 'NULL', rc_inspection_date: '10/02/2026', rc_inclusion: 'Expected standard', rc_safeguarding_met: 'Met'}
- {urn: 1, inspection_date: 'NULL', ungraded_inspection_date: 'NULL', rc_inspection_date: 'NULL'}
expect:
rows:
- {urn: 149612, graded_inspection_date: null, ungraded_inspection_date: null, rc_inspection_date: '2026-02-10', inspection_date: '2026-02-10', rc_inclusion: 3}
@@ -0,0 +1,10 @@
-- A grade is dated by the inspection that awarded or confirmed it, never
-- after the latest visit, and has a date and a basis exactly when it exists.
-- A report card leaves no legacy grade in force.
select urn
from {{ ref('fact_ofsted_latest') }}
where current_grade_date > latest_visit_date
or (current_grade is null) <> (current_grade_date is null)
or (current_grade is null) <> (current_grade_basis is null)
or (rc_inspection_date is not null and current_grade is not null)