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..5bcf04b --- /dev/null +++ b/nextjs-app/__tests__/components/FilterBarRadius.test.tsx @@ -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(); + 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'); + }); +}); + +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/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..25fde82 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 { @@ -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 d291ee0..3ea0b4f 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'); @@ -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` } 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 */