Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:40:45 +01:00
tudor 19c574edb0 Merge pull request 'feat(search): one state school group, not academy and council-run' (#171) from feat/single-state-school-group into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 25s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m2s
Reviewed-on: #171
2026-10-02 15:33:13 +00:00
14 changed files with 271 additions and 43 deletions

No files matched your search

+10 -4
View File
@@ -40,7 +40,7 @@ from .data_loader import (
from .data_loader import get_data_info as get_db_info
from . import flags
from .places import build_place_index, build_place_registry, places_for_urn
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, PHASE_ORDER, RANKING_COLUMNS, SCHOOL_COLUMNS
from .school_groups import (
FAITH_GROUPS,
FAITH_KEYS,
@@ -381,6 +381,12 @@ def clean_filter_values(series: pd.Series) -> list[str]:
)
def order_phases(phases: list[str]) -> list[str]:
"""Phases in the order a child meets them; any GIAS adds later follow, A-Z."""
rank = {p: i for i, p in enumerate(PHASE_ORDER)}
return sorted(phases, key=lambda p: (rank.get(p.lower(), len(rank)), p))
# =============================================================================
# SECURITY MIDDLEWARE & HELPERS
# =============================================================================
@@ -937,7 +943,7 @@ async def get_schools(
result_filters = {
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
"phases": order_phases(clean_filter_values(schools_df["phase"])) if "phase" in schools_df.columns else [],
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
}
@@ -1207,8 +1213,8 @@ async def get_filter_options(request: Request):
"faiths": [],
}
# Phases: return values from data, ordered sensibly
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
# Phases: the values in the data, in the order a child meets them
phases = order_phases(clean_filter_values(df["phase"])) if "phase" in df.columns else []
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
+13
View File
@@ -544,6 +544,19 @@ PHASE_GROUPS: dict[str, set[str]] = {
"all-through": {"all-through"},
}
# GIAS phases in the order a child meets them, for the phase filter's options.
# All-through spans the whole path, so it follows the stages. Lowercased, as
# PHASE_GROUPS is, so a change of case in the GIAS label keeps its place.
PHASE_ORDER: list[str] = [
"nursery",
"primary",
"middle deemed primary",
"middle deemed secondary",
"secondary",
"16 plus",
"all-through",
]
# School listing columns
SCHOOL_COLUMNS = [
"urn",
+22
View File
@@ -82,3 +82,25 @@ def test_grouped_phases_still_take_in_their_related_phases(client):
def test_an_unknown_phase_returns_nothing_rather_than_everything(client):
assert _urns(client, "kindergarten") == []
def test_filters_lists_phases_in_the_order_a_child_meets_them(client):
# Alphabetical put "16 plus" and "All-through" first and Nursery fifth.
# All-through spans the whole path, so it comes after the stages.
assert client.get("/api/filters").json()["phases"] == [
"Nursery",
"Primary",
"Middle deemed primary",
"Middle deemed secondary",
"Secondary",
"16 plus",
"All-through",
]
def test_an_unknown_phase_follows_the_known_ones():
from backend.app import order_phases
assert order_phases(["Secondary", "Zeta", "Alpha", "Nursery"]) == [
"Nursery", "Secondary", "Alpha", "Zeta",
]
+49
View File
@@ -286,6 +286,19 @@ test('the phase filter switches straight from secondary to primary', async ({ pa
await expect(phase).toHaveValue('primary');
});
test('the phase filter lists phases in the order a child meets them', async ({ page }) => {
// They were alphabetical, so "16 plus" and "All-through" came before Nursery.
const childPath = ['Nursery', 'Primary', 'Middle deemed primary',
'Middle deemed secondary', 'Secondary', '16 plus', 'All-through'];
await page.goto('/?search=school');
const phase = page.getByRole('combobox', { name: 'Phase' });
await expect(phase).toBeVisible({ timeout: 15_000 });
const offered = (await phase.locator('option').allTextContents())
.filter((o) => childPath.includes(o));
expect(offered, 'no phase on offer').toContain('Primary');
expect(offered).toEqual(childPath.filter((p) => offered.includes(p)));
});
test('school type and gender switch straight to another value', async ({ page }) => {
// Their options came from the result set, which the filter had already
// narrowed, so with one value chosen it was the only one on offer.
@@ -309,6 +322,19 @@ test('school type and gender switch straight to another value', async ({ page })
* School type offers six groups a parent recognises, and Faith sits beside it.
* Data-invariant: asserts what every returned school is, never how many.
*/
// Clear all once dropped the search with the filters, which sent desktop back
// to the home page instead of to the unfiltered results.
test('Clear all removes the filters and keeps the search', async ({ page }) => {
await page.goto('/?search=school&phase=primary');
const row = page.getByRole('group', { name: 'Filters', exact: true });
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
await row.getByRole('button', { name: 'Clear all', exact: true }).click();
await expect(page).not.toHaveURL(/[?&]phase=/);
await expect(page).toHaveURL(/[?&]search=school(&|$)/);
await expect(schoolLinks(page).first()).toBeVisible();
await expect(row.getByRole('button', { name: 'Clear all', exact: true })).toHaveCount(0);
});
test('school type groups and the faith filter narrow to what they name', async ({ page }) => {
await page.goto('/?search=school');
const type = page.getByRole('combobox', { name: 'School type', exact: true });
@@ -3260,3 +3286,26 @@ for (const width of [360, 390, 430]) {
expect(failing).toEqual([]);
});
}
/*
* The header's facts row read GIAS text as booleans: "Not applicable" put a
* "Nursery" chip on secondaries aged 11–18, and "Girls" became "Girls's
* school". Data-invariant: the chip follows whatever the API says.
*/
test('a girls\' secondary header names it properly and shows Nursery only when it has one', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&phase=secondary&gender=girls&page_size=1');
expect(res.ok()).toBeTruthy();
const [school] = (await res.json()).schools ?? [];
test.skip(!school, 'no girls\' secondary in this environment');
expect(school.gender, 'the gender filter was ignored').toBe('Girls');
const detail = await (await page.request.get(`/api/schools/${school.urn}`)).json();
// Without the field, the Nursery assertion below would pass vacuously.
expect(detail.school_info).toHaveProperty('nursery_provision');
await page.goto(`/school/${school.urn}`);
const header = page.locator('header', { has: page.getByRole('heading', { level: 1 }) });
await expect(header.getByText("Girls' school", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(header.getByText(/'s school/)).toHaveCount(0);
await expect(header.getByText('Nursery', { exact: true }))
.toHaveCount(detail.school_info.nursery_provision === 'Has Nursery Classes' ? 1 : 0);
});
@@ -361,12 +361,12 @@ describe('PlaceView school attributes', () => {
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
rwm_expected_pct: 82, attainment_8_score: null,
age_range: '4-11', religious_denomination: 'Church of England',
nursery_provision: true,
nursery_provision: 'Has Nursery Classes',
parliamentary_constituency: 'Chelmsford' } as never,
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
rwm_expected_pct: null, attainment_8_score: 47,
age_range: '11-16', religious_denomination: 'Does not apply',
nursery_provision: false,
nursery_provision: 'No Nursery Classes',
parliamentary_constituency: 'Witham' } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
@@ -459,6 +459,18 @@ describe('PlaceView school attributes', () => {
expect(cells.slice(2)).toEqual(['—', '—', '—', '—']);
});
it('reads "Not applicable" as no nursery, not as a yes', () => {
// GIAS sends text. Tested for truthiness, every value was a "Yes".
const notApplicable: PlaceDetail = {
...withAttributes,
schools: [{ urn: 5, school_name: 'Epsilon Primary', phase: 'Primary',
rwm_expected_pct: 70, nursery_provision: 'Not applicable' } as never],
};
const { container } = render(<PlaceView detail={notApplicable} phase="primary"
englandAverage={61} neighbours={[]} />);
expect(container.querySelector('tbody')!.textContent).not.toContain('Yes');
});
it('gives an all-through school its nursery under primary only', () => {
// All-through schools render in both groups. Nursery belongs to the
// primary reading of the same school, not the secondary one.
@@ -467,7 +479,7 @@ describe('PlaceView school attributes', () => {
schools: [{ urn: 4, school_name: 'Delta Academy', phase: 'All-through',
rwm_expected_pct: 66, attainment_8_score: 51,
age_range: '4-18', religious_denomination: 'None',
nursery_provision: true,
nursery_provision: 'Has Nursery Classes',
parliamentary_constituency: 'Chelmsford' } as never],
};
const { container } = render(<PlaceView detail={allThrough}
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { HomeView } from '@/components/HomeView';
import { FilterBar } from '@/components/FilterBar';
import { fetchSchools } from '@/lib/api';
@@ -175,3 +175,24 @@ describe('the results list', () => {
expect(screen.queryByText('Search: southmead')).not.toBeInTheDocument();
});
});
describe('the desktop Clear all', () => {
const row = () => screen.getByRole('group', { name: 'Filters' });
it('removes the filters and keeps the search, rather than going home', () => {
params = new URLSearchParams('postcode=SW196AR&radius=3&phase=primary&school_type=council&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
fireEvent.click(within(row()).getByRole('button', { name: 'Clear all' }));
const pushed = push.mock.calls.at(-1)![0] as string;
const next = new URLSearchParams(pushed.split('?')[1]);
expect(next.get('postcode')).toBe('SW196AR');
expect(next.get('radius')).toBe('3');
for (const key of ['phase', 'school_type', 'local_authority']) expect(next.get(key)).toBeNull();
});
it('is not offered when only a search is applied', () => {
params = new URLSearchParams('search=southmead');
render(<FilterBar filters={filters} />);
expect(within(row()).queryByRole('button', { name: /^Clear/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/**
* The facts row under the school name.
*
* 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 } 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 } };
}
describe('school header nursery chip', () => {
it('shows Nursery when GIAS says the school has nursery classes', () => {
renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
expect(screen.getByText('Nursery', { selector: 'span' })).toBeInTheDocument();
});
it.each(['No Nursery Classes', 'Not applicable', null])(
'hides Nursery when GIAS says %p',
(value) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
expect(screen.queryByText('Nursery', { selector: 'span' })).not.toBeInTheDocument();
},
);
});
describe('school header single-sex chip', () => {
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();
});
});
+31
View File
@@ -15,6 +15,8 @@ import {
computeYBounds,
formatAgeRange,
formatAgeSpan,
hasNurseryClasses,
singleSexLabel,
} from '@/lib/utils';
describe('formatPercentage', () => {
@@ -346,3 +348,32 @@ describe('formatAgeRange', () => {
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
});
});
describe('hasNurseryClasses', () => {
it('is true only for the GIAS value that means it', () => {
// GIAS sends text, and two of its three values mean no nursery.
expect(hasNurseryClasses('Has Nursery Classes')).toBe(true);
expect(hasNurseryClasses('No Nursery Classes')).toBe(false);
expect(hasNurseryClasses('Not applicable')).toBe(false);
expect(hasNurseryClasses(null)).toBe(false);
expect(hasNurseryClasses(undefined)).toBe(false);
});
});
describe('singleSexLabel', () => {
it('uses the plural possessive GIAS values need', () => {
expect(singleSexLabel('Girls')).toBe("Girls' school");
expect(singleSexLabel('Boys')).toBe("Boys' school");
});
it('ignores case, as hasNurseryClasses does', () => {
expect(singleSexLabel(' girls ')).toBe("Girls' school");
expect(singleSexLabel('BOYS')).toBe("Boys' school");
});
it('returns null for a mixed or unknown school', () => {
expect(singleSexLabel('Mixed')).toBeNull();
expect(singleSexLabel(null)).toBeNull();
expect(singleSexLabel(undefined)).toBeNull();
});
});
+6 -24
View File
@@ -377,31 +377,13 @@ export function FilterBar({
updateURL({ ...cleared, [key]: value });
};
// Every filter at once, keeping the search and its distance: what "Clear
// all" means beside the applied filters, where the search is not one of them.
// Every filter at once, keeping the search and its distance. The search is
// what the results answer, not a filter on them: clearing it too used to
// send desktop back to the home page.
const handleClearFilterValues = () => {
updateURL(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
};
const handleClearFilters = () => {
setOmniValue("");
pendingQueryRef.current = "";
startTransition(() => {
router.push(pathname);
});
};
const hasActiveFilters =
currentSearch ||
currentLA ||
currentType ||
currentFaith ||
currentPhase ||
currentPostcode ||
currentGender ||
currentAdmissionsPolicy ||
currentHasSixthForm;
/*
* A filter's options come from the full lists, not from the results: the
* results have already been narrowed by that filter, so scoping to them
@@ -844,14 +826,14 @@ export function FilterBar({
/>
</button>
{hasActiveFilters && (
{appliedCount > 0 && (
<button
onClick={handleClearFilters}
onClick={handleClearFilterValues}
className={`btn btn-tertiary ${styles.clearButton}`}
type="button"
disabled={isPending}
>
Clear
Clear all
</button>
)}
</div>
+4
View File
@@ -79,7 +79,11 @@
font-family: var(--font-display);
}
/* 44px, the MOBILE.md touch-target minimum: the 24px icon and its padding
came to 40, and the phone filter sheet journey caught it. */
.closeButton {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.5rem;
background: transparent;
border: none;
+5 -4
View File
@@ -13,7 +13,7 @@ import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types';
import { schoolUrl, formatAgeSpan } from '@/lib/utils';
import { schoolUrl, formatAgeSpan, hasNurseryClasses } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site';
import { TrackPlaceView } from './TrackPlaceView';
import styles from './PlaceView.module.css';
@@ -131,9 +131,10 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
{showNursery && (
<td className={styles.attr}>
{/* Undefined is a mart the pipeline has not rebuilt, and
false is a school without one. Neither is a "Yes", and
neither is worth two different words. */}
{s.nursery_provision ? 'Yes' : NO_VALUE}
"No Nursery Classes" or "Not applicable" is a school
without one. None is a "Yes", and none is worth a
different word. */}
{hasNurseryClasses(s.nursery_provision) ? 'Yes' : NO_VALUE}
</td>
)}
<td className={styles.attrWide}>
@@ -21,7 +21,7 @@ import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
import { formatAgeRange, hasNurseryClasses, isProposedToClose, singleSexLabel } from '@/lib/utils';
import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
@@ -122,7 +122,7 @@ export function SchoolDetailShell({
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
// The chrome needs only these four. The section-shape flags are computed
// The chrome needs only these few. The section-shape flags are computed
// once on the server (lib/schoolSections) and consumed by the section
// composers; recomputing them here would duplicate that work for values
// this component never renders.
@@ -130,6 +130,7 @@ export function SchoolDetailShell({
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const singleSex = singleSexLabel(schoolInfo.gender);
const handleComparisonToggle = () => {
if (isInComparison) {
@@ -214,13 +215,11 @@ export function SchoolDetailShell({
{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>
)}
{singleSex && <span className={styles.metaItem}>{singleSex}</span>}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
{hasNurseryClasses(schoolInfo.nursery_provision) && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
+2 -1
View File
@@ -20,7 +20,8 @@ export interface School {
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
nursery_provision?: boolean | null;
/** GIAS text; read it through hasNurseryClasses(). */
nursery_provision?: string | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
+20
View File
@@ -99,6 +99,26 @@ export function formatAgeRange(ageRange: string | null | undefined): string {
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
}
/**
* GIAS NurseryProvision is text: "Has Nursery Classes", "No Nursery Classes"
* or "Not applicable". Only the first means a nursery, so never test the raw
* value for truthiness.
*/
export function hasNurseryClasses(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === 'has nursery classes';
}
/**
* "Girls' school" / "Boys' school" for a single-sex school, null otherwise.
* GIAS genders are plural, so the possessive is a bare apostrophe.
*/
export function singleSexLabel(gender: string | null | undefined): string | null {
const g = gender?.trim().toLowerCase();
if (g === 'girls') return "Girls' school";
if (g === 'boys') return "Boys' school";
return null;
}
// ============================================================================
// Number Formatting
// ============================================================================