From 50546ecf2205f96f6b1676155574868966dc2fed Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:39:07 +0100 Subject: [PATCH 1/9] docs(spec): school type groups and a faith filter Co-Authored-By: Claude Opus 5.5 --- ...ool-type-groups-and-faith-filter-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md diff --git a/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md new file mode 100644 index 0000000..af02ad1 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md @@ -0,0 +1,218 @@ +# School Type Groups and a Faith Filter — Design + +**Date:** 2026-10-02 +**Status:** approved in conversation, awaiting spec review +**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 six groups a +parent recognises, and add a Faith filter. + +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 | +|---|---|---|---| +| `academy` | State school: academy or free school | 28 Academy sponsor led, 34 Academy converter, 35 Free schools, 40 University technical college, 41 Studio schools, 6 City technology college | 11,186 | +| `council` | State school: council-run | 1 Community school, 2 Voluntary aided school, 3 Voluntary controlled school, 5 Foundation school, 15 Local authority nursery school | 9,316 | +| `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 academies.** They are + legally academies, they are few (66 together), and a family considering one + searches for it by name. +- **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 at the same point 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(code) -> str | None` and + `faith_groups_for(code) -> tuple[str, ...]` (a missing code gives + `("none",)`). + +No dependence on the generated GIAS dictionaries beyond their codes, so the +backend/pipeline dictionary parity test is untouched. + +### `backend/data_loader.py` + +In `translate_gias_code_columns`, before the code columns are replaced by +names, add two columns from the codes: + +- `school_type_group`: `type_group_for(school_type_code)`, or None. +- `faith_groups`: `faith_groups_for(religious_character_code)`, a tuple. + +The fallback query that reads name columns from older marts +(`_MAIN_QUERY_NO_EXTRA_COLS` and its replacements) has no codes. There, both +columns are derived from the names by reverse lookup through `SCHOOL_TYPE` and +`RELIGIOUS_CHARACTER`. + +### `/api/schools` + +- `school_type`: if the value is a type group key, filter on + `school_type_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): filter to + rows whose `faith_groups` contains the key. 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. From 3e2fa4a4199a92ae55ab2191daca38c26b012807 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:44:57 +0100 Subject: [PATCH 2/9] docs(plan): school type groups and a faith filter Co-Authored-By: Claude Opus 5.5 --- ...-02-school-type-groups-and-faith-filter.md | 911 ++++++++++++++++++ 1 file changed, 911 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-school-type-groups-and-faith-filter.md diff --git a/docs/superpowers/plans/2026-10-02-school-type-groups-and-faith-filter.md b/docs/superpowers/plans/2026-10-02-school-type-groups-and-faith-filter.md new file mode 100644 index 0000000..089d277 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-school-type-groups-and-faith-filter.md @@ -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=&faith=` + - `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(); + 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(); + 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(); + 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(); + expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument(); + }); + + it('is left out when the API sends no groups', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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) => ( + + ))} +``` + +10. After `laSelect`, add: + +```tsx + const faithSelect = (look: Look) => ( + + + + ); +``` + +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 `{typeSelect("sheet")}` with: + +```tsx + {typeOptions.length > 0 && ( + {typeSelect("sheet")} + )} + {faithOptions.length > 0 && ( + {faithSelect("sheet")} + )} +``` + +- [ ] **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. From 354244f75577b03687ef788730295982d7578c9f Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:52:43 +0100 Subject: [PATCH 3/9] feat(api): group GIAS school types and religions for parents Co-Authored-By: Claude Opus 5.5 --- backend/school_groups.py | 104 ++++++++++++++++++ backend/tests/test_school_groups.py | 98 +++++++++++++++++ ...ool-type-groups-and-faith-filter-design.md | 27 ++--- 3 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 backend/school_groups.py create mode 100644 backend/tests/test_school_groups.py diff --git a/backend/school_groups.py b/backend/school_groups.py new file mode 100644 index 0000000..7a14fb7 --- /dev/null +++ b/backend/school_groups.py @@ -0,0 +1,104 @@ +"""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), ()) diff --git a/backend/tests/test_school_groups.py b/backend/tests/test_school_groups.py new file mode 100644 index 0000000..8e67d3a --- /dev/null +++ b/backend/tests/test_school_groups.py @@ -0,0 +1,98 @@ +"""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)") == () diff --git a/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md index af02ad1..89e095b 100644 --- a/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md +++ b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md @@ -103,8 +103,8 @@ Christian schools that are not tied to one church. 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 at the same point needs no mart change, so there is no Airflow run -between merge and staging showing it. +Grouping there needs no mart change, so there is no Airflow run between merge +and staging showing it. ### `backend/school_groups.py` (new) @@ -112,25 +112,22 @@ between merge and staging showing it. - `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(code) -> str | None` and - `faith_groups_for(code) -> tuple[str, ...]` (a missing code gives +- `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. -### `backend/data_loader.py` +### Grouping by name, at filter time -In `translate_gias_code_columns`, before the code columns are replaced by -names, add two columns from the codes: - -- `school_type_group`: `type_group_for(school_type_code)`, or None. -- `faith_groups`: `faith_groups_for(religious_character_code)`, a tuple. - -The fallback query that reads name columns from older marts -(`_MAIN_QUERY_NO_EXTRA_COLS` and its replacements) has no codes. There, both -columns are derived from the names by reverse lookup through `SCHOOL_TYPE` and -`RELIGIOUS_CHARACTER`. +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` From 15b8923e60e838ca39a8dba6ae70d99f59c5c741 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:53:14 +0100 Subject: [PATCH 4/9] feat(api): filter schools by type group and by faith Co-Authored-By: Claude Opus 5.5 --- backend/app.py | 64 +++++++++++- backend/tests/test_type_and_faith_filters.py | 100 +++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_type_and_faith_filters.py diff --git a/backend/app.py b/backend/app.py index 59d79b8..a8d571a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -41,6 +41,14 @@ 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 .school_groups import ( + FAITH_GROUPS, + FAITH_KEYS, + TYPE_GROUP_KEYS, + TYPE_GROUPS, + faith_groups_for, + type_group_for, +) from .nearby_schools import select_nearby from .utils import clean_for_json, convert_to_native @@ -608,6 +616,15 @@ def verify_admin_api_key(x_api_key: str = Header(None)) -> bool: # 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 +761,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 +774,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 +815,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 = [ @@ -883,10 +918,17 @@ async def get_schools( schools_df["local_authority"].str.lower() == local_authority.lower() ] + # 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: - schools_df = schools_df[ - schools_df["school_type"].str.lower() == school_type.lower() - ] + 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] # Compute result-scoped filter values (before pagination). # Gender and admissions are secondary-only filters — scope them to schools @@ -1161,6 +1203,8 @@ async def get_filter_options(request: Request): "local_authorities": [], "school_types": [], "years": [], + "school_type_groups": [], + "faiths": [], } # Phases: return values from data, ordered sensibly @@ -1170,6 +1214,18 @@ async def get_filter_options(request: Request): 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 +1233,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), } diff --git a/backend/tests/test_type_and_faith_filters.py b/backend/tests/test_type_and_faith_filters.py new file mode 100644 index 0000000..00efe57 --- /dev/null +++ b/backend/tests/test_type_and_faith_filters.py @@ -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", [ + ("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"] From 17bfb4a3f73740d5cb1cf9ea8237ddb469101339 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:54:04 +0100 Subject: [PATCH 5/9] feat(search): school type groups and a faith filter Co-Authored-By: Claude Opus 5.5 --- .../components/FilterBarOptions.test.tsx | 16 ++- .../components/FilterBarTypeFaith.test.tsx | 114 ++++++++++++++++++ .../__tests__/components/FilterSheet.test.tsx | 5 +- .../components/FilterSheetPending.test.tsx | 5 +- .../components/ResultsToolbar.test.tsx | 3 +- nextjs-app/app/(frontend)/page.tsx | 5 +- nextjs-app/components/FilterBar.tsx | 50 +++++++- nextjs-app/lib/types.ts | 10 ++ 8 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx diff --git a/nextjs-app/__tests__/components/FilterBarOptions.test.tsx b/nextjs-app/__tests__/components/FilterBarOptions.test.tsx index 35355b4..723b117 100644 --- a/nextjs-app/__tests__/components/FilterBarOptions.test.tsx +++ b/nextjs-app/__tests__/components/FilterBarOptions.test.tsx @@ -5,7 +5,7 @@ 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, gender and admissions offer the + * 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. * @@ -29,6 +29,10 @@ const filters = { 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. @@ -54,11 +58,11 @@ beforeEach(() => { }); describe('filter options', () => { - it('offer every school type, gender and admissions policy, whatever the results hold', () => { - params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&gender=girls'); + 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(); const sheet = openSheet(); - expect(optionsOf(sheet, 'School type')).toEqual(['Any school type', 'Academy converter', 'Community school']); + 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']); }); @@ -107,13 +111,13 @@ describe('the secondary-only filters', () => { 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=Community+school'); + 'postcode=SW196AR&radius=1&phase=secondary&gender=girls&has_sixth_form=yes&admissions_policy=selective&school_type=council'); render(); 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('Community school'); + expect(next.get('school_type')).toBe('council'); }); it('are kept when the new phase still has them', () => { diff --git a/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx b/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx new file mode 100644 index 0000000..f03d1b9 --- /dev/null +++ b/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx @@ -0,0 +1,114 @@ +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(); + 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(); + 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(); + 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(); + expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument(); + }); + + it('is left out when the API sends no groups', () => { + render(); + 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(); + 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(); + 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(); + 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(); + expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/components/FilterSheet.test.tsx b/nextjs-app/__tests__/components/FilterSheet.test.tsx index ca17bf0..46dd87d 100644 --- a/nextjs-app/__tests__/components/FilterSheet.test.tsx +++ b/nextjs-app/__tests__/components/FilterSheet.test.tsx @@ -21,6 +21,7 @@ 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]); @@ -38,7 +39,7 @@ describe('the phone Filters button', () => { }); it('counts every applied filter, phase and type included', () => { - params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=Community+school'); + params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=council'); render(); expect(screen.getByRole('button', { name: 'Filters, 2 applied' })).toBeInTheDocument(); }); @@ -145,7 +146,7 @@ describe('the applied-filter chips', () => { }); it('carry a Clear all that keeps the search', () => { - params = new URLSearchParams('search=southmead&school_type=Community+school'); + params = new URLSearchParams('search=southmead&school_type=council'); render(); fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' })) .getByRole('button', { name: 'Clear all' })); diff --git a/nextjs-app/__tests__/components/FilterSheetPending.test.tsx b/nextjs-app/__tests__/components/FilterSheetPending.test.tsx index 19dd2e7..be88818 100644 --- a/nextjs-app/__tests__/components/FilterSheetPending.test.tsx +++ b/nextjs-app/__tests__/components/FilterSheetPending.test.tsx @@ -27,6 +27,7 @@ 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]); @@ -53,10 +54,10 @@ it('builds a second change on the first, not on the URL it has not reached', () 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: 'Community school' } }); + { target: { value: 'council' } }); const next = pushedParams(); expect(next.get('phase')).toBe('primary'); - expect(next.get('school_type')).toBe('Community school'); + expect(next.get('school_type')).toBe('council'); expect(next.get('postcode')).toBe('SW196AR'); }); diff --git a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx index c59842e..bc16678 100644 --- a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx +++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx @@ -35,6 +35,7 @@ jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () =>
= { phase: currentPhase, school_type: currentType, + faith: currentFaith, local_authority: currentLA, gender: currentGender, has_sixth_form: currentHasSixthForm, @@ -432,6 +440,11 @@ export function FilterBar({ 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 labelled.find((o) => o.value === value)?.label ?? value; // Phase, gender and admissions values are lowercased option names; the // rest are the names themselves. const named: Partial> = { @@ -487,9 +500,9 @@ export function FilterBar({ disabled={isPending && look !== "sheet"} > - {typeOptions.map((type) => ( - ))} @@ -515,6 +528,25 @@ export function FilterBar({ ); + const faithSelect = (look: Look) => ( + + + + ); + const genderSelect = (look: Look) => ( handleFilterChange("school_type", e.target.value)} className={selectClass(look, currentType)} aria-label="School type" disabled={isPending && look !== "sheet"} > + {typeSelected.unlisted && ( + + )} {typeOptions.map((o) => (