Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5.5 355a5a841c feat(search): offer a quarter-mile distance
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
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 17s
Adds "Within 0.25 miles" to the Distance control; 0.5 stays the default.
The radius shown in the results heading and drawn on the map was rounded
to one decimal place, which would have turned 0.25 into 0.3. It now keeps
two, and the list heading says "1 mile" rather than "1.0 miles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:52:59 +01:00
TudorandClaude Opus 5.5 96deab7d58 fix(search): start postcode searches at half a mile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
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
New postcode searches, the near-me button and URLs without a radius now
use 0.5 miles. A postcode URL with no radius used to show "1 mile" in the
Distance control while the API applied its own 5-mile default; the page
and the map fetch now send the same default the control displays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:40:44 +01:00
tudor e9886361d2 Merge pull request 'fix(search): set the toolbar's line count by width, not by results' (#164) from fix/toolbar-one-line 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 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 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m3s
Reviewed-on: #164
2026-10-01 15:06:31 +00:00
7 changed files with 84 additions and 10 deletions

No files matched your search

+3
View File
@@ -270,6 +270,9 @@ test('searching by name returns school results', async ({ page }) => {
test('searching by postcode returns nearby schools', async ({ page }) => {
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
// A new postcode search starts at half a mile.
await expect(page).toHaveURL(/[?&]radius=0\.5(&|$)/);
await expect(page.getByRole('combobox', { name: 'Distance' })).toHaveValue('0.5');
});
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
@@ -0,0 +1,51 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterBar } from '@/components/FilterBar';
const push = jest.fn();
let searchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => searchParams,
}));
const FILTERS = {
local_authorities: [], school_types: [], years: [], phases: [],
genders: [], admissions_policies: [],
};
beforeEach(() => {
push.mockClear();
searchParams = new URLSearchParams();
});
describe('FilterBar default distance', () => {
it('searches a new postcode within half a mile', async () => {
render(<FilterBar filters={FILTERS} />);
await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i), 'SW19 6AR{Enter}');
await waitFor(() => expect(push).toHaveBeenCalledWith(expect.stringContaining('radius=0.5')));
});
it('shows half a mile when the URL carries a postcode but no radius', () => {
searchParams = new URLSearchParams('postcode=SW196AR');
render(<FilterBar filters={FILTERS} />);
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveValue('0.5');
});
it('keeps a distance the user already chose', async () => {
searchParams = new URLSearchParams('postcode=SW196AR&radius=3');
render(<FilterBar filters={FILTERS} />);
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveValue('3');
});
});
describe('FilterBar distance options', () => {
it('offers a quarter mile without making it the default', () => {
searchParams = new URLSearchParams('postcode=SW196AR');
render(<FilterBar filters={FILTERS} />);
const distance = screen.getByRole('combobox', { name: 'Distance' });
expect([...(distance as HTMLSelectElement).options].map(o => o.value)).toEqual(['0.25', '0.5', '1', '3', '5']);
expect(distance).toHaveValue('0.5');
});
});
@@ -180,5 +180,16 @@ it('lets a keyboard pick a pin from the list, with a real button', async () => {
it('keeps the postcode in the heading in list view, where there is room', async () => {
await renderMap();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 1.0 miles of SW196AR' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '3 schools within 1 mile of SW196AR' })).toBeInTheDocument();
});
it('draws and names a quarter-mile search as 0.25, not rounded to 0.3', async () => {
params = new URLSearchParams('postcode=SW196AR&radius=0.25');
const quarter = { ...results(), location_info: { postcode: 'SW196AR', radius: 0.25 * 1.60934, coordinates: [51.42, -0.21] } } as SchoolsResponse;
render(<HomeView initialSchools={quarter} filters={filters} />);
await act(async () => {});
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '0.25');
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 0.25 miles of SW196AR' })).toBeInTheDocument();
});
+4 -2
View File
@@ -6,7 +6,7 @@
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { formatAcademicYear } from '@/lib/utils';
import { DEFAULT_RADIUS_MILES, formatAcademicYear } from '@/lib/utils';
import { HomeView } from '@/components/HomeView';
import { getFlags } from '@/lib/flags';
import { HowItWorksSection } from '@/components/HowItWorksSection';
@@ -71,7 +71,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// Parse search params
const page = parseInt(params.page || '1');
const radius = params.radius ? parseFloat(params.radius) : undefined;
// Without this the API applies its own 5-mile default while the Distance
// control shows DEFAULT_RADIUS_MILES.
const radius = params.radius ? parseFloat(params.radius) : DEFAULT_RADIUS_MILES;
// Check if user has performed a search
const hasSearchParams = !!(
+4 -3
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode, schoolUrl } from "@/lib/utils";
import { DEFAULT_RADIUS_MILES, isValidPostcode, schoolUrl } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
@@ -70,7 +70,7 @@ export function FilterBar({
const currentSearch = searchParams.get("search") || "";
const currentPostcode = searchParams.get("postcode") || "";
const currentRadius = searchParams.get("radius") || "1";
const currentRadius = searchParams.get("radius") || String(DEFAULT_RADIUS_MILES);
const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue);
@@ -269,7 +269,7 @@ export function FilterBar({
if (isPostcode) {
updateURL({
postcode: cleaned.toUpperCase(),
radius: currentRadius || "1",
radius: currentRadius,
search: "",
});
} else {
@@ -481,6 +481,7 @@ export function FilterBar({
aria-label="Distance"
disabled={isPending}
>
<option value="0.25">Within 0.25 miles</option>
<option value="0.5">Within 0.5 miles</option>
<option value="1">Within 1 mile</option>
<option value="3">Within 3 miles</option>
+7 -4
View File
@@ -17,6 +17,7 @@ import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { track } from '@/lib/analytics';
import { DEFAULT_RADIUS_MILES } from '@/lib/utils';
import styles from './HomeView.module.css';
interface HomeViewProps {
@@ -340,6 +341,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
setIsLoadingMap(true);
const params: Record<string, any> = {};
searchParams.forEach((value, key) => { params[key] = value; });
params.radius ??= DEFAULT_RADIUS_MILES;
params.page = 1;
params.page_size = 500;
fetchSchools(params, { cache: 'no-store', signal: controller.signal })
@@ -419,7 +421,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
setGeoState('idle');
track('near_me_used', { outcome: 'granted' });
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=${DEFAULT_RADIUS_MILES}`);
} else {
track('near_me_used', { outcome: 'no_postcode' });
setGeoState('error');
@@ -459,8 +461,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
// open card); only the list beside it is sorted.
const mapResultSchools = mapSchools.length > 0 ? mapSchools : initialSchools.schools;
const mapListSchools = sortSchools(mapResultSchools);
// Two places, so a 0.25-mile search is not drawn or labelled as 0.3.
const radiusMiles = initialSchools.location_info
? Number((initialSchools.location_info.radius / 1.60934).toFixed(1))
? Number((initialSchools.location_info.radius / 1.60934).toFixed(2))
: undefined;
// Empty-results sentinel: track when a search returns nothing.
@@ -594,8 +597,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
drops the postcode, which the search box just above already shows. */}
{isMapView && radiusMiles != null
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'}`
: isLocationSearch && initialSchools.location_info
? `${schoolCount} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
: isLocationSearch && radiusMiles != null && initialSchools.location_info
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'} of ${initialSchools.location_info.postcode}`
: `${schoolCount} found`
}
</h2>
+3
View File
@@ -235,6 +235,9 @@ export function getVariabilityLabel(stdDev: number | null): string {
// Validation
// ============================================================================
/** Radius a postcode search uses until the user picks another. */
export const DEFAULT_RADIUS_MILES = 0.5;
/**
* Validate UK postcode format
*/