From 96deab7d588e06a2d9ddbe87c65315c7830c060c Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 1 Oct 2026 17:40:44 +0100 Subject: [PATCH 1/2] fix(search): start postcode searches at half a mile 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 --- e2e/tests/journeys.spec.ts | 3 ++ .../components/FilterBarRadius.test.tsx | 41 +++++++++++++++++++ nextjs-app/app/(frontend)/page.tsx | 6 ++- nextjs-app/components/FilterBar.tsx | 6 +-- nextjs-app/components/HomeView.tsx | 4 +- nextjs-app/lib/utils.ts | 3 ++ 6 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 nextjs-app/__tests__/components/FilterBarRadius.test.tsx diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index f3a218c..8c0c9b7 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -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 }) => { diff --git a/nextjs-app/__tests__/components/FilterBarRadius.test.tsx b/nextjs-app/__tests__/components/FilterBarRadius.test.tsx new file mode 100644 index 0000000..d6d200e --- /dev/null +++ b/nextjs-app/__tests__/components/FilterBarRadius.test.tsx @@ -0,0 +1,41 @@ +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(); + 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(); + 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(); + expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveValue('3'); + }); +}); diff --git a/nextjs-app/app/(frontend)/page.tsx b/nextjs-app/app/(frontend)/page.tsx index 378ae99..7faf807 100644 --- a/nextjs-app/app/(frontend)/page.tsx +++ b/nextjs-app/app/(frontend)/page.tsx @@ -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 = !!( diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 50119e0..ebab6e9 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -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 { diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index d291ee0..f14c019 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -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 = {}; 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'); diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index b528e7e..3422edb 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -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 */ -- 2.54.0 From 355a5a841ca28d5001936e89bbd4c0b75c658ac0 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 1 Oct 2026 17:52:59 +0100 Subject: [PATCH 2/2] feat(search): offer a quarter-mile distance 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 --- .../__tests__/components/FilterBarRadius.test.tsx | 10 ++++++++++ .../__tests__/components/ResultsMapView.test.tsx | 13 ++++++++++++- nextjs-app/components/FilterBar.tsx | 1 + nextjs-app/components/HomeView.tsx | 7 ++++--- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/nextjs-app/__tests__/components/FilterBarRadius.test.tsx b/nextjs-app/__tests__/components/FilterBarRadius.test.tsx index d6d200e..5bcf04b 100644 --- a/nextjs-app/__tests__/components/FilterBarRadius.test.tsx +++ b/nextjs-app/__tests__/components/FilterBarRadius.test.tsx @@ -39,3 +39,13 @@ describe('FilterBar default distance', () => { 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(); + 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'); + }); +}); diff --git a/nextjs-app/__tests__/components/ResultsMapView.test.tsx b/nextjs-app/__tests__/components/ResultsMapView.test.tsx index 2178630..61b6ee0 100644 --- a/nextjs-app/__tests__/components/ResultsMapView.test.tsx +++ b/nextjs-app/__tests__/components/ResultsMapView.test.tsx @@ -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(); + 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(); }); diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index ebab6e9..25fde82 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -481,6 +481,7 @@ export function FilterBar({ aria-label="Distance" disabled={isPending} > + diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index f14c019..3ea0b4f 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -461,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. @@ -596,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` } -- 2.54.0