diff --git a/client/src/components/Map/useMergedMapPois.test.ts b/client/src/components/Map/useMergedMapPois.test.ts new file mode 100644 index 000000000..1ecb7a2cd --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useMergedMapPois } from './useMergedMapPois' +import type { Poi } from './poiCategories' + +/** + * FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode. + * + * The corridor answers "what is along the drive", the category pill answers "what is in + * view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is + * only new when the content really is — the map rebuilds every pin off it. + */ + +const poi = (osm_id: string, name = osm_id): Poi => ({ + osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap', +} as Poi) + +describe('useMergedMapPois', () => { + it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => { + const explore = [poi('n1')] + const { result } = renderHook(() => useMergedMapPois(null, explore)) + expect(result.current).toBe(explore) + }) + + it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => { + const corridor = [poi('n1')] + const { result } = renderHook(() => useMergedMapPois(corridor, [])) + expect(result.current).toBe(corridor) + }) + + it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => { + const explore = [poi('n2')] + const { result } = renderHook(() => useMergedMapPois([], explore)) + expect(result.current).toBe(explore) + }) + + it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => { + const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')])) + expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2']) + }) + + it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => { + // The corridor copy carries how far along the drive it sits, which the pill's does not. + const corridor = [poi('n1', 'Hotel am Weg')] + const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')])) + expect(result.current).toBe(corridor) + expect(result.current).toHaveLength(1) + expect(result.current[0].name).toBe('Hotel am Weg') + }) + + it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => { + const corridor = [poi('n1')] + const explore = [poi('n2')] + const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore)) + const first = result.current + rerender() + expect(result.current).toBe(first) + }) +}) diff --git a/client/src/components/Map/useMergedMapPois.ts b/client/src/components/Map/useMergedMapPois.ts new file mode 100644 index 000000000..b6253671a --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react' +import type { Poi } from './poiCategories' + +/** + * The findings the map should draw, when two searches can be running at once. + * + * In road trip mode the corridor search answers "what is along this drive". The category + * pill answers a different question — "what is in view right now" — and a hotel at tonight's + * stop is exactly the case where someone wants the second one without giving up the first, + * so the two lists are drawn together rather than one replacing the other. + * + * Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles + * on: a hotel found both along the route and in the viewport is one pin, and the corridor's + * copy wins because it carries how far along the drive it sits. + * + * Returns the input array itself whenever there is nothing to merge. The map redraws off + * this reference, so handing it a fresh array on every render would rebuild every pin. + */ +export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] { + return useMemo(() => { + if (!corridor) return explore + if (!explore.length) return corridor + if (!corridor.length) return explore + const seen = new Set(corridor.map(p => p.osm_id)) + const extra = explore.filter(p => !seen.has(p.osm_id)) + return extra.length ? [...corridor, ...extra] : corridor + }, [corridor, explore]) +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7410395af..c531c8ad6 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -47,6 +47,7 @@ import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, T import { ListTodo, Download, Plus, Trash2, FolderPlus } from 'lucide-react' import { useTripPlanner } from './tripPlanner/useTripPlanner' import { usePoiExplore } from '../components/Map/usePoiExplore' +import { useMergedMapPois } from '../components/Map/useMergedMapPois' import PoiCategoryPill from '../components/Map/PoiCategoryPill' import { useTouchDragBridge } from '../hooks/useTouchDragBridge' @@ -320,7 +321,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { const [glMap, setGlMap] = useState(null) // The corridor search draws into the same map channel and answers the same question for // a drive, so the explore pill stands down while road trip mode is on. - const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive + // Also in road trip mode: searching the view is a different question from searching the + // drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the + // two answers are drawn side by side rather than one hiding the other. + const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false + const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois) // Costs expense editor opened from a booking modal (save-then-open). Lives at the // page level so it has tripMembers / base currency / current user available. @@ -425,12 +430,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { const r = reservations.find(x => x.id === rid) if (r) setMapTransportDetail(r) }} - pois={roadtripActive - /* `visible`, not `search.results`: the map is the picture of this very - list, and filtering the list while seventy pins stay on the map no - longer answers "which of these". */ - ? roadtripCorridor.visible - : poi.pois} + /* In road trip mode the corridor's `visible` (not `search.results`: the map is + the picture of that very list, and filtering the list while seventy pins stay + on the map no longer answers "which of these") plus whatever the category + pill found in view. Outside it, only the pill's hits. */ + pois={mapPois} onPoiClick={handlePoiClick} // Only while road trip mode is on: outside it there is no drive to drop onto. onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}