This commit is contained in:
Andras Schmelczer 2026-06-02 13:46:18 +01:00
parent a04ac2d857
commit d43da9708c
47 changed files with 4120 additions and 573 deletions

View file

@ -154,7 +154,100 @@ function searchResultKey(result: SearchResult): string {
return `place:${result.slug}`;
}
export function useLocationSearch(mode?: string) {
/** 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);
@ -165,6 +258,9 @@ export function useLocationSearch(mode?: string) {
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) => {
@ -212,6 +308,11 @@ export function useLocationSearch(mode?: string) {
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 })
@ -223,47 +324,19 @@ export function useLocationSearch(mode?: string) {
}
return;
}
const json: {
places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
} = await res.json();
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);
const json: PlacesApiResponse = await res.json();
const combinedResults = (
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults]
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;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
// 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);

View file

@ -178,7 +178,11 @@ describe('resolveTransitVariant', () => {
describe('parseServerMode', () => {
it('round-trips the four toggle-reachable variants', () => {
expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false });
expect(parseServerMode('transit')).toEqual({
mode: 'transit',
noChange: false,
noBuses: false,
});
expect(parseServerMode('transit-no-bus')).toEqual({
mode: 'transit',
noChange: false,
@ -198,8 +202,16 @@ describe('parseServerMode', () => {
it('parses non-transit base modes', () => {
expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false });
expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false });
expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false });
expect(parseServerMode('bicycle')).toEqual({
mode: 'bicycle',
noChange: false,
noBuses: false,
});
expect(parseServerMode('walking')).toEqual({
mode: 'walking',
noChange: false,
noBuses: false,
});
});
it('returns null for variants the UI cannot represent (no silent broadening)', () => {

View file

@ -138,7 +138,15 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false },
{
mode,
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: false,
noBuses: false,
},
]);
}, []);