From 3e2fa4a4199a92ae55ab2191daca38c26b012807 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 11:44:57 +0100 Subject: [PATCH] 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.