Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:02:32 +01:00
tudor dd5b48e612 Merge pull request 'feat(search): phases in the order a child meets them' (#174) from feat/phase-order-child-path into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 31s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m10s
Reviewed-on: #174
2026-10-02 18:07:08 +00:00
32 changed files with 1237 additions and 159 deletions

No files matched your search

+36 -8
View File
@@ -621,6 +621,31 @@ def verify_admin_api_key(x_api_key: str = Header(None)) -> bool:
return True return True
def _with_whole_school_pupils(rows: pd.DataFrame, source: pd.DataFrame) -> pd.DataFrame:
"""Set total_pupils to the size of the school.
fact_performance's total_pupils is the cohort a year's results were
measured on. For a secondary that is the GCSE year group alone (Burntwood:
245 against 1,462 on roll). Cards, map popups and place rows label it
"pupils", so they take the register's whole-school count instead, and
nothing when the register has none.
"""
whole = source["gias_total_pupils"] if "gias_total_pupils" in source.columns else None
return rows.assign(total_pupils=whole)
def _with_type_group(rows: pd.DataFrame) -> pd.DataFrame:
"""Name each row's search-filter type group, or None for a type in no group.
The school page and the search rows print it ("State school") in place of
the GIAS establishment type, and an independent school gets a Fee-paying
flag from it.
"""
if "school_type" not in rows.columns:
return rows
return rows.assign(type_group=rows["school_type"].map(type_group_for))
# Input validation helpers # Input validation helpers
def _names_in_group(names: pd.Series, in_group) -> set: def _names_in_group(names: pd.Series, in_group) -> set:
"""The distinct names in a column that a group predicate accepts. """The distinct names in a column that a group predicate accepts.
@@ -859,7 +884,7 @@ async def get_schools(
if c in df_latest.columns if c in df_latest.columns
] ]
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn. # fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
schools_df = df_latest[available_cols] schools_df = _with_whole_school_pupils(df_latest[available_cols], df_latest)
# Location-based search (uses pre-geocoded data from database) # Location-based search (uses pre-geocoded data from database)
search_coords = None search_coords = None
@@ -952,7 +977,7 @@ async def get_schools(
total = len(schools_df) total = len(schools_df)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
end_idx = start_idx + page_size end_idx = start_idx + page_size
schools_df = schools_df.iloc[start_idx:end_idx] schools_df = _with_type_group(schools_df.iloc[start_idx:end_idx])
return { return {
"schools": clean_for_json(schools_df), "schools": clean_for_json(schools_df),
@@ -1016,11 +1041,14 @@ async def get_school_details(request: Request, urn: int):
"school_name": latest.get("school_name", ""), "school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""), "local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""), "school_type": latest.get("school_type", ""),
"type_group": type_group_for(latest.get("school_type")),
"address": latest.get("address", ""), "address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""), "religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""), "age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"), "has_sixth_form": latest.get("has_sixth_form"),
"nursery_provision": latest.get("nursery_provision"), "nursery_provision": latest.get("nursery_provision"),
# The header's Selective flag reads it (lib/schoolFacts).
"admissions_policy": latest.get("admissions_policy"),
"status": latest.get("status"), "status": latest.get("status"),
"latitude": latest.get("latitude"), "latitude": latest.get("latitude"),
"longitude": latest.get("longitude"), "longitude": latest.get("longitude"),
@@ -1509,13 +1537,12 @@ async def get_place(request: Request, kind: str, slug: str,
# warning. Ordered de-duplication keeps the column order and the warning # warning. Ordered de-duplication keeps the column order and the warning
# cannot come back. # cannot come back.
# #
# nursery_provision and parliamentary_constituency are not in # parliamentary_constituency is not in SCHOOL_COLUMNS and the place table
# SCHOOL_COLUMNS and the place table shows both. The `in rows.columns` # shows it. The `in rows.columns` guard is what keeps a mart the pipeline
# guard is what keeps a mart the pipeline has not rebuilt working: those # has not rebuilt working: it and nursery_provision are the optional GIAS
# two are the optional GIAS columns data_loader degrades to NULL. # columns data_loader degrades to NULL.
cols = [c for c in dict.fromkeys( cols = [c for c in dict.fromkeys(
SCHOOL_COLUMNS + ["latitude", "longitude", "phase", SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
"nursery_provision",
"parliamentary_constituency", "parliamentary_constituency",
"rwm_expected_pct", "attainment_8_score", "rwm_expected_pct", "attainment_8_score",
"total_pupils"]) "total_pupils"])
@@ -1545,7 +1572,8 @@ async def get_place(request: Request, kind: str, slug: str,
# variants that exist rather than 404s. # variants that exist rather than 404s.
"phases": [ph for ph in ("primary", "secondary") "phases": [ph for ph in ("primary", "secondary")
if place.publishes_phase(ph)]}, if place.publishes_phase(ph)]},
"schools": clean_for_json(rows[cols]), "schools": clean_for_json(
_with_type_group(_with_whole_school_pupils(rows[cols], rows))),
"averages": averages, "averages": averages,
} }
+1
View File
@@ -569,6 +569,7 @@ SCHOOL_COLUMNS = [
"religious_denomination", "religious_denomination",
"age_range", "age_range",
"has_sixth_form", "has_sixth_form",
"nursery_provision",
"status", "status",
"gender", "gender",
"admissions_policy", "admissions_policy",
@@ -0,0 +1,55 @@
"""The school page's header flags (nextjs-app/lib/schoolFacts.ts) read seven
school_info fields, so the detail payload must carry every one.
It lacked admissions_policy, so no school page could flag Selective while its
search row did: Tiffin and The Grammar School at Leeds showed the tag in
search and nothing on their own pages.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
# The fields schoolFlags() picks from School (FlagFields in lib/schoolFacts.ts).
FLAG_FIELDS = (
"type_group", "admissions_policy", "gender", "religious_denomination",
"nursery_provision", "has_sixth_form", "phase",
)
def _school_df() -> pd.DataFrame:
return pd.DataFrame([{
"urn": 136910, "school_name": "Tiffin School", "phase": "Secondary",
"school_type": "Academy converter", "admissions_policy": "Selective",
"gender": "Boys", "religious_denomination": "Christian",
"nursery_provision": "Not applicable", "has_sixth_form": True,
"age_range": "11-18", "local_authority": "Kingston upon Thames",
"address": "Queen Elizabeth Road, Kingston upon Thames, KT2 6RL",
"postcode": "KT2 6RL", "latitude": 51.41, "longitude": -0.30,
"year": 202425, "attainment_8_score": 75.0, "total_pupils": 200,
"gias_total_pupils": 1478, "ofsted_grade": np.nan,
}])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _school_df)
monkeypatch.setattr(app_module, "load_latest_school_data", _school_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_school_page_carries_every_field_its_flags_read(client):
resp = client.get("/api/schools/136910")
assert resp.status_code == 200, resp.text
info = resp.json()["school_info"]
assert [f for f in FLAG_FIELDS if f not in info] == []
def test_the_school_page_says_a_selective_school_is_selective(client):
info = client.get("/api/schools/136910").json()["school_info"]
assert info["admissions_policy"] == "Selective"
+81
View File
@@ -0,0 +1,81 @@
"""Payloads name each school's type group.
The school page and the search rows say "State school" or "Independent
school" in the search filter's own terms, not GIAS's 34 establishment types,
and an independent school gets a Fee-paying flag. A type in no group keeps a
null group, and the page prints the register's own name for it. Search rows
also carry nursery_provision, for their "Nursery class" flag.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "phase": "Primary", "year": 202425,
"ofsted_grade": 2.0, "ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
"rwm_expected_pct": 60.0, "nursery_provision": "No Nursery Classes",
}
rows = [
{**base, "urn": 100001, "school_name": "Alpha Academy",
"school_type": "Academy converter", "nursery_provision": "Has Nursery Classes"},
{**base, "urn": 100002, "school_name": "Beta Prep",
"school_type": "Other independent school"},
{**base, "urn": 100003, "school_name": "Gamma Unit",
"school_type": "Secure units"},
]
# Enough schools in one town for it to have a place page.
rows += [
{**base, "urn": 100010 + i, "school_name": f"Delta Primary {i}",
"school_type": "Community school"}
for i in range(3)
]
return pd.DataFrame(rows)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def _by_urn(schools: list[dict]) -> dict[int, dict]:
return {s["urn"]: s for s in schools}
def test_the_list_names_each_type_group(client):
resp = client.get("/api/schools?page_size=50")
assert resp.status_code == 200, resp.text
schools = _by_urn(resp.json()["schools"])
assert schools[100001]["type_group"] == "state"
assert schools[100002]["type_group"] == "independent"
assert schools[100003]["type_group"] is None
def test_the_list_carries_nursery_provision(client):
schools = _by_urn(client.get("/api/schools?page_size=50").json()["schools"])
assert schools[100001]["nursery_provision"] == "Has Nursery Classes"
def test_the_school_page_names_its_type_group(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["type_group"] == "independent"
def test_a_place_page_names_each_type_group(client):
resp = client.get("/api/places/town/brentwood")
assert resp.status_code == 200, resp.text
schools = _by_urn(resp.json()["schools"])
assert schools[100001]["type_group"] == "state"
assert schools[100003]["type_group"] is None
+67
View File
@@ -0,0 +1,67 @@
"""Cards, map popups and place rows label total_pupils "pupils".
fact_performance's total_pupils is the cohort a year's results were measured
on. For a secondary that is the GCSE year group alone: Burntwood showed 245 in
search against 1,462 on roll. The list and place payloads therefore carry the
register's whole-school count, and nothing when the register has none.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy converter",
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
"gender": "Mixed", "rwm_expected_pct": np.nan, "attainment_8_score": 50.0,
}
rows = [
# Secondary: results cohort 245, register 1,462.
{**base, "urn": 100001, "school_name": "Alpha High", "phase": "Secondary",
"total_pupils": 245, "gias_total_pupils": 1462},
# Register count missing: no count, never the cohort.
{**base, "urn": 100002, "school_name": "Beta High", "phase": "Secondary",
"total_pupils": 180, "gias_total_pupils": np.nan},
]
# Enough schools in one town for it to have a place page.
rows += [
{**base, "urn": 100010 + i, "school_name": f"Gamma High {i}", "phase": "Secondary",
"total_pupils": 200, "gias_total_pupils": 1000 + i}
for i in range(5)
]
return pd.DataFrame(rows)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def _pupils(schools: list[dict]) -> dict[int, object]:
return {s["urn"]: s.get("total_pupils") for s in schools}
def test_the_list_carries_the_whole_school_count(client):
resp = client.get("/api/schools?page_size=50")
assert resp.status_code == 200, resp.text
pupils = _pupils(resp.json()["schools"])
assert pupils[100001] == 1462
assert pupils[100002] is None
def test_a_place_page_carries_the_whole_school_count(client):
resp = client.get("/api/places/town/brentwood")
assert resp.status_code == 200, resp.text
pupils = _pupils(resp.json()["schools"])
assert pupils[100001] == 1462
assert pupils[100002] is None
+173 -12
View File
@@ -53,7 +53,7 @@ async function settledScrollLeft(scroller: Locator): Promise<number> {
* whatever primaries the environment holds. * whatever primaries the environment holds.
*/ */
async function twoPrimaryUrns(page: Page): Promise<[string, string]> { async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=primary&per_page=50'); const res = await page.request.get('/api/schools?search=primary&page_size=50');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const body = await res.json(); const body = await res.json();
const urns: string[] = (body.schools ?? []) const urns: string[] = (body.schools ?? [])
@@ -66,7 +66,7 @@ async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
} }
async function twoSecondaryUrns(page: Page): Promise<[string, string]> { async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=school&per_page=100'); const res = await page.request.get('/api/schools?search=school&page_size=100');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const body = await res.json(); const body = await res.json();
const urns: string[] = (body.schools ?? []) const urns: string[] = (body.schools ?? [])
@@ -355,6 +355,84 @@ test('school type groups and the faith filter narrow to what they name', async (
} }
}); });
/*
* Search rows printed tags the register does not hold. Every non-selective
* secondary was "Selective" ("non-selective" contains "selective"), and a
* school with no religious character got "Faith priority" or a bare "None"
* chip, because only "Does not apply" was excluded. Data-invariant: each test
* picks its school from the API and reads only that school's row.
*/
async function rowTags(page: Page, school: { urn: number; school_name: string }) {
await searchByName(page, school.school_name);
const link = page.locator(`a[href^="/school/${school.urn}-"]`).first();
await expect(link).toBeVisible({ timeout: 15_000 });
return link.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
.locator('[class*="__line2"]');
}
test('a non-selective secondary is not tagged Selective in search', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&admissions_policy=non-selective&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no non-selective secondary in this environment');
expect(school.admissions_policy, 'the admissions filter was ignored').toBe('Non-selective');
const tags = await rowTags(page, school);
await expect(tags).toBeVisible();
await expect(tags.getByText('Selective', { exact: true })).toHaveCount(0);
});
test('a school with no religious character carries no faith tag in search', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&faith=none&page_size=100');
expect(res.ok()).toBeTruthy();
// Not a selective school: the Selective tag would win and hide the bug.
const school = ((await res.json()).schools ?? []).find(
(s: { religious_denomination?: string; admissions_policy?: string }) =>
s.religious_denomination === 'None' && !/selective/i.test(s.admissions_policy ?? ''));
test.skip(!school, 'no school recorded with religious character "None" here');
const tags = await rowTags(page, school);
await expect(tags).toBeVisible();
await expect(tags.getByText('Faith priority', { exact: true })).toHaveCount(0);
await expect(tags.getByText('None', { exact: true })).toHaveCount(0);
});
test('search and the school page agree on how many pupils a secondary has', async ({ page }) => {
// Search showed the GCSE year group as "pupils": Burntwood had 245 in
// search and 1,462 on its page. Both now carry the register's count.
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=20');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { total_pupils?: number | null }) => s.total_pupils != null);
test.skip(!school, 'no secondary with a pupil count in this environment');
const detail = await (await page.request.get(`/api/schools/${school.urn}`)).json();
expect(school.total_pupils).toBe(detail.school_info.total_pupils);
});
test('a secondary search row compares its Attainment 8 with the LA average', async ({ page }) => {
// The comparison vanished unnoticed: the averages were fetched with
// force-cache, so one stored failure hid it in that browser for good.
// Playwright disables the HTTP cache when it intercepts requests, so this
// guards the comparison itself; the unit test pins the cache mode.
const la = await (await page.request.get('/api/la-averages')).json();
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) =>
s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? ''));
test.skip(!school, 'no mainstream secondary with an LA average here');
await searchByName(page, school.school_name);
const link = page.locator(`a[href^="/school/${school.urn}-"]`).first();
await expect(link).toBeVisible({ timeout: 15_000 });
const stats = link.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
.locator('[class*="__line3"]');
await expect(stats.getByText(/vs LA avg/)).toBeVisible();
});
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => { test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
// The search page offers every GIAS phase, but the API only knew the grouped // The search page offers every GIAS phase, but the API only knew the grouped
// ones and silently dropped the rest — so "Nursery" returned primaries. // ones and silently dropped the rest — so "Nursery" returned primaries.
@@ -432,9 +510,10 @@ test('school detail page shows GIAS identity/contact details and drops the unwir
if (info.telephone) { if (info.telephone) {
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible(); await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
} }
if (info.parliamentary_constituency) { // Constituency and county left the header in the facts-and-flags redesign:
await expect(page.getByText('Constituency:').first()).toBeVisible(); // neither helps a parent decide. The place pages keep both.
} await expect(page.getByText('Constituency:')).toHaveCount(0);
await expect(page.getByText('County:')).toHaveCount(0);
}); });
test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => { test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => {
@@ -544,7 +623,7 @@ test('school with no performance data still gets a working detail page', async (
const candidates: number[] = []; const candidates: number[] = [];
for (const q of ['post 16', 'specialist college', 'sixth form']) { for (const q of ['post 16', 'specialist college', 'sixth form']) {
const resp = await page.request.get( const resp = await page.request.get(
`/api/schools?search=${encodeURIComponent(q)}&per_page=20` `/api/schools?search=${encodeURIComponent(q)}&page_size=20`
); );
if (!resp.ok()) continue; if (!resp.ok()) continue;
const body = await resp.json(); const body = await resp.json();
@@ -1123,7 +1202,7 @@ test('compare metric-help popover stays within the mobile viewport', async ({ pa
test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => { test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => {
// Find a school with at least two years carrying an offer rate — the toggle // Find a school with at least two years carrying an offer rate — the toggle
// only appears then. Data-invariant: uses whatever the environment holds. // only appears then. Data-invariant: uses whatever the environment holds.
const res = await page.request.get('/api/schools?search=primary&per_page=50'); const res = await page.request.get('/api/schools?search=primary&page_size=50');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn); const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn);
@@ -2083,7 +2162,7 @@ test('English schools with Welsh postcodes are kept', async ({ page }) => {
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => { test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
// Paired on purpose: the Welsh assertion alone would also pass if the whole // Paired on purpose: the Welsh assertion alone would also pass if the whole
// site were down, which is the failure this test most needs to distinguish. // site were down, which is the failure this test most needs to distinguish.
const english = await page.request.get('/api/schools?search=primary&per_page=1'); const english = await page.request.get('/api/schools?search=primary&page_size=1');
expect(english.ok()).toBeTruthy(); expect(english.ok()).toBeTruthy();
const [first] = (await english.json()).schools ?? []; const [first] = (await english.json()).schools ?? [];
expect(first, 'no English school available to compare against').toBeTruthy(); expect(first, 'no English school available to compare against').toBeTruthy();
@@ -2193,7 +2272,7 @@ test('a filtered homepage still canonicalises to the bare root', async ({ page }
}); });
test('a school page canonicalises to its own slug on the www host', async ({ page }) => { test('a school page canonicalises to its own slug on the www host', async ({ page }) => {
const res = await page.request.get('/api/schools?search=primary&per_page=1'); const res = await page.request.get('/api/schools?search=primary&page_size=1');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const [first] = (await res.json()).schools ?? []; const [first] = (await res.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy(); expect(first, 'no school available').toBeTruthy();
@@ -2268,7 +2347,7 @@ function blocksEverything(robots: string, agent: string): boolean {
} }
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => { test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
const list = await page.request.get('/api/schools?search=primary&per_page=1'); const list = await page.request.get('/api/schools?search=primary&page_size=1');
const [first] = (await list.json()).schools ?? []; const [first] = (await list.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy(); expect(first, 'no school available').toBeTruthy();
@@ -2879,7 +2958,7 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
async function secondaryWithDestinations(page: Page): Promise<{ async function secondaryWithDestinations(page: Page): Promise<{
urn: string; destinations: any; urn: string; destinations: any;
}> { }> {
const res = await page.request.get('/api/schools?search=school&per_page=100'); const res = await page.request.get('/api/schools?search=school&page_size=100');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const body = await res.json(); const body = await res.json();
const urns: string[] = (body.schools ?? []) const urns: string[] = (body.schools ?? [])
@@ -2975,7 +3054,7 @@ test('switching to disadvantaged pupils never reveals a withheld figure', async
}); });
test('a school with no sixth form has no post-16 destinations section', async ({ page }) => { test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&per_page=100'); const res = await page.request.get('/api/schools?search=school&page_size=100');
const body = await res.json(); const body = await res.json();
const noSixthForm = (body.schools ?? []) const noSixthForm = (body.schools ?? [])
.filter((s: { phase?: string; has_sixth_form?: boolean }) => .filter((s: { phase?: string; has_sixth_form?: boolean }) =>
@@ -3286,3 +3365,85 @@ for (const width of [360, 390, 430]) {
expect(failing).toEqual([]); expect(failing).toEqual([]);
}); });
} }
/*
* The header's facts row read GIAS text as booleans: "Not applicable" put a
* "Nursery" chip on secondaries aged 11–18, and "Girls" became "Girls's
* school". Data-invariant: the chip follows whatever the API says.
*/
test('a girls\' secondary header names it properly and shows Nursery only when it has one', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&phase=secondary&gender=girls&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no girls\' secondary in this environment');
expect(school.gender, 'the gender filter was ignored').toBe('Girls');
const detail = await (await page.request.get(`/api/schools/${school.urn}`)).json();
// Without the field, the Nursery assertion below would pass vacuously.
expect(detail.school_info).toHaveProperty('nursery_provision');
await page.goto(`/school/${school.urn}`);
const header = page.locator('header', { has: page.getByRole('heading', { level: 1 }) });
await expect(header.getByText("Girls' school", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(header.getByText(/'s school/)).toHaveCount(0);
await expect(header.getByText('Nursery class', { exact: true }))
.toHaveCount(detail.school_info.nursery_provision === 'Has Nursery Classes' ? 1 : 0);
});
/*
* The header states facts in fixed slots and flags only what applies: a
* selective school is flagged Selective, and its type reads in the search
* filter's words, not as a GIAS establishment type. The search row carries
* the same flag. Data-invariant: the school comes from the API.
*/
const TYPE_GROUP_LABELS: Record<string, string> = {
state: 'State school', independent: 'Independent school', special: 'Special school (SEND)',
post16: 'Sixth form or college', alternative: 'Alternative provision',
};
test('a selective school is flagged Selective, on its page and in search', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&admissions_policy=selective&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no selective secondary in this environment');
expect(school.admissions_policy, 'the admissions filter was ignored').toBe('Selective');
expect(school, 'the list must name the type group').toHaveProperty('type_group');
await page.goto(`/school/${school.urn}`);
const header = page.locator('header', { has: page.getByRole('heading', { level: 1 }) });
const flags = header.getByRole('list', { name: 'Admission and provision' });
await expect(flags.getByText('Selective', { exact: true })).toBeVisible({ timeout: 15_000 });
const typeLabel = TYPE_GROUP_LABELS[school.type_group] ?? school.school_type;
await expect(header.getByText(typeLabel, { exact: true })).toBeVisible();
const tags = await rowTags(page, school);
await expect(tags.getByText('Selective', { exact: true })).toBeVisible();
});
/*
* The Admissions section repeated the search rows' old tag logic: "selective"
* matched inside "Non-selective", and "None" counted as a faith, so Burntwood
* read "this school has a faith-based admissions priority (None)". The
* Selective half stayed hidden only because the school page's API did not
* send admissions_policy. Data-invariant: the school comes from the API, and
* must have an Admissions section to read.
*/
test('a non-selective school with no faith makes neither claim in its Admissions section', async ({ page }) => {
const res = await page.request.get(
'/api/schools?search=school&phase=secondary&faith=none&admissions_policy=non-selective&page_size=20');
expect(res.ok()).toBeTruthy();
let urn: number | null = null;
for (const s of ((await res.json()).schools ?? []).slice(0, 8)) {
if (s.religious_denomination !== 'None') continue;
const detail = await (await page.request.get(`/api/schools/${s.urn}`)).json();
if (detail.admissions) { urn = s.urn; break; }
}
test.skip(urn == null, 'no non-selective, no-faith secondary with admissions data here');
await page.goto(`/school/${urn}`);
const admissions = page.locator('section#admissions');
await expect(admissions).toBeVisible({ timeout: 15_000 });
await expect(admissions.getByText('Selective:', { exact: true })).toHaveCount(0);
await expect(admissions.getByText('Faith priority:', { exact: true })).toHaveCount(0);
await expect(admissions.getByText(/\(None\)/)).toHaveCount(0);
});
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import { HomeView } from '@/components/HomeView'; import { HomeView } from '@/components/HomeView';
import { fetchSchools } from '@/lib/api'; import { fetchLAaverages, fetchSchools } from '@/lib/api';
import { primaryFixture } from '../support/schoolFixtures'; import { primaryFixture } from '../support/schoolFixtures';
import type { SchoolsResponse, School } from '@/lib/types'; import type { SchoolsResponse, School } from '@/lib/types';
@@ -84,3 +84,23 @@ test('failed map requests can be retried by reopening the map', async () => {
expect(fetchSchools).toHaveBeenCalledTimes(2); expect(fetchSchools).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('map')).toHaveTextContent('Retry result'); expect(screen.getByTestId('map')).toHaveTextContent('Retry result');
}); });
test('LA averages are not fetched with force-cache, so one failure is not replayed for good', async () => {
// force-cache serves any stored response, however old, without asking the
// server. A request that failed once (a staging deploy restart, the July
// proxy outage) was stored and replayed on every later visit, and the
// "vs LA avg" delta vanished from every secondary row in that browser.
// The default mode honours the API's Cache-Control and never reuses an
// error.
params = new URLSearchParams('search=high');
const secondary: SchoolsResponse = {
...response('Alpha High'),
schools: [{ ...primaryFixture.schoolInfo, school_name: 'Alpha High', phase: 'Secondary', attainment_8_score: 50 }],
};
render(<HomeView initialSchools={secondary} filters={filters} />);
await act(async () => {});
expect(fetchLAaverages).toHaveBeenCalled();
for (const [options] of jest.mocked(fetchLAaverages).mock.calls) {
expect(options?.cache).not.toBe('force-cache');
}
});
@@ -361,12 +361,12 @@ describe('PlaceView school attributes', () => {
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary', { urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null, rwm_expected_pct: 82, attainment_8_score: null,
age_range: '4-11', religious_denomination: 'Church of England', age_range: '4-11', religious_denomination: 'Church of England',
nursery_provision: true, nursery_provision: 'Has Nursery Classes',
parliamentary_constituency: 'Chelmsford' } as never, parliamentary_constituency: 'Chelmsford' } as never,
{ urn: 2, school_name: 'Beta High', phase: 'Secondary', { urn: 2, school_name: 'Beta High', phase: 'Secondary',
rwm_expected_pct: null, attainment_8_score: 47, rwm_expected_pct: null, attainment_8_score: 47,
age_range: '11-16', religious_denomination: 'Does not apply', age_range: '11-16', religious_denomination: 'Does not apply',
nursery_provision: false, nursery_provision: 'No Nursery Classes',
parliamentary_constituency: 'Witham' } as never, parliamentary_constituency: 'Witham' } as never,
], ],
averages: { rwm_expected_pct: 63, attainment_8_score: 45 }, averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
@@ -459,6 +459,18 @@ describe('PlaceView school attributes', () => {
expect(cells.slice(2)).toEqual(['—', '—', '—', '—']); expect(cells.slice(2)).toEqual(['—', '—', '—', '—']);
}); });
it('reads "Not applicable" as no nursery, not as a yes', () => {
// GIAS sends text. Tested for truthiness, every value was a "Yes".
const notApplicable: PlaceDetail = {
...withAttributes,
schools: [{ urn: 5, school_name: 'Epsilon Primary', phase: 'Primary',
rwm_expected_pct: 70, nursery_provision: 'Not applicable' } as never],
};
const { container } = render(<PlaceView detail={notApplicable} phase="primary"
englandAverage={61} neighbours={[]} />);
expect(container.querySelector('tbody')!.textContent).not.toContain('Yes');
});
it('gives an all-through school its nursery under primary only', () => { it('gives an all-through school its nursery under primary only', () => {
// All-through schools render in both groups. Nursery belongs to the // All-through schools render in both groups. Nursery belongs to the
// primary reading of the same school, not the secondary one. // primary reading of the same school, not the secondary one.
@@ -467,7 +479,7 @@ describe('PlaceView school attributes', () => {
schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through', schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through',
rwm_expected_pct: 66, attainment_8_score: 51, rwm_expected_pct: 66, attainment_8_score: 51,
age_range: '4-18', religious_denomination: 'None', age_range: '4-18', religious_denomination: 'None',
nursery_provision: true, nursery_provision: 'Has Nursery Classes',
parliamentary_constituency: 'Chelmsford' } as never], parliamentary_constituency: 'Chelmsford' } as never],
}; };
const { container } = render(<PlaceView detail={allThrough} const { container } = render(<PlaceView detail={allThrough}
@@ -0,0 +1,54 @@
/**
* SchoolRow (primary search results): line 2 prints the religious character
* only when the school has one. The register's "None" was printed as a chip.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SchoolRow } from '@/components/SchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100001,
school_name: 'Alpha Primary School',
local_authority: 'Testshire',
school_type: 'Free schools',
phase: 'Primary',
gender: 'Mixed',
age_range: '4-11',
rwm_expected_pct: 70,
} as unknown as School;
describe('SchoolRow religious character', () => {
it.each(['None', 'Does not apply', ''])(
'prints nothing when the register says %p',
(religious_denomination) => {
render(<SchoolRow school={{ ...base, religious_denomination }} />);
expect(screen.queryByText('None')).not.toBeInTheDocument();
expect(screen.queryByText('Does not apply')).not.toBeInTheDocument();
},
);
it('prints a religious character the school has', () => {
render(<SchoolRow school={{ ...base, religious_denomination: 'Church of England' }} />);
expect(screen.getByText('Church of England')).toBeInTheDocument();
});
});
describe('SchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Free schools')).not.toBeInTheDocument();
});
it('flags a nursery class', () => {
render(<SchoolRow school={{ ...base, nursery_provision: 'Has Nursery Classes' }} />);
expect(screen.getByText('Nursery class')).toBeInTheDocument();
});
it("flags a boys' school", () => {
render(<SchoolRow school={{ ...base, gender: 'Boys' }} />);
expect(screen.getByText("Boys' school")).toBeInTheDocument();
});
});
@@ -65,3 +65,54 @@ describe('SecondarySchoolRow proposed-to-close tag', () => {
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument(); expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
}); });
}); });
describe('SecondarySchoolRow admissions tag', () => {
it('tags a selective school', () => {
render(<SecondarySchoolRow school={{ ...base, admissions_policy: 'Selective' }} />);
expect(screen.getByText('Selective')).toBeInTheDocument();
});
it('does not tag a non-selective school as selective', () => {
// "Non-selective" contains "selective": a substring test tagged every
// comprehensive (Burntwood, Graveney) as Selective.
render(<SecondarySchoolRow school={{ ...base, admissions_policy: 'Non-selective' }} />);
expect(screen.queryByText('Selective')).not.toBeInTheDocument();
});
it.each(['None', 'Does not apply'])(
'gives no faith tag when the religious character is %p',
(religious_denomination) => {
render(
<SecondarySchoolRow
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination }}
/>,
);
expect(screen.queryByText(religious_denomination)).not.toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
},
);
it('tags the religious character the register records, not "Faith priority"', () => {
render(
<SecondarySchoolRow
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination: 'Church of England' }}
/>,
);
expect(screen.getByText('Church of England')).toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Academy')).not.toBeInTheDocument();
});
it("flags a girls' school and fees", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'independent', gender: 'Girls' }} />);
expect(screen.getByText("Girls' school")).toBeInTheDocument();
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
/**
* The school header: one fact line (phase, ages, type, pupils), then flags
* only for what applies, then the address and the details.
*
* nursery_provision is GIAS text, not a boolean: "Has Nursery Classes",
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
* of those read as a nursery, so secondaries aged 11–18 showed "Nursery".
*/
import { screen, within } from '@testing-library/react';
import type { School } from '@/lib/types';
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/SatsChart', () => ({
__esModule: true,
default: () => <div data-testid="sats-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function withSchool<T extends { schoolInfo: School }>(fixture: T, info: Partial<School>): T {
return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
}
const flagList = () => screen.queryByRole('list', { name: 'Admission and provision' });
const flagLabels = () => within(flagList()!).getAllByRole('listitem').map((li) => li.textContent);
describe('school header fact line', () => {
it('states phase, ages, type and pupils, in that order', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
const line = screen.getByText('Ages 4–11').parentElement!;
expect(line).toHaveTextContent(/^PrimaryAges 4–11State school420 pupils$/);
});
it("prints the register's type name for a type in no group", () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: null, school_type: 'Secure units' }));
expect(screen.getByText('Secure units')).toBeInTheDocument();
});
it('no longer prints the GIAS establishment type', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { type_group: 'state' }));
expect(screen.queryByText('Academy converter')).not.toBeInTheDocument();
});
});
describe('school header flags', () => {
it('shows no flag list for a school with nothing to flag', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
expect(flagList()).not.toBeInTheDocument();
});
it('lists who-can-apply flags, then what the school offers', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, {
type_group: 'independent', admissions_policy: 'Selective', gender: 'Girls',
religious_denomination: 'Church of England', has_sixth_form: true,
}));
expect(flagLabels()).toEqual(['Fee-paying', 'Selective', "Girls' school", 'Church of England', 'Sixth form']);
});
it('shows Nursery class when GIAS says the school has nursery classes', () => {
renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
expect(flagLabels()).toEqual(['Nursery class']);
});
it.each(['No Nursery Classes', 'Not applicable', null])(
'shows no nursery flag when GIAS says %p',
(value) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
expect(screen.queryByText(/^Nursery/)).not.toBeInTheDocument();
},
);
it.each([['Girls', "Girls' school"], ['Boys', "Boys' school"]])(
'labels a %s school with a plural possessive',
(gender, label) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender }));
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.queryByText(/'s school/)).not.toBeInTheDocument();
},
);
it('says nothing for a mixed school', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender: 'Mixed' }));
expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
});
});
describe('school header address', () => {
it('names the council after the postcode', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText(/TE1 1ST · Westshire/)).toBeInTheDocument();
});
it('leaves the council out when the address already names it', () => {
renderSchoolDetail(withSchool(primaryFixture, { local_authority: 'Testville' }));
expect(screen.queryByText(/· Testville/)).not.toBeInTheDocument();
});
});
describe('school header details', () => {
const detailed = {
headteacher_name: 'Mrs A Head', capacity: 426, county: 'Surrey',
parliamentary_constituency: 'Putney', religious_denomination: 'Church of England',
};
it('drops county, constituency and religious character', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.queryByText('County:')).not.toBeInTheDocument();
expect(screen.queryByText('Constituency:')).not.toBeInTheDocument();
expect(screen.queryByText('Religious character:')).not.toBeInTheDocument();
});
it('shows the capacity', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.getByText('Capacity:').parentElement).toHaveTextContent('Capacity: 426');
});
it('names an academy trust', () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'BURNTWOOD TRUST' }));
expect(screen.getByText('Academy trust:').parentElement).toHaveTextContent('BURNTWOOD TRUST');
});
it("hides a trust that has the school's own name", () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'TEST PRIMARY SCHOOL' }));
expect(screen.queryByText('Academy trust:')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
/**
* "Pupils" on a school page is the size of the school.
*
* A year's results row carries the cohort its figures were measured on. For a
* secondary that is the GCSE year group alone (Burntwood: 245, against 1,462
* on roll), so it must never stand in for the whole-school count.
*/
import { screen } from '@testing-library/react';
import { secondaryFixture } from '../support/schoolFixtures';
import { renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/SatsChart', () => ({
__esModule: true,
default: () => <div data-testid="sats-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function withoutCensus(schoolTotal: number | null) {
const yearlyData = secondaryFixture.yearlyData.map((r) => ({ ...r, total_pupils: 245 }));
return {
...secondaryFixture,
census: null,
yearlyData,
schoolInfo: { ...secondaryFixture.schoolInfo, total_pupils: schoolTotal },
};
}
describe('pupil count without a census record', () => {
it('uses the register count in the header, not the results cohort', () => {
renderSecondarySchoolDetail(withoutCensus(1462));
expect(screen.getByText('1,462 pupils')).toBeInTheDocument();
expect(screen.queryByText('245 pupils')).not.toBeInTheDocument();
});
it('shows no count rather than the results cohort when the register has none', () => {
renderSecondarySchoolDetail(withoutCensus(null));
expect(screen.queryByText(/^[\d,]+ pupils$/)).not.toBeInTheDocument();
expect(screen.queryByText('Total pupils')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
/**
* The Admissions section's Selective and faith notes.
*
* It tested the policy with includes('selective'), which "Non-selective"
* passes, and excluded only "Does not apply" from the religious character, so
* Burntwood (no religious character, recorded as "None") read "this school
* has a faith-based admissions priority (None)". The Selective half never
* fired only because the school page's API did not send admissions_policy.
*/
import { screen, within } from '@testing-library/react';
import type { School } from '@/lib/types';
import { secondaryFixture } from '../support/schoolFixtures';
import { renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
jest.mock('@/lib/analytics', () => ({
track: jest.fn(),
getNavigationSource: () => 'direct',
}));
jest.mock('@/components/PerformanceChart', () => ({
PerformanceChart: () => <div data-testid="performance-chart" />,
}));
jest.mock('@/components/AdmissionsTrendChart', () => ({
__esModule: true,
default: () => <div data-testid="admissions-trend-chart" />,
}));
jest.mock('@/components/SchoolHeroMap', () => ({
SchoolHeroMap: () => <div data-testid="hero-map" />,
__esModule: true,
}));
function admissionsOf(info: Partial<School>) {
renderSecondarySchoolDetail({ ...secondaryFixture, schoolInfo: { ...secondaryFixture.schoolInfo, ...info } });
return within(screen.getByRole('heading', { name: 'Admissions' }).closest('section')!);
}
describe('Admissions section notes', () => {
it('notes the entrance test for a selective school', () => {
const section = admissionsOf({ admissions_policy: 'Selective', religious_denomination: 'None' });
expect(section.getByText('Selective:')).toBeInTheDocument();
});
it('does not call a non-selective school selective', () => {
const section = admissionsOf({ admissions_policy: 'Non-selective', religious_denomination: 'None' });
expect(section.queryByText('Selective:')).not.toBeInTheDocument();
});
it.each(['None', 'Does not apply'])('claims no faith priority when the register says %p', (religious_denomination) => {
// Not "Non-selective": a misread Selective would win and hide this case.
const section = admissionsOf({ admissions_policy: 'Not applicable', religious_denomination });
expect(section.queryByText('Faith priority:')).not.toBeInTheDocument();
});
it('notes the religious character of a faith school', () => {
const section = admissionsOf({ admissions_policy: 'Non-selective', religious_denomination: 'Church of England' });
expect(section.getByText('Faith priority:')).toBeInTheDocument();
});
});
@@ -157,6 +157,13 @@ describe('compareToCutoff', () => {
}); });
describe('describeCutoffAbsence', () => { describe('describeCutoffAbsence', () => {
it('does not read "Non-selective" as selective', () => {
// A substring test matched "selective" inside "Non-selective".
const s = describeCutoffAbsence({ localAuthority: 'Wandsworth', admissionsPolicy: 'Non-selective' });
expect(s).not.toMatch(/entrance test/);
expect(s).toMatch(/^Wandsworth has not published/);
});
it('explains a selective school by how it admits, not as missing data', () => { it('explains a selective school by how it admits, not as missing data', () => {
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' }); const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
expect(s).toContain('entrance test'); expect(s).toContain('entrance test');
@@ -0,0 +1,74 @@
/**
* The facts the school header and the search rows print: a type in the search
* filter's terms, and flags only for what applies.
*/
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { School } from '@/lib/types';
const school = (over: Partial<School>): School =>
({ urn: 1, school_name: 'Test School', ...over }) as School;
const labels = (over: Partial<School>) => schoolFlags(school(over)).map((f) => f.label);
describe('schoolTypeLabel', () => {
it.each([
['state', 'State school'],
['independent', 'Independent school'],
['special', 'Special school (SEND)'],
['post16', 'Sixth form or college'],
['alternative', 'Alternative provision'],
])("names the %s group in the search filter's terms", (type_group, label) => {
expect(schoolTypeLabel(school({ type_group, school_type: 'Academy converter' }))).toBe(label);
});
it("prints the register's own name for a type in no group", () => {
expect(schoolTypeLabel(school({ type_group: null, school_type: 'Secure units' }))).toBe('Secure units');
});
it('returns null when there is no type at all', () => {
expect(schoolTypeLabel(school({}))).toBeNull();
});
});
describe('schoolFlags', () => {
it('flags nothing for a mixed, non-faith, non-selective state school', () => {
expect(labels({
type_group: 'state', gender: 'Mixed', admissions_policy: 'Non-selective',
religious_denomination: 'None', nursery_provision: 'No Nursery Classes',
has_sixth_form: false, phase: 'Secondary',
})).toEqual([]);
});
it('lists who-can-apply flags first, then what the school offers, in a fixed order', () => {
expect(labels({
type_group: 'independent', admissions_policy: 'Selective', gender: 'Boys',
religious_denomination: 'Christian', nursery_provision: 'Has Nursery Classes',
has_sixth_form: true, phase: 'All-through',
})).toEqual(['Fee-paying', 'Selective', "Boys' school", 'Christian', 'Nursery class', 'Sixth form']);
});
it('marks who-can-apply flags as conditions and offers as provision', () => {
const kinds = Object.fromEntries(
schoolFlags(school({ admissions_policy: 'Selective', has_sixth_form: true, phase: 'Secondary' }))
.map((f) => [f.label, f.kind]),
);
expect(kinds).toEqual({ Selective: 'condition', 'Sixth form': 'provision' });
});
it('never prints Non-selective, and never a no-faith value', () => {
expect(labels({ admissions_policy: 'Non-selective', religious_denomination: 'Does not apply' })).toEqual([]);
expect(labels({ admissions_policy: 'Not applicable', religious_denomination: 'None' })).toEqual([]);
// PlaceView's rule: GIAS sometimes says "Not applicable" for no faith too.
expect(labels({ religious_denomination: 'Not applicable' })).toEqual([]);
});
it('prints the religious character as the register records it', () => {
expect(labels({ religious_denomination: 'Church of England/Methodist' })).toEqual(['Church of England/Methodist']);
});
it('does not flag a nursery class on a nursery school, or a sixth form on a post-16 one', () => {
expect(labels({ phase: 'Nursery', nursery_provision: 'Has Nursery Classes' })).toEqual([]);
expect(labels({ phase: '16 plus', has_sixth_form: true })).toEqual([]);
});
});
+31
View File
@@ -15,6 +15,8 @@ import {
computeYBounds, computeYBounds,
formatAgeRange, formatAgeRange,
formatAgeSpan, formatAgeSpan,
hasNurseryClasses,
singleSexLabel,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -346,3 +348,32 @@ describe('formatAgeRange', () => {
expect(formatAgeRange('4-11')).toBe('Ages 4–11'); expect(formatAgeRange('4-11')).toBe('Ages 4–11');
}); });
}); });
describe('hasNurseryClasses', () => {
it('is true only for the GIAS value that means it', () => {
// GIAS sends text, and two of its three values mean no nursery.
expect(hasNurseryClasses('Has Nursery Classes')).toBe(true);
expect(hasNurseryClasses('No Nursery Classes')).toBe(false);
expect(hasNurseryClasses('Not applicable')).toBe(false);
expect(hasNurseryClasses(null)).toBe(false);
expect(hasNurseryClasses(undefined)).toBe(false);
});
});
describe('singleSexLabel', () => {
it('uses the plural possessive GIAS values need', () => {
expect(singleSexLabel('Girls')).toBe("Girls' school");
expect(singleSexLabel('Boys')).toBe("Boys' school");
});
it('ignores case, as hasNurseryClasses does', () => {
expect(singleSexLabel(' girls ')).toBe("Girls' school");
expect(singleSexLabel('BOYS')).toBe("Boys' school");
});
it('returns null for a mixed or unknown school', () => {
expect(singleSexLabel('Mixed')).toBeNull();
expect(singleSexLabel(null)).toBeNull();
expect(singleSexLabel(undefined)).toBeNull();
});
});
@@ -39,7 +39,6 @@ export function renderSchoolDetail(fixture: any) {
withProviders( withProviders(
<SchoolDetailShell <SchoolDetailShell
schoolInfo={fixture.schoolInfo} schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census} census={fixture.census}
navItems={navItems} navItems={navItems}
> >
@@ -67,7 +66,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
withProviders( withProviders(
<SchoolDetailShell <SchoolDetailShell
schoolInfo={fixture.schoolInfo} schoolInfo={fixture.schoolInfo}
yearlyData={fixture.yearlyData}
census={fixture.census} census={fixture.census}
navItems={navItems} navItems={navItems}
> >
@@ -249,7 +249,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
{isSecondary ? ( {isSecondary ? (
<SchoolDetailShell <SchoolDetailShell
schoolInfo={school_info} schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null} census={census ?? null}
navItems={secondaryNavItems} navItems={secondaryNavItems}
> >
@@ -273,7 +272,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
) : ( ) : (
<SchoolDetailShell <SchoolDetailShell
schoolInfo={school_info} schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null} census={census ?? null}
navItems={primaryNavItems} navItems={primaryNavItems}
> >
+6 -2
View File
@@ -358,10 +358,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
return () => controller.abort(); return () => controller.abort();
}, [resultsView, searchParams, initialSchools.schools]); }, [resultsView, searchParams, initialSchools.schools]);
// Fetch LA averages when secondary or mixed schools are visible // Fetch LA averages when secondary or mixed schools are visible. Default
// cache mode, never force-cache: force-cache replays any stored response
// without asking the server, so one failed request (a deploy restart) hid
// every "vs LA avg" delta in that browser for good. The API's Cache-Control
// already lets the browser reuse a good answer for five minutes.
useEffect(() => { useEffect(() => {
if (!isSecondaryView && !isMixedView) return; if (!isSecondaryView && !isMixedView) return;
fetchLAaverages({ cache: 'force-cache' }) fetchLAaverages()
.then(data => setLaAverages(data.secondary.attainment_8_by_la)) .then(data => setLaAverages(data.secondary.attainment_8_by_la))
.catch(() => {}); .catch(() => {});
}, [isSecondaryView, isMixedView]); }, [isSecondaryView, isMixedView]);
@@ -100,6 +100,19 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
a verdict. An inset ring keeps the box the size of its neighbours. */
.conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
/* Line 3: stats */ /* Line 3: stats */
.line3 { .line3 {
display: flex; display: flex;
+13 -8
View File
@@ -3,13 +3,14 @@
* Four-line row for primary school search results * Four-line row for primary school search results
* *
* Line 1: School name · Ofsted badge (framework-aware) * Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender * Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils * Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance * Line 4: Local authority · Distance
*/ */
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils'; import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SchoolRow.module.css'; import styles from './SchoolRow.module.css';
interface SchoolRowProps { interface SchoolRowProps {
@@ -33,10 +34,8 @@ export function SchoolRow({
const phase = getPhaseStyle(school.phase); const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school); const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed'; const typeLabel = schoolTypeLabel(school);
const showDenomination = const flags = schoolFlags(school);
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// The school's OWN figure and its year-over-year trend are same-school // The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value (not the all-zero // measures — shown whenever there's a real value (not the all-zero
@@ -79,10 +78,16 @@ export function SchoolRow({
{phase.label} {phase.label}
</span> </span>
)} )}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>} {typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>} {flags.map((flag) => (
{showGender && <span className={styles.attr}>{school.gender}</span>} <span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.attr}
>
{flag.label}
</span>
))}
{isProposedToClose(school) && ( {isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span> <span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
)} )}
@@ -183,9 +183,17 @@
white-space: nowrap; white-space: nowrap;
} }
.selectiveTag { /* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
background: rgba(var(--status-below-rgb), 0.1); a verdict. An inset ring keeps the box the size of its neighbours. */
color: var(--status-below); .conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
} }
/* ── Ofsted badge ────────────────────────────────────── */ /* ── Ofsted badge ────────────────────────────────────── */
+12 -28
View File
@@ -3,7 +3,7 @@
* Four-line row for secondary school search results * Four-line row for secondary school search results
* *
* Line 1: School name · Ofsted badge * Line 1: School name · Ofsted badge
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag * Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Attainment 8 (large) · ±LA avg delta · Pupils * Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
* Line 4: LA name · distance * Line 4: LA name · distance
*/ */
@@ -12,21 +12,9 @@
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SecondarySchoolRow.module.css'; import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
const policy = school.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = school.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
}
function hasSixthForm(school: School): boolean {
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
interface SecondarySchoolRowProps { interface SecondarySchoolRowProps {
school: School; school: School;
isLocationSearch?: boolean; isLocationSearch?: boolean;
@@ -64,9 +52,8 @@ export function SecondarySchoolRow({
? att8 - laAvgAttainment8 ? att8 - laAvgAttainment8
: null; : null;
const admissionsTag = detectAdmissionsTag(school); const typeLabel = schoolTypeLabel(school);
const sixthForm = hasSixthForm(school); const flags = schoolFlags(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
return ( return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}> <div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
@@ -90,19 +77,16 @@ export function SecondarySchoolRow({
{phase.label} {phase.label}
</span> </span>
)} )}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>} {typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showGender && ( {flags.map((flag) => (
<span className={styles.provisionTag}>{school.gender}</span> <span
)} key={flag.label}
{sixthForm && ( className={flag.kind === 'condition' ? styles.conditionTag : styles.provisionTag}
<span className={styles.provisionTag}>Sixth form</span> >
)} {flag.label}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
</span> </span>
)} ))}
{isProposedToClose(school) && ( {isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span> <span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
)} )}
+5 -4
View File
@@ -13,7 +13,7 @@ import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places'; import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places'; import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { schoolUrl, formatAgeSpan } from '@/lib/utils'; import { schoolUrl, formatAgeSpan, hasNurseryClasses } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site'; import { absoluteUrl } from '@/lib/site';
import { TrackPlaceView } from './TrackPlaceView'; import { TrackPlaceView } from './TrackPlaceView';
import styles from './PlaceView.module.css'; import styles from './PlaceView.module.css';
@@ -131,9 +131,10 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
{showNursery && ( {showNursery && (
<td className={styles.attr}> <td className={styles.attr}>
{/* Undefined is a mart the pipeline has not rebuilt, and {/* Undefined is a mart the pipeline has not rebuilt, and
false is a school without one. Neither is a "Yes", and "No Nursery Classes" or "Not applicable" is a school
neither is worth two different words. */} without one. None is a "Yes", and none is worth a
{s.nursery_provision ? 'Yes' : NO_VALUE} different word. */}
{hasNurseryClasses(s.nursery_provision) ? 'Yes' : NO_VALUE}
</td> </td>
)} )}
<td className={styles.attrWide}> <td className={styles.attrWide}>
@@ -145,20 +145,90 @@
} }
.meta { /* Fact line: the phase pill, then plain register values joined by dots.
The line starts 1.125rem left of the column and clips that strip, so a
value that wraps to the start of a line loses the dot in front of it
instead of opening the line with one. */
.facts {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; align-items: center;
margin-bottom: 0.5rem; row-gap: 0.25rem;
margin: 0 0 0.5rem -1.125rem;
clip-path: inset(0 0 0 1.125rem);
font-size: 0.875rem;
color: var(--text-secondary);
} }
.metaItem { .facts > * {
font-size: 0.8125rem; margin-left: 1.125rem;
}
.fact {
position: relative;
}
.fact::before {
content: "·";
position: absolute;
left: -0.75rem;
color: var(--text-muted);
}
/* The search rows' phase pill, in the same phase colours. */
.phasePill {
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
}
.phasePillPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.phasePillSecondary { background: var(--phase-secondary-bg); color: var(--phase-secondary-text); }
.phasePillAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
.phasePillPost16 { background: var(--phase-post16-bg); color: var(--phase-post16-text); }
.phasePillNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
/* Flags are facts, not verdicts, so they carry no hue. Outlined changes who
can apply or what it costs; filled is what the school offers. Text may
wrap: the longest religious character runs past a 360px line. */
.flags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0 0 0.625rem;
padding: 0;
list-style: none;
}
.flagCondition,
.flagProvision {
padding: 0.0625rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border: 1px solid transparent;
border-radius: 4px;
}
.flagCondition {
border-color: rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
.flagProvision {
background: rgba(var(--ink-rgb), 0.07);
color: var(--text-secondary); color: var(--text-secondary);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary);
border-radius: 3px;
} }
@@ -703,17 +773,8 @@
word-break: break-word; word-break: break-word;
} }
/* Pills wrap horizontally instead of stacking — short tokens like /* Secondary header info (headteacher, website, phone, trust, capacity)
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full isn't needed above the fold on phones/tablets, so it's
rows of empty horizontal space. */
.meta {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Secondary header info (headteacher, website, pupil count, trust,
contact, area) isn't needed above the fold on phones/tablets, so it's
collapsed by default and revealed on demand via the "Show all details" collapsed by default and revealed on demand via the "Show all details"
link — reclaiming the vertical space so the metrics surface sooner. */ link — reclaiming the vertical space so the metrics surface sooner. */
.detailsToggle { .detailsToggle {
@@ -20,8 +20,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap'; import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type { School, SchoolResult, SchoolCensus } from '@/lib/types'; import type { School, SchoolCensus } from '@/lib/types';
import { formatAgeRange, isProposedToClose } from '@/lib/utils'; import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { NavItem } from '@/lib/schoolSections'; import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics'; import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css'; import styles from './SchoolDetailShell.module.css';
@@ -34,8 +35,6 @@ import styles from './SchoolDetailShell.module.css';
*/ */
export interface SchoolDetailShellProps { export interface SchoolDetailShellProps {
schoolInfo: School; schoolInfo: School;
/** Only for the header's pupil-count fallback. */
yearlyData: SchoolResult[];
census: SchoolCensus | null; census: SchoolCensus | null;
/** Section list for the sticky nav, computed on the server. */ /** Section list for the sticky nav, computed on the server. */
navItems: NavItem[]; navItems: NavItem[];
@@ -43,8 +42,14 @@ export interface SchoolDetailShellProps {
children: ReactNode; children: ReactNode;
} }
/** Equal ignoring case and punctuation: "TIFFIN SCHOOL" is Tiffin School. */
function sameName(a: string, b: string): boolean {
const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
return key(a) === key(b);
}
export function SchoolDetailShell({ export function SchoolDetailShell({
schoolInfo, yearlyData, census, navItems, children, schoolInfo, census, navItems, children,
}: SchoolDetailShellProps) { }: SchoolDetailShellProps) {
const router = useRouter(); const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison(); const { addSchool, removeSchool, isSelected } = useComparison();
@@ -63,7 +68,7 @@ export function SchoolDetailShell({
const heroMapRef = useRef<SchoolHeroMapHandle>(null); const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section. // "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false); const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a // Header details (headteacher, contact, trust, capacity) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold. // "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
@@ -122,15 +127,34 @@ export function SchoolDetailShell({
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]); }, [sectionsOpen]);
// The chrome needs only these four. The section-shape flags are computed // The chrome needs only these few. The section-shape flags are computed
// once on the server (lib/schoolSections) and consumed by the section // once on the server (lib/schoolSections) and consumed by the section
// composers; recomputing them here would duplicate that work for values // composers; recomputing them here would duplicate that work for values
// this component never renders. // this component never renders.
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const phase = schoolInfo.phase ?? ''; const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
// Header facts: each is one register value in a fixed slot (lib/schoolFacts).
const phasePill = getPhaseStyle(schoolInfo.phase);
// Never latestResults.total_pupils: that is the results cohort, which for a
// secondary is the GCSE year group alone.
const pupils = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
const facts = [
formatAgeRange(schoolInfo.age_range),
schoolTypeLabel(schoolInfo),
pupils != null ? `${pupils.toLocaleString()} pupils` : null,
].filter((fact): fact is string => !!fact);
const flags = schoolFlags(schoolInfo);
// The council, unless the address already names it.
const council = schoolInfo.local_authority
&& !(schoolInfo.address ?? '').toLowerCase().includes(schoolInfo.local_authority.toLowerCase())
? schoolInfo.local_authority
: null;
// A single-academy trust carries the school's own name, which says nothing.
const trust = schoolInfo.trust_name && !sameName(schoolInfo.trust_name, schoolInfo.school_name)
? schoolInfo.trust_name
: null;
const handleComparisonToggle = () => { const handleComparisonToggle = () => {
if (isInComparison) { if (isInComparison) {
removeSchool(schoolInfo.urn); removeSchool(schoolInfo.urn);
@@ -204,29 +228,31 @@ export function SchoolDetailShell({
<div className={styles.headerContent}> <div className={styles.headerContent}>
<div className={styles.titleSection}> <div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1> <h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}> {(phasePill.label || facts.length > 0) && (
{schoolInfo.local_authority && ( <div className={styles.facts}>
<span className={styles.metaItem}>{schoolInfo.local_authority}</span> {phasePill.label && (
)} <span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
{schoolInfo.school_type && ( {phasePill.label}
<span className={styles.metaItem}>{schoolInfo.school_type}</span> </span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)} )}
{facts.map((fact) => (
<span key={fact} className={styles.fact}>{fact}</span>
))}
</div> </div>
)}
{flags.length > 0 && (
// role="list": list-style: none drops list semantics in Safari.
<ul role="list" className={styles.flags} aria-label="Admission and provision">
{flags.map((flag) => (
<li
key={flag.label}
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
>
{flag.label}
</li>
))}
</ul>
)}
{isProposedToClose(schoolInfo) && ( {isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note"> <div className={styles.closingStrip} role="note">
<strong>⚠ Proposed to close.</strong> Check with the local authority before <strong>⚠ Proposed to close.</strong> Check with the local authority before
@@ -236,6 +262,7 @@ export function SchoolDetailShell({
{schoolInfo.address && ( {schoolInfo.address && (
<p className={styles.address}> <p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{council && ` · ${council}`}
{hasLocation && ( {hasLocation && (
<> <>
{' · '} {' · '}
@@ -282,21 +309,6 @@ export function SchoolDetailShell({
</a> </a>
</span> </span>
)} )}
{(() => {
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && ( {schoolInfo.telephone && (
<span className={styles.headerDetail}> <span className={styles.headerDetail}>
<strong>Phone:</strong>{' '} <strong>Phone:</strong>{' '}
@@ -305,22 +317,14 @@ export function SchoolDetailShell({
</a> </a>
</span> </span>
)} )}
{schoolInfo.religious_denomination && ( {trust && (
<span className={styles.headerDetail}> <span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '} <strong>Academy trust:</strong> {trust}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span> </span>
)} )}
{schoolInfo.county && ( {schoolInfo.capacity != null && (
<span className={styles.headerDetail}> <span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county} <strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span> </span>
)} )}
</div> </div>
@@ -7,7 +7,7 @@
*/ */
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types'; import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils'; import { formatPercentage, hasReligiousCharacter, isSelective } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared'; import { Section, sectionStyles as styles } from './sectionShared';
import { import {
describeCutoff, describeCutoffAbsence, describeCutoff, describeCutoffAbsence,
@@ -33,10 +33,8 @@ export function SecondaryAdmissionsSection({
const featureOn = admissionDistance !== undefined; const featureOn = admissionDistance !== undefined;
// Moved with this section from SecondarySchoolDetailView, its only consumer. // Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => { const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; if (isSelective(schoolInfo.admissions_policy)) return 'Selective';
if (policy.includes('selective')) return 'Selective'; if (hasReligiousCharacter(schoolInfo.religious_denomination)) return 'Faith priority';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null; return null;
})(); })();
@@ -54,7 +54,8 @@ export function WellbeingSection({
</div> </div>
)} )}
{(() => { {(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null; // Not latestResults.total_pupils: that is the GCSE year group.
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
if (total == null) return null; if (total == null) return null;
const female = census?.female_pupils ?? null; const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null; const male = census?.male_pupils ?? null;
@@ -16,7 +16,7 @@
*/ */
import type { SchoolAdmissionDistance } from '@/lib/types'; import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils'; import { formatCutoffDistance, formatMiles, formatEntryYear, isSelective } from '@/lib/utils';
export interface CutoffDisplay { export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */ /** Headline figure, e.g. "0.31 miles". */
@@ -193,8 +193,7 @@ export function describeCutoffAbsence({
admissionsPolicy, admissionsPolicy,
admissionsHistory = [], admissionsHistory = [],
}: AbsenceInput): string { }: AbsenceInput): string {
const policy = (admissionsPolicy ?? '').toLowerCase(); if (isSelective(admissionsPolicy)) {
if (policy.includes('selective')) {
return 'Places at this school are ranked by the entrance test rather than by ' return 'Places at this school are ranked by the entrance test rather than by '
+ 'distance, so no cut-off distance applies.'; + 'distance, so no cut-off distance applies.';
} }
+66
View File
@@ -0,0 +1,66 @@
/**
* The facts the school header and the search rows print, so a parent reads
* the same words in the list and on the page.
*
* Every value comes from one register field. Nothing is inferred or explained
* inline, and a missing field prints nothing.
*/
import type { School } from './types';
import { hasNurseryClasses, hasReligiousCharacter, isSelective, singleSexLabel } from './utils';
/** The search filter's type groups (backend/school_groups.py), without its
* parenthesised notes: fees have a flag of their own. */
const TYPE_GROUP_LABELS: Record<string, string> = {
state: 'State school',
independent: 'Independent school',
special: 'Special school (SEND)',
post16: 'Sixth form or college',
alternative: 'Alternative provision',
};
/** The school's type in the search filter's terms, or the register's own name
* for a type in no group (secure units, online providers). */
export function schoolTypeLabel(school: Pick<School, 'type_group' | 'school_type'>): string | null {
const grouped = school.type_group ? TYPE_GROUP_LABELS[school.type_group] : undefined;
return grouped ?? (school.school_type?.trim() || null);
}
/** "condition": changes who can apply or what it costs.
* "provision": what the school offers. */
export type SchoolFlagKind = 'condition' | 'provision';
export interface SchoolFlag {
label: string;
kind: SchoolFlagKind;
}
type FlagFields = Pick<
School,
'type_group' | 'admissions_policy' | 'gender' | 'religious_denomination'
| 'nursery_provision' | 'has_sixth_form' | 'phase'
>;
/**
* Flags for what applies, in a fixed order: conditions first, then provision.
*
* Selective needs the exact value. The register files a partly selective
* school as "Non-selective", so that value is never printed.
*/
export function schoolFlags(school: FlagFields): SchoolFlag[] {
const flags: SchoolFlag[] = [];
const condition = (label: string) => flags.push({ label, kind: 'condition' });
const provision = (label: string) => flags.push({ label, kind: 'provision' });
const phase = school.phase?.trim().toLowerCase();
if (school.type_group === 'independent') condition('Fee-paying');
if (isSelective(school.admissions_policy)) condition('Selective');
const singleSex = singleSexLabel(school.gender);
if (singleSex) condition(singleSex);
if (hasReligiousCharacter(school.religious_denomination)) {
condition(school.religious_denomination!.trim());
}
if (hasNurseryClasses(school.nursery_provision) && phase !== 'nursery') provision('Nursery class');
if (school.has_sixth_form && phase !== '16 plus') provision('Sixth form');
return flags;
}
+4 -1
View File
@@ -17,10 +17,13 @@ export interface School {
local_authority_code: number | null; local_authority_code: number | null;
school_type: string | null; school_type: string | null;
school_type_code: string | null; school_type_code: string | null;
/** Search-filter type group ("state", "independent"…), null for a type in none. */
type_group?: string | null;
religious_denomination: string | null; religious_denomination: string | null;
age_range: string | null; age_range: string | null;
has_sixth_form?: boolean | null; has_sixth_form?: boolean | null;
nursery_provision?: boolean | null; /** GIAS text; read it through hasNurseryClasses(). */
nursery_provision?: string | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close") status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address // Address
+39
View File
@@ -99,6 +99,26 @@ export function formatAgeRange(ageRange: string | null | undefined): string {
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span; return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
} }
/**
* GIAS NurseryProvision is text: "Has Nursery Classes", "No Nursery Classes"
* or "Not applicable". Only the first means a nursery, so never test the raw
* value for truthiness.
*/
export function hasNurseryClasses(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === 'has nursery classes';
}
/**
* "Girls' school" / "Boys' school" for a single-sex school, null otherwise.
* GIAS genders are plural, so the possessive is a bare apostrophe.
*/
export function singleSexLabel(gender: string | null | undefined): string | null {
const g = gender?.trim().toLowerCase();
if (g === 'girls') return "Girls' school";
if (g === 'boys') return "Boys' school";
return null;
}
// ============================================================================ // ============================================================================
// Number Formatting // Number Formatting
// ============================================================================ // ============================================================================
@@ -902,6 +922,25 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t); return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
} }
/**
* Whether GIAS records a religious character. "None", "Does not apply" and
* "Not applicable" are the register's ways of saying it has none; the place
* table (PlaceView's NO_FAITH) reads the same three.
*/
export function hasReligiousCharacter(value: string | null | undefined): boolean {
const v = value?.trim().toLowerCase() ?? '';
return v !== '' && v !== 'none' && v !== 'does not apply' && v !== 'not applicable';
}
/**
* Whether GIAS records the school as selective. Exact: "Non-selective"
* contains "selective", so a substring test read every comprehensive as
* selective. (The register files a partly selective school as non-selective.)
*/
export function isSelective(admissionsPolicy: string | null | undefined): boolean {
return admissionsPolicy?.trim().toLowerCase() === 'selective';
}
/** /**
* The school's combined Reading, Writing & Maths figure, or null when there is * The school's combined Reading, Writing & Maths figure, or null when there is
* no real one to show. * no real one to show.