Compare commits

...
Author SHA1 Message Date
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
7 changed files with 103 additions and 29 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",
]
+26
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 });
@@ -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();
});
});
+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;