diff --git a/CLAUDE.md b/CLAUDE.md index 7384836..d8333ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ The Vite `base` is derived from `package.json` `homepage` (see `vite.config.ts Three Zustand stores, each with `immer` + `devtools`: - `src/stores/common-store.ts` — settings panel/directions panel open state, costing settings, dateTime, map-ready flag. `Profile` enum and `profileEnum` zod schema live here. -- `src/stores/directions-store.ts` — waypoints (with geocode results), route results, highlighted maneuver, optimized-route flag, active-route index. +- `src/stores/directions-store.ts` — waypoints (each with a selected address + geocode candidates), route results, highlighted maneuver, optimized-route flag, active-route index. - `src/stores/isochrones-store.ts` — input/result, range/interval/denoise/generalize, color palette, opacity. Server-state lives in TanStack Query. The global `QueryClient` (`src/lib/tanstack-query/root-provider.tsx`) sets `refetchOnWindowFocus: false`, `retry: 1`, `staleTime: 5min`, `gcTime: 10min`. Query hooks are in `src/hooks/use-*-queries.ts`. They read inputs directly from Zustand stores via `useStore.getState()` and from the router via `router.state.location.search` rather than parameters — keep that pattern when adding new queries. diff --git a/src/components/directions/directions.spec.tsx b/src/components/directions/directions.spec.tsx index f7326ce..c4fe0fc 100644 --- a/src/components/directions/directions.spec.tsx +++ b/src/components/directions/directions.spec.tsx @@ -19,8 +19,8 @@ vi.mock('@/utils/parse-url-params', () => ({ })); const mockWaypoints = [ - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' }, + { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' }, + { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' }, ]; const mockResults = { @@ -54,8 +54,8 @@ vi.mock('@/stores/directions-store', () => { ); return { defaultWaypoints: [ - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' }, + { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' }, + { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' }, ], useDirectionsStore, }; @@ -107,8 +107,8 @@ describe('DirectionsControl', () => { mockResults.data = null; mockWaypoints.length = 0; mockWaypoints.push( - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' } + { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' }, + { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' } ); }); @@ -160,12 +160,11 @@ describe('DirectionsControl', () => { mockWaypoints.push( { id: '0', - geocodeResults: [ - { selected: true, sourcelnglat: [13.4, 52.5] }, - ] as never[], + geocodeResults: [], + selectedAddress: { sourcelnglat: [13.4, 52.5] } as never, userInput: 'Berlin', }, - { id: '1', geocodeResults: [], userInput: '' } + { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' } ); render(); @@ -222,16 +221,14 @@ describe('DirectionsControl', () => { mockWaypoints.push( { id: '0', - geocodeResults: [ - { selected: true, sourcelnglat: [13.4, 52.5] }, - ] as never[], + geocodeResults: [], + selectedAddress: { sourcelnglat: [13.4, 52.5] } as never, userInput: 'Berlin', }, { id: '1', - geocodeResults: [ - { selected: true, sourcelnglat: [10.0, 48.0] }, - ] as never[], + geocodeResults: [], + selectedAddress: { sourcelnglat: [10.0, 48.0] } as never, userInput: 'Munich', } ); @@ -259,8 +256,8 @@ describe('DirectionsControl URL parsing', () => { mockResults.data = null; mockWaypoints.length = 0; mockWaypoints.push( - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' } + { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' }, + { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' } ); }); diff --git a/src/components/directions/directions.tsx b/src/components/directions/directions.tsx index eeca19f..b8fcde2 100644 --- a/src/components/directions/directions.tsx +++ b/src/components/directions/directions.tsx @@ -52,7 +52,7 @@ export const DirectionsControl = () => { const alreadyHydrated = useDirectionsStore .getState() - .waypoints.some((wp) => wp.geocodeResults.some((r) => r.selected)); + .waypoints.some((wp) => wp.selectedAddress); if (alreadyHydrated) { urlParamsProcessed.current = true; return; @@ -83,10 +83,9 @@ export const DirectionsControl = () => { const wps: number[] = []; for (const wp of waypoints) { - for (const result of wp.geocodeResults) { - if (result.selected && result.sourcelnglat) { - wps.push(result.sourcelnglat[0], result.sourcelnglat[1]); - } + const lngLat = wp.selectedAddress?.sourcelnglat; + if (lngLat) { + wps.push(lngLat[0], lngLat[1]); } } @@ -108,8 +107,8 @@ export const DirectionsControl = () => { clearRoutes(); }, [clearWaypoints, clearRoutes]); - const activeWaypointsCount = waypoints.filter((wp) => - wp.geocodeResults.some((r) => r.selected) + const activeWaypointsCount = waypoints.filter( + (wp) => wp.selectedAddress ).length; return ( diff --git a/src/components/directions/waypoints/waypoint-item.spec.tsx b/src/components/directions/waypoints/waypoint-item.spec.tsx index 0708dbd..1bbcf46 100644 --- a/src/components/directions/waypoints/waypoint-item.spec.tsx +++ b/src/components/directions/waypoints/waypoint-item.spec.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { Waypoint } from './waypoint-item'; const mockReceiveGeocodeResults = vi.fn(); -const mockUpdateTextInput = vi.fn(); +const mockSelectAddress = vi.fn(); const mockDoRemoveWaypoint = vi.fn(); const mockRefetchDirections = vi.fn(); const mockSetWaypointFromCoords = vi.fn().mockResolvedValue([]); @@ -45,29 +45,38 @@ vi.mock('@/stores/directions-store', () => ({ { id: 'wp-1', userInput: 'Berlin', - geocodeResults: [ - { - title: 'Berlin, Germany', - addressindex: 0, - displaylnglat: [13.4, 52.5], - selected: true, - }, - ], + geocodeResults: [], + selectedAddress: { + title: 'Berlin, Germany', + addressindex: 0, + displaylnglat: [13.4, 52.5], + }, }, { id: 'wp-2', userInput: 'Munich', geocodeResults: [], + selectedAddress: null, }, ], receiveGeocodeResults: mockReceiveGeocodeResults, - updateTextInput: mockUpdateTextInput, + selectAddress: mockSelectAddress, doRemoveWaypoint: mockDoRemoveWaypoint, }) ), defaultWaypoints: [ - { id: 'default-1', userInput: '', geocodeResults: [] }, - { id: 'default-2', userInput: '', geocodeResults: [] }, + { + id: 'default-1', + userInput: '', + geocodeResults: [], + selectedAddress: null, + }, + { + id: 'default-2', + userInput: '', + geocodeResults: [], + selectedAddress: null, + }, ], })); @@ -171,16 +180,15 @@ describe('Waypoint', () => { }); }); - it('should call updateTextInput and refetchDirections when result is selected', async () => { + it('should call selectAddress and refetchDirections when result is selected', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId('select-result')); - expect(mockUpdateTextInput).toHaveBeenCalledWith({ - inputValue: 'Selected', + expect(mockSelectAddress).toHaveBeenCalledWith({ index: 0, - addressindex: 0, + address: { title: 'Selected', addressindex: 0, lngLat: [0, 0] }, }); expect(mockRefetchDirections).toHaveBeenCalled(); }); diff --git a/src/components/directions/waypoints/waypoint-item.tsx b/src/components/directions/waypoints/waypoint-item.tsx index 42beb54..4554c37 100644 --- a/src/components/directions/waypoints/waypoint-item.tsx +++ b/src/components/directions/waypoints/waypoint-item.tsx @@ -37,7 +37,7 @@ export const Waypoint = ({ id, index }: WaypointProps) => { const receiveGeocodeResults = useDirectionsStore( (state) => state.receiveGeocodeResults ); - const updateTextInput = useDirectionsStore((state) => state.updateTextInput); + const selectAddress = useDirectionsStore((state) => state.selectAddress); const { refetch: refetchDirections } = useDirectionsQuery(); const { setWaypointFromCoords } = useSetWaypointFromCoords(); const doRemoveWaypoint = useDirectionsStore( @@ -45,8 +45,8 @@ export const Waypoint = ({ id, index }: WaypointProps) => { ); const { mainMap } = useMap(); const waypoint = waypoints[index]; - const { userInput, geocodeResults } = waypoint!; - const selectedCoords = geocodeResults?.find((r) => r.selected)?.displaylnglat; + const { userInput, geocodeResults, selectedAddress } = waypoint!; + const selectedCoords = selectedAddress?.displaylnglat; const handleGeocodeResults = useCallback( (addresses: ActiveWaypoint[]) => { @@ -87,15 +87,10 @@ export const Waypoint = ({ id, index }: WaypointProps) => { const handleResultSelect = useCallback( (result: ActiveWaypoint) => { - updateTextInput({ - inputValue: result.title, - index: index, - addressindex: result.addressindex, - }); - + selectAddress({ index, address: result }); refetchDirections(); }, - [updateTextInput, index, refetchDirections] + [selectAddress, index, refetchDirections] ); const style = { @@ -185,10 +180,7 @@ export const Waypoint = ({ id, index }: WaypointProps) => { refetchDirections(); }} data-testid="remove-waypoint-button" - disabled={ - waypoints.length < 3 && - !geocodeResults?.some((r) => r.selected) - } + disabled={waypoints.length < 3 && !selectedAddress} > diff --git a/src/components/isochrones/isochrones.spec.tsx b/src/components/isochrones/isochrones.spec.tsx index b45f86f..3411293 100644 --- a/src/components/isochrones/isochrones.spec.tsx +++ b/src/components/isochrones/isochrones.spec.tsx @@ -30,25 +30,16 @@ const mockResults = { show: true, }; -const mockGeocodeResults: { - selected: boolean; - sourcelnglat: [number, number]; -}[] = []; +let mockSelectedAddress: { sourcelnglat: [number, number] } | null = null; vi.mock('@/stores/isochrones-store', () => { + const getState = () => ({ + results: mockResults, + selectedAddress: mockSelectedAddress, + }); const useIsochronesStore = Object.assign( - vi.fn((selector) => - selector({ - results: mockResults, - geocodeResults: mockGeocodeResults, - }) - ), - { - getState: () => ({ - results: mockResults, - geocodeResults: mockGeocodeResults, - }), - } + vi.fn((selector) => selector(getState())), + { getState } ); return { useIsochronesStore }; }); @@ -96,7 +87,7 @@ describe('IsochronesControl', () => { beforeEach(() => { vi.clearAllMocks(); mockResults.data = null; - mockGeocodeResults.length = 0; + mockSelectedAddress = null; }); it('should render without crashing', () => { @@ -143,10 +134,7 @@ describe('IsochronesControl', () => { }); it('should sync geocode results to URL', () => { - mockGeocodeResults.push({ - selected: true, - sourcelnglat: [13.4, 52.5], - }); + mockSelectedAddress = { sourcelnglat: [13.4, 52.5] }; render(); @@ -157,10 +145,7 @@ describe('IsochronesControl', () => { }); it('should call navigate with wps parameter when center exists', () => { - mockGeocodeResults.push({ - selected: true, - sourcelnglat: [13.4, 52.5], - }); + mockSelectedAddress = { sourcelnglat: [13.4, 52.5] }; render(); @@ -197,7 +182,7 @@ describe('IsochronesControl URL parsing', () => { beforeEach(() => { vi.clearAllMocks(); mockResults.data = null; - mockGeocodeResults.length = 0; + mockSelectedAddress = null; }); it('should process URL params with valid coordinates', async () => { diff --git a/src/components/isochrones/isochrones.tsx b/src/components/isochrones/isochrones.tsx index 654098e..a33982c 100644 --- a/src/components/isochrones/isochrones.tsx +++ b/src/components/isochrones/isochrones.tsx @@ -16,7 +16,7 @@ import { export const IsochronesControl = () => { const { mainMap } = useMap(); const results = useIsochronesStore((state) => state.results); - const geocodeResults = useIsochronesStore((state) => state.geocodeResults); + const selectedAddress = useIsochronesStore((state) => state.selectedAddress); const initialUrlParams = useRef(parseUrlParams()); const urlParamsProcessed = useRef(false); const navigate = useNavigate({ from: '/$activeTab' }); @@ -26,9 +26,8 @@ export const IsochronesControl = () => { useEffect(() => { if (urlParamsProcessed.current || !mainMap) return; - const alreadyHydrated = useIsochronesStore - .getState() - .geocodeResults.some((r) => r.selected); + const alreadyHydrated = + useIsochronesStore.getState().selectedAddress !== null; if (alreadyHydrated) { urlParamsProcessed.current = true; return; @@ -61,19 +60,13 @@ export const IsochronesControl = () => { // Sync isochrone center to URL useEffect(() => { - let center: string | undefined; - - for (const result of geocodeResults) { - if (result.selected && result.sourcelnglat) { - center = result.sourcelnglat.join(','); - } - } + const center = selectedAddress?.sourcelnglat?.join(','); navigate({ search: (prev) => ({ ...prev, wps: center || undefined }), replace: true, }); - }, [geocodeResults, navigate]); + }, [selectedAddress, navigate]); return ( <> diff --git a/src/components/map/index.spec.tsx b/src/components/map/index.spec.tsx index 5b0af83..c816dfc 100644 --- a/src/components/map/index.spec.tsx +++ b/src/components/map/index.spec.tsx @@ -160,7 +160,7 @@ vi.mock('@/stores/directions-store', () => ({ vi.mock('@/stores/isochrones-store', () => ({ useIsochronesStore: vi.fn((selector) => { const state = { - geocodeResults: [], + selectedAddress: null, }; return selector(state); }), diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx index 32dabe7..371351c 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -383,7 +383,7 @@ export const MapComponent = () => { }, [directionResults, heightPayload, updateInclineDecline]); // Update markers when waypoints or isochrone centers change - const geocodeResults = useIsochronesStore((state) => state.geocodeResults); + const isochroneCenter = useIsochronesStore((state) => state.selectedAddress); const markers = useMemo(() => { const newMarkers: MarkerData[] = []; @@ -397,40 +397,37 @@ export const MapComponent = () => { : isDestination ? 'red' : 'grey'; - waypoint.geocodeResults.forEach((address) => { - if (address.selected) { - newMarkers.push({ - id: `waypoint-${index}`, - lng: address.displaylnglat[0], - lat: address.displaylnglat[1], - type: 'waypoint', - index: index, - title: address.title, - color, - number: (index + 1).toString(), - }); - } - }); - }); - - // Add isochrone center marker - geocodeResults.forEach((address) => { - if (address.selected) { + const address = waypoint.selectedAddress; + if (address) { newMarkers.push({ - id: 'iso-center', + id: `waypoint-${index}`, lng: address.displaylnglat[0], lat: address.displaylnglat[1], - type: 'isocenter', + type: 'waypoint', + index: index, title: address.title, - color: 'purple', - shape: 'star', - number: '1', + color, + number: (index + 1).toString(), }); } }); + // Add isochrone center marker + if (isochroneCenter) { + newMarkers.push({ + id: 'iso-center', + lng: isochroneCenter.displaylnglat[0], + lat: isochroneCenter.displaylnglat[1], + type: 'isocenter', + title: isochroneCenter.title, + color: 'purple', + shape: 'star', + number: '1', + }); + } + return newMarkers; - }, [waypoints, geocodeResults]); + }, [waypoints, isochroneCenter]); //Stores the route content const lastZoomedCoordKeyRef = useRef(null); diff --git a/src/components/types.ts b/src/components/types.ts index 272d153..ce709b5 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,7 +1,6 @@ export interface ActiveWaypoint { title: string; description?: string; - selected?: boolean; addresslnglat?: [number, number]; sourcelnglat?: [number, number]; displaylnglat: [number, number]; @@ -177,7 +176,6 @@ export interface IsochronesRequestParams { export interface Center { title: string; description: string; - selected: boolean; addresslnglat: number[]; sourcelnglat: number[]; displaylnglat: number[]; diff --git a/src/components/ui/waypoint-search.tsx b/src/components/ui/waypoint-search.tsx index 4b66523..47613dd 100644 --- a/src/components/ui/waypoint-search.tsx +++ b/src/components/ui/waypoint-search.tsx @@ -86,7 +86,6 @@ export const WaypointSearch = ({ { title: internalValue.trim(), description: '', - selected: false, addresslnglat: result.lngLat, sourcelnglat: result.lngLat, displaylnglat: result.lngLat, diff --git a/src/hooks/use-directions-queries.ts b/src/hooks/use-directions-queries.ts index 6856ea9..58198c3 100644 --- a/src/hooks/use-directions-queries.ts +++ b/src/hooks/use-directions-queries.ts @@ -17,11 +17,17 @@ import { forward_geocode, parseGeocodeResponse } from '@/utils/nominatim'; import { filterProfileSettings } from '@/utils/filter-profile-settings'; import { getDirectionsLanguage } from '@/utils/directions-language'; import { useCommonStore } from '@/stores/common-store'; -import { useDirectionsStore, type Waypoint } from '@/stores/directions-store'; +import { + createCoordinateAddress, + useDirectionsStore, + type Waypoint, +} from '@/stores/directions-store'; import { router } from '@/routes'; const getActiveWaypoints = (waypoints: Waypoint[]): ActiveWaypoint[] => - waypoints.flatMap((wp) => wp.geocodeResults.filter((r) => r.selected)); + waypoints + .map((wp) => wp.selectedAddress) + .filter((address) => address !== null); async function fetchDirections() { const waypoints = useDirectionsStore.getState().waypoints; @@ -126,16 +132,10 @@ export function useDirectionsQuery() { } export function useSetWaypointFromCoords() { - const receiveGeocodeResults = useDirectionsStore( - (state) => state.receiveGeocodeResults - ); - const updateTextInput = useDirectionsStore((state) => state.updateTextInput); + const selectAddress = useDirectionsStore((state) => state.selectAddress); const addEmptyWaypointToEnd = useDirectionsStore( (state) => state.addEmptyWaypointToEnd ); - const updatePlaceholderAddressAtIndex = useDirectionsStore( - (state) => state.updatePlaceholderAddressAtIndex - ); const setWaypointFromCoords = async ( lng: number, @@ -153,27 +153,9 @@ export function useSetWaypointFromCoords() { } } - // Set placeholder immediately - updatePlaceholderAddressAtIndex(index, lng, lat); - - const lngLat: [number, number] = [lng, lat]; - const address: ActiveWaypoint = { - title: `${lng.toFixed(6)}, ${lat.toFixed(6)}`, - key: 0, - selected: true, - addresslnglat: lngLat, - sourcelnglat: lngLat, - displaylnglat: lngLat, - addressindex: 0, - }; - const addresses = [address]; - receiveGeocodeResults({ addresses, index }); - updateTextInput({ - inputValue: address.title, - index, - addressindex: 0, - }); - return addresses; + const address = createCoordinateAddress(lng, lat); + selectAddress({ index, address }); + return [address]; }; return { setWaypointFromCoords }; @@ -188,7 +170,6 @@ async function fetchForwardGeocode( { title: lngLat.toString(), key: 0, - selected: false, addresslnglat: lngLat, sourcelnglat: lngLat, displaylnglat: lngLat, diff --git a/src/hooks/use-isochrones-queries.ts b/src/hooks/use-isochrones-queries.ts index 4a90a0f..8f49430 100644 --- a/src/hooks/use-isochrones-queries.ts +++ b/src/hooks/use-isochrones-queries.ts @@ -24,13 +24,13 @@ import { useIsochronesStore } from '@/stores/isochrones-store'; import { router } from '@/routes'; async function fetchIsochrones() { - const { geocodeResults, maxRange, interval, denoise, generalize } = + const { selectedAddress, maxRange, interval, denoise, generalize } = useIsochronesStore.getState(); const profile = router.state.location.search.profile; const { settings: rawSettings } = useCommonStore.getState(); const settings = filterProfileSettings(profile || 'bicycle', rawSettings); - const center = geocodeResults.find((result) => result.selected); + const center = selectedAddress; if (!center) { return null; @@ -142,7 +142,6 @@ export function useReverseGeocodeIsochrones() { // Set placeholder immediately const placeholderAddresses: ActiveWaypoint[] = [ { - selected: true, title: '', displaylnglat: [lng, lat], sourcelnglat: [lng, lat], @@ -184,7 +183,6 @@ async function fetchForwardGeocode( { title: lngLat.toString(), key: 0, - selected: false, addresslnglat: lngLat, sourcelnglat: lngLat, displaylnglat: lngLat, diff --git a/src/hooks/use-optimized-route-query.ts b/src/hooks/use-optimized-route-query.ts index 78b1d3f..b12c848 100644 --- a/src/hooks/use-optimized-route-query.ts +++ b/src/hooks/use-optimized-route-query.ts @@ -27,15 +27,12 @@ export function useOptimizedRouteQuery() { const mutation = useMutation({ mutationFn: async () => { - const relevantWaypoints: Waypoint[] = []; - - const activeWaypoints = waypoints.flatMap((wp) => { - const selected = wp.geocodeResults.filter((r) => r.selected); - if (selected.length > 0) { - relevantWaypoints.push(wp); - } - return selected; - }); + const relevantWaypoints: Waypoint[] = waypoints.filter( + (wp) => wp.selectedAddress + ); + const activeWaypoints = relevantWaypoints.map( + (wp) => wp.selectedAddress! + ); if (activeWaypoints.length < 4) { throw new Error('Not enough waypoints to optimize'); diff --git a/src/stores/directions-store.spec.ts b/src/stores/directions-store.spec.ts new file mode 100644 index 0000000..fa9a91c --- /dev/null +++ b/src/stores/directions-store.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import type { ActiveWaypoint } from '@/components/types'; +import { + createCoordinateAddress, + defaultWaypoints, + useDirectionsStore, +} from './directions-store'; + +const berlin: ActiveWaypoint = { + title: 'Berlin, Germany', + displaylnglat: [13.4, 52.5], + sourcelnglat: [13.4, 52.5], + key: 0, + addressindex: 0, +}; + +const munich: ActiveWaypoint = { + title: 'Munich, Germany', + displaylnglat: [11.58, 48.14], + sourcelnglat: [11.58, 48.14], + key: 1, + addressindex: 1, +}; + +const waypointIds = () => + useDirectionsStore.getState().waypoints.map((wp) => wp.id); + +describe('directions-store', () => { + beforeEach(() => { + useDirectionsStore.setState({ waypoints: [...defaultWaypoints] }); + }); + + describe('selectAddress', () => { + it('records the address and shows its title as the input value', () => { + useDirectionsStore + .getState() + .selectAddress({ index: 0, address: berlin }); + + const waypoint = useDirectionsStore.getState().waypoints[0]!; + expect(waypoint.selectedAddress).toEqual(berlin); + expect(waypoint.userInput).toBe('Berlin, Germany'); + }); + + it('ignores indices that have no waypoint', () => { + useDirectionsStore + .getState() + .selectAddress({ index: 7, address: berlin }); + + expect(useDirectionsStore.getState().waypoints).toHaveLength(2); + }); + }); + + describe('receiveGeocodeResults', () => { + // Regression: a new (or empty) search used to wipe the selection, which + // dropped the waypoint from the route and from the permalink. + it('keeps the selected address when new candidates arrive', () => { + const { selectAddress, receiveGeocodeResults } = + useDirectionsStore.getState(); + selectAddress({ index: 0, address: berlin }); + + receiveGeocodeResults({ index: 0, addresses: [munich] }); + + const waypoint = useDirectionsStore.getState().waypoints[0]!; + expect(waypoint.geocodeResults).toEqual([munich]); + expect(waypoint.selectedAddress).toEqual(berlin); + expect(waypoint.userInput).toBe('Berlin, Germany'); + }); + + it('keeps the selected address when a search comes back empty', () => { + const { selectAddress, receiveGeocodeResults } = + useDirectionsStore.getState(); + selectAddress({ index: 0, address: berlin }); + + receiveGeocodeResults({ index: 0, addresses: [] }); + + expect( + useDirectionsStore.getState().waypoints[0]!.selectedAddress + ).toEqual(berlin); + }); + }); + + describe('waypoint ids', () => { + // Regression: ids derived from the waypoint count collided after a + // removal, which gave two rows the same React key. + it('stays unique when a waypoint is removed and another is added', () => { + const { addEmptyWaypointToEnd, doRemoveWaypoint } = + useDirectionsStore.getState(); + + addEmptyWaypointToEnd(); + addEmptyWaypointToEnd(); + doRemoveWaypoint({ index: 1 }); + addEmptyWaypointToEnd(); + + const ids = waypointIds(); + expect(new Set(ids).size).toBe(ids.length); + }); + }); + + describe('doRemoveWaypoint', () => { + it('clears the last two waypoints in place instead of removing them', () => { + const { selectAddress, doRemoveWaypoint } = useDirectionsStore.getState(); + selectAddress({ index: 0, address: berlin }); + + doRemoveWaypoint({ index: 0 }); + + const waypoint = useDirectionsStore.getState().waypoints[0]!; + expect(useDirectionsStore.getState().waypoints).toHaveLength(2); + expect(waypoint.selectedAddress).toBeNull(); + expect(waypoint.userInput).toBe(''); + }); + }); + + describe('addWaypointAtIndex', () => { + it('inserts a placeholder that is already routable', () => { + useDirectionsStore.getState().addWaypointAtIndex({ + index: 1, + placeholder: { lng: 13.4, lat: 52.5 }, + }); + + const waypoint = useDirectionsStore.getState().waypoints[1]!; + expect(waypoint.selectedAddress).toEqual( + createCoordinateAddress(13.4, 52.5) + ); + expect(waypoint.userInput).toBe('13.400000, 52.500000'); + }); + }); +}); diff --git a/src/stores/directions-store.ts b/src/stores/directions-store.ts index 1be212b..435c183 100644 --- a/src/stores/directions-store.ts +++ b/src/stores/directions-store.ts @@ -9,6 +9,7 @@ import { immer } from 'zustand/middleware/immer'; export interface Waypoint { id: string; geocodeResults: ActiveWaypoint[]; + selectedAddress: ActiveWaypoint | null; userInput: string; } @@ -40,6 +41,7 @@ interface LatLng { const createEmptyWaypoint = (id: string): Waypoint => ({ id, geocodeResults: [], + selectedAddress: null, userInput: '', }); @@ -48,23 +50,35 @@ export const defaultWaypoints: Waypoint[] = [ createEmptyWaypoint('1'), ]; +/** An address for a raw coordinate pair, i.e. one that was never geocoded. */ +export const createCoordinateAddress = ( + lng: number, + lat: number +): ActiveWaypoint => { + const lngLat: [number, number] = [lng, lat]; + return { + title: `${lng.toFixed(6)}, ${lat.toFixed(6)}`, + addresslnglat: lngLat, + sourcelnglat: lngLat, + displaylnglat: lngLat, + key: 0, + addressindex: 0, + }; +}; + const getNextWaypointId = (waypoints: Waypoint[]): string => { const maxIndex = Math.max(...waypoints.map((wp) => parseInt(wp.id, 10))); return (isFinite(maxIndex) ? maxIndex + 1 : 0).toString(); }; const hasActiveRoute = (waypoints: Waypoint[]): boolean => - waypoints.filter( - (wp) => - wp.geocodeResults.length > 0 && wp.geocodeResults.some((r) => r.selected) - ).length >= 2; + waypoints.filter((wp) => wp.selectedAddress).length >= 2; export interface DirectionsState { successful: boolean; highlightSegment: HighlightSegment; waypoints: Waypoint[]; zoomObj: ZoomObj; - selectedAddresses: string | (Waypoint | null)[]; results: RouteResult; inclineDeclineTotal?: InclineDeclineTotal; isOptimized: boolean; @@ -80,11 +94,7 @@ interface DirectionsActions { index: number; addresses: ActiveWaypoint[]; }) => void; - updateTextInput: (params: { - inputValue: string; - index: number; - addressindex?: number; - }) => void; + selectAddress: (params: { index: number; address: ActiveWaypoint }) => void; clearWaypoints: () => void; emptyWaypoint: (params: { index: number }) => void; setWaypoint: (waypoints: Waypoint[]) => void; @@ -93,11 +103,6 @@ interface DirectionsActions { doRemoveWaypoint: (params: { index: number }) => void; highlightManeuver: (fromTo: HighlightSegment) => void; zoomToManeuver: (zoomObj: ZoomObj) => void; - updatePlaceholderAddressAtIndex: ( - index: number, - lng: number, - lat: number - ) => void; setIsOptimized: (isOptimized: boolean) => void; setActiveRouteIndex: (index: number) => void; } @@ -111,7 +116,6 @@ export const useDirectionsStore = create()( highlightSegment: { startIndex: -1, endIndex: -1, alternate: -1 }, waypoints: defaultWaypoints, zoomObj: { index: -1, timeNow: -1 }, - selectedAddresses: '', results: { data: null, show: { '0': true } }, isOptimized: false, activeRouteIndex: 0, @@ -173,26 +177,17 @@ export const useDirectionsStore = create()( 'receiveGeocodeResults' ), - updateTextInput: ({ inputValue, index, addressindex }) => + selectAddress: ({ index, address }) => set( (state) => { - state.selectedAddresses = state.waypoints.flatMap((wp) => - wp.geocodeResults.map((_, i) => (i === addressindex ? wp : null)) - ); - if (state.waypoints[index]) { - state.waypoints[index].userInput = inputValue; - state.waypoints[index].geocodeResults = state.waypoints[ - index - ].geocodeResults.map((result, j) => ({ - ...result, - selected: j === addressindex, - })); + state.waypoints[index].selectedAddress = address; + state.waypoints[index].userInput = address.title; state.isOptimized = false; } }, undefined, - 'updateTextInput' + 'selectAddress' ), clearWaypoints: () => @@ -211,6 +206,7 @@ export const useDirectionsStore = create()( if (state.waypoints[index]) { state.waypoints[index].userInput = ''; state.waypoints[index].geocodeResults = []; + state.waypoints[index].selectedAddress = null; state.isOptimized = false; } }, @@ -230,23 +226,18 @@ export const useDirectionsStore = create()( addWaypointAtIndex: ({ index, placeholder }) => set( (state) => { - const id = getNextWaypointId(state.waypoints); - - const newWaypoint: Waypoint = placeholder - ? { - id, - geocodeResults: [ - { - title: '', - displaylnglat: [placeholder.lng, placeholder.lat], - sourcelnglat: [placeholder.lng, placeholder.lat], - key: index, - addressindex: index, - }, - ], - userInput: `${placeholder.lng.toFixed(6)}, ${placeholder.lat.toFixed(6)}`, - } - : createEmptyWaypoint(id); + const newWaypoint = createEmptyWaypoint( + getNextWaypointId(state.waypoints) + ); + + if (placeholder) { + const address = createCoordinateAddress( + placeholder.lng, + placeholder.lat + ); + newWaypoint.selectedAddress = address; + newWaypoint.userInput = address.title; + } state.waypoints.splice(index, 0, newWaypoint); state.isOptimized = false; @@ -259,7 +250,7 @@ export const useDirectionsStore = create()( set( (state) => { state.waypoints.push( - createEmptyWaypoint((state.waypoints.length + 1).toString()) + createEmptyWaypoint(getNextWaypointId(state.waypoints)) ); state.isOptimized = false; }, @@ -275,6 +266,7 @@ export const useDirectionsStore = create()( } else if (state.waypoints[index]) { state.waypoints[index].userInput = ''; state.waypoints[index].geocodeResults = []; + state.waypoints[index].selectedAddress = null; } state.isOptimized = false; @@ -313,29 +305,6 @@ export const useDirectionsStore = create()( 'zoomToManeuver' ), - updatePlaceholderAddressAtIndex: (index, lng, lat) => - set( - (state) => { - if (state.waypoints[index]) { - state.waypoints[index].geocodeResults = [ - { - title: '', - displaylnglat: [lng, lat], - sourcelnglat: [lng, lat], - key: index, - addressindex: index, - selected: true, - }, - ]; - state.waypoints[index].userInput = - `${lng.toFixed(6)}, ${lat.toFixed(6)}`; - state.isOptimized = false; - } - }, - undefined, - 'updatePlaceholderAddressAtIndex' - ), - setIsOptimized: (isOptimized) => set( (state) => { diff --git a/src/stores/isochrones-store.ts b/src/stores/isochrones-store.ts index 417931d..febbb1a 100644 --- a/src/stores/isochrones-store.ts +++ b/src/stores/isochrones-store.ts @@ -94,10 +94,6 @@ export const useIsochronesStore = create()( state.geocodeResults[addressIndex] ) { state.selectedAddress = state.geocodeResults[addressIndex]; - state.geocodeResults = state.geocodeResults.map((result, i) => ({ - ...result, - selected: i === addressIndex, - })); } }, undefined, diff --git a/src/utils/nominatim.ts b/src/utils/nominatim.ts index 846a34d..d5e9d5e 100644 --- a/src/utils/nominatim.ts +++ b/src/utils/nominatim.ts @@ -51,7 +51,6 @@ export const parseGeocodeResponse = ( processedResults.push({ title: lngLat?.toString() || '', description: '', - selected: true, addresslnglat: '', sourcelnglat: lngLat, displaylnglat: lngLat, @@ -65,7 +64,6 @@ export const parseGeocodeResponse = ( ? result.display_name : lngLat?.toString() || '', description: `https://www.openstreetmap.org/${result.osm_type}/${result.osm_id}`, - selected: false, addresslnglat: [parseFloat(result.lon), parseFloat(result.lat)], sourcelnglat: lngLat === undefined diff --git a/src/utils/valhalla.spec.ts b/src/utils/valhalla.spec.ts index ca88150..4fd00a9 100644 --- a/src/utils/valhalla.spec.ts +++ b/src/utils/valhalla.spec.ts @@ -168,7 +168,6 @@ describe('valhalla.ts', () => { { title: 'Start', description: 'Starting point', - selected: true, addresslnglat: [-74.006, 40.7128], sourcelnglat: [-74.006, 40.7128], displaylnglat: [-74.006, 40.7128], @@ -178,7 +177,6 @@ describe('valhalla.ts', () => { { title: 'Via', description: 'Via point', - selected: true, addresslnglat: [-118.2437, 34.0522], sourcelnglat: [-118.2437, 34.0522], displaylnglat: [-118.2437, 34.0522], @@ -188,7 +186,6 @@ describe('valhalla.ts', () => { { title: 'End', description: 'Ending point', - selected: true, addresslnglat: [-87.6298, 41.8781], sourcelnglat: [-87.6298, 41.8781], displaylnglat: [-87.6298, 41.8781], @@ -462,7 +459,6 @@ describe('valhalla.ts', () => { { title: 'Start', description: 'Starting point', - selected: true, addresslnglat: [-74.006, 40.7128], sourcelnglat: [-74.006, 40.7128], displaylnglat: [-74.006, 40.7128], @@ -472,7 +468,6 @@ describe('valhalla.ts', () => { { title: 'Via 1', description: 'Via point 1', - selected: true, addresslnglat: [-73.99, 40.75], sourcelnglat: [-73.99, 40.75], displaylnglat: [-73.99, 40.75], @@ -482,7 +477,6 @@ describe('valhalla.ts', () => { { title: 'Via 2', description: 'Via point 2', - selected: true, addresslnglat: [-73.98, 40.755], sourcelnglat: [-73.98, 40.755], displaylnglat: [-73.98, 40.755], @@ -492,7 +486,6 @@ describe('valhalla.ts', () => { { title: 'End', description: 'Ending point', - selected: true, addresslnglat: [-87.6298, 41.8781], sourcelnglat: [-87.6298, 41.8781], displaylnglat: [-87.6298, 41.8781], @@ -715,7 +708,6 @@ describe('valhalla.ts', () => { const mockCenter: import('@/components/types').Center = { title: 'Center', description: 'Center point', - selected: true, addresslnglat: [-118.2437, 34.0522], sourcelnglat: [-118.2437, 34.0522], displaylnglat: [-118.2437, 34.0522], @@ -973,7 +965,6 @@ describe('valhalla.ts', () => { { title: 'Start', description: 'Starting point', - selected: true, addresslnglat: [-74.006, 40.7128], sourcelnglat: [-74.006, 40.7128], displaylnglat: [-74.006, 40.7128], @@ -983,7 +974,6 @@ describe('valhalla.ts', () => { { title: 'Via 1', description: 'Via point 1', - selected: true, addresslnglat: [-118.2437, 34.0522], sourcelnglat: [-118.2437, 34.0522], displaylnglat: [-118.2437, 34.0522], @@ -993,7 +983,6 @@ describe('valhalla.ts', () => { { title: 'Via 2', description: 'Via point 2', - selected: true, addresslnglat: [-87.6298, 41.8781], sourcelnglat: [-87.6298, 41.8781], displaylnglat: [-87.6298, 41.8781], @@ -1003,7 +992,6 @@ describe('valhalla.ts', () => { { title: 'End', description: 'Ending point', - selected: true, addresslnglat: [-122.4194, 37.7749], sourcelnglat: [-122.4194, 37.7749], displaylnglat: [-122.4194, 37.7749],