perfect-postcode/frontend/src/hooks/useLocationSearch.ts
2026-06-02 20:14:32 +01:00

436 lines
13 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
const RECENT_SEARCH_LIMIT = 3;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
function looksLikePostcode(s: string) {
return FULL_POSTCODE_RE.test(s.trim());
}
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
function normalizePostcode(s: string): string {
const stripped = s.replace(/\s+/g, '').toUpperCase();
if (stripped.length >= 5) {
return stripped.slice(0, -3) + ' ' + stripped.slice(-3);
}
return stripped;
}
function searchableTextForResult(result: SearchResult): string {
if (result.type === 'postcode') {
return result.label;
}
if (result.type === 'address') {
return `${result.address} ${result.postcode}`;
}
return `${result.name} ${result.city ?? ''}`;
}
function searchTokens(value: string): string[] {
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
function compactSearchText(value: string): string {
return searchTokens(value).join('');
}
function resultMatchesQuery(result: SearchResult, query: string): boolean {
const queryTokens = searchTokens(query);
if (queryTokens.length === 0) {
return false;
}
const searchable = searchableTextForResult(result);
const resultText = searchTokens(searchable).join(' ');
const resultCompact = compactSearchText(searchable);
const queryCompact = queryTokens.join('');
return (
queryTokens.every((token) => resultText.includes(token)) ||
(queryCompact.length >= 2 && resultCompact.includes(queryCompact))
);
}
function filterResultsForQuery(results: SearchResult[], query: string): SearchResult[] {
return results.filter((result) => resultMatchesQuery(result, query)).slice(0, 20);
}
export type SearchResult =
| { type: 'postcode'; label: string }
| {
type: 'address';
address: string;
postcode: string;
lat: number;
lon: number;
}
| {
type: 'place';
name: string;
slug: string;
place_type: string;
lat: number;
lon: number;
city?: string;
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isSearchResult(value: unknown): value is SearchResult {
if (!value || typeof value !== 'object') return false;
const result = value as Record<string, unknown>;
if (result.type === 'postcode') {
return typeof result.label === 'string';
}
if (result.type === 'address') {
return (
typeof result.address === 'string' &&
typeof result.postcode === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon)
);
}
if (result.type === 'place') {
return (
typeof result.name === 'string' &&
typeof result.slug === 'string' &&
typeof result.place_type === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon) &&
(result.city === undefined || typeof result.city === 'string')
);
}
return false;
}
function readRecentSearches(): SearchResult[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSearchResult).slice(0, RECENT_SEARCH_LIMIT);
} catch {
return [];
}
}
function writeRecentSearches(searches: SearchResult[]) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
RECENT_SEARCHES_STORAGE_KEY,
JSON.stringify(searches.slice(0, RECENT_SEARCH_LIMIT))
);
} catch {
// Recent searches are a convenience only; storage failures should not affect search.
}
}
function searchResultKey(result: SearchResult): string {
if (result.type === 'postcode') {
return `postcode:${normalizePostcode(result.label)}`;
}
if (result.type === 'address') {
return `address:${result.postcode.toUpperCase()}:${result.address.toLowerCase()}`;
}
return `place:${result.slug}`;
}
/** A category-tagged, scored result from the unified `/api/places` ranking. */
type UnifiedResultDTO =
| { type: 'postcode'; label: string; score: number }
| { type: 'address'; address: string; postcode: string; lat: number; lon: number; score: number }
| {
type: 'place';
name: string;
slug: string;
place_type: string;
lat: number;
lon: number;
city?: string;
score: number;
};
interface PlacesApiResponse {
places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
/** Preferred: a single relevance-ordered list across all categories. */
results?: UnifiedResultDTO[];
}
function isNonNull<T>(value: T | null): value is T {
return value !== null;
}
function unifiedToSearchResult(result: UnifiedResultDTO): SearchResult | null {
if (result.type === 'postcode') {
return { type: 'postcode', label: result.label };
}
if (result.type === 'address') {
return {
type: 'address',
address: result.address,
postcode: result.postcode,
lat: result.lat,
lon: result.lon,
};
}
if (result.type === 'place') {
return {
type: 'place',
name: result.name,
slug: result.slug,
place_type: result.place_type,
lat: result.lat,
lon: result.lon,
city: result.city === 'City of London' ? 'London' : result.city,
};
}
return null;
}
/** Legacy ordering for servers that predate the unified `results` list: positional buckets,
* re-filtered locally. Retained only as a fallback. */
function legacyCombineResults(json: PlacesApiResponse, trimmed: string): SearchResult[] {
const placeResults = json.places.map((p) => ({
type: 'place' as const,
name: p.name,
slug: p.slug,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city,
}));
const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode');
const otherPlaceResults = placeResults.filter((result) => result.place_type !== 'outcode');
const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({
type: 'postcode' as const,
label: postcode,
}));
const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({
type: 'address' as const,
address: address.address,
postcode: address.postcode,
lat: address.lat,
lon: address.lon,
}));
const containsHouseNumber = /\d/.test(trimmed);
return filterResultsForQuery(
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults],
trimmed
);
}
export type ViewportCenter = { lat: number; lng: number };
export function useLocationSearch(mode?: string, getViewportCenter?: () => ViewportCenter | null) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestQueryRef = useRef('');
const lastResultsRef = useRef<SearchResult[]>([]);
// Held in a ref so a non-memoized callback from the parent doesn't churn handleInputChange.
const getViewportCenterRef = useRef(getViewportCenter);
getViewportCenterRef.current = getViewportCenter;
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
latestQueryRef.current = value;
setActiveIndex(-1);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim();
if (!trimmed) {
setSearching(false);
setResults(recentSearches);
lastResultsRef.current = [];
setOpen(recentSearches.length > 0);
return;
}
if (!mode && looksLikePostcode(trimmed)) {
setSearching(false);
const postcodeResults: SearchResult[] = [
{ type: 'postcode', label: normalizePostcode(trimmed) },
];
setResults(postcodeResults);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setSearching(false);
setResults([]);
setOpen(false);
return;
}
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
setResults(locallyFilteredResults);
setOpen(locallyFilteredResults.length > 0);
setSearching(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed });
if (mode) params.set('mode', mode);
const center = getViewportCenterRef.current?.();
if (center) {
params.set('lat', String(center.lat));
params.set('lng', String(center.lng));
}
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal })
);
if (!res.ok) {
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setResults([]);
setOpen(true);
}
return;
}
const json: PlacesApiResponse = await res.json();
const combinedResults = (
Array.isArray(json.results)
? json.results.map(unifiedToSearchResult).filter(isNonNull)
: legacyCombineResults(json, trimmed)
).slice(0, 20);
if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) {
return;
}
lastResultsRef.current = combinedResults;
// Trust the server's unified ranking — re-filtering here previously dropped valid
// alias and partial-postcode matches. The optimistic pre-fetch path still filters.
setResults(combinedResults);
setOpen(true);
} catch (err) {
logNonAbortError('places search', err);
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setResults([]);
setOpen(true);
}
} finally {
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setSearching(false);
}
}
}, 200);
},
[mode, recentSearches]
);
const showEmptySearches = useCallback(() => {
if (latestQueryRef.current.trim()) {
setOpen(results.length > 0 || latestQueryRef.current.trim().length >= 2);
return;
}
setResults(recentSearches);
setActiveIndex(-1);
setOpen(recentSearches.length > 0);
}, [recentSearches, results.length]);
const close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
setQuery('');
latestQueryRef.current = '';
setSearching(false);
setResults([]);
lastResultsRef.current = [];
setOpen(false);
setActiveIndex(-1);
}, []);
const saveRecentSearch = useCallback((result: SearchResult) => {
setRecentSearches((prev) => {
const key = searchResultKey(result);
const next = [result, ...prev.filter((recent) => searchResultKey(recent) !== key)].slice(
0,
RECENT_SEARCH_LIMIT
);
writeRecentSearches(next);
return next;
});
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < results.length) {
onSelect(results[activeIndex]);
} else if (results.length > 0) {
onSelect(results[0]);
} else if (looksLikePostcode(query)) {
onSelect({ type: 'postcode', label: normalizePostcode(query) });
}
} else if (e.key === 'Escape') {
setOpen(false);
}
},
[results, activeIndex, query]
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return {
query,
results,
activeIndex,
setActiveIndex,
open,
searching,
setOpen,
handleInputChange,
handleKeyDown,
showEmptySearches,
saveRecentSearch,
close,
clear,
};
}