From f4a31660683c6a093ed08d880547298959e2f6a1 Mon Sep 17 00:00:00 2001 From: Nils Nolde Date: Wed, 21 Oct 2026 16:00:13 +0200 Subject: [PATCH] route/isochrone now shows after pasting in mobile browser --- CLAUDE.md | 1 + src/components/directions/directions.spec.tsx | 141 --------------- src/components/directions/directions.tsx | 66 +------ src/components/isochrones/isochrones.spec.tsx | 135 -------------- src/components/isochrones/isochrones.tsx | 69 ------- src/components/route-planner.spec.tsx | 8 + src/components/route-planner.tsx | 6 + src/hooks/use-directions-permalink.spec.ts | 170 ++++++++++++++++++ src/hooks/use-directions-permalink.ts | 72 ++++++++ src/hooks/use-isochrones-permalink.spec.ts | 142 +++++++++++++++ src/hooks/use-isochrones-permalink.ts | 73 ++++++++ 11 files changed, 473 insertions(+), 410 deletions(-) create mode 100644 src/hooks/use-directions-permalink.spec.ts create mode 100644 src/hooks/use-directions-permalink.ts create mode 100644 src/hooks/use-isochrones-permalink.spec.ts create mode 100644 src/hooks/use-isochrones-permalink.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7384836c..9c8eaa8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,7 @@ Server-state lives in TanStack Query. The global `QueryClient` (`src/lib/tanstac - `src/components/map/` — MapLibre map. `index.tsx` is the orchestrator; `parts/` holds map sublayers (route lines, isochrone polygons, hover popups, draw controls, marker icons). `valhalla-layers.ts` defines internal Valhalla edge/node/shortcut/access-restriction MVT layer IDs. - `src/components/directions/`, `src/components/isochrones/`, `src/components/tiles/` — the three tab panels. +- Permalinks (`wps` URL param ↔ waypoints/isochrone center) are handled by `src/hooks/use-directions-permalink.ts` / `use-isochrones-permalink.ts`, called from `RoutePlanner`. They must **not** live inside the tab panels: `SheetContent` unmounts while the sidebar is closed (the default on mobile), so a pasted link would never load. - `src/components/quick-settings.tsx` — left-sidebar "General settings" collapsible panel. Hosts the most-used controls inline so they're visible without opening the advanced panel: ferry/highway/toll icon buttons (a state-decorated `IconEnumButton` for each), a `DateTimeButton`, an alternates slider, and the directions language picker. Used by both the directions and isochrones tabs (the latter passes `showAlternates={false} showLanguage={false}`). Renders the `SettingsButton` ("Advanced settings") at the bottom — that's the entry point to `SettingsPanel`. - `src/components/settings-panel/` — full ("advanced") costing options panel. `settings-options.ts` holds `settingsInit`, `settingsInitTruckOverride`, the per-profile `profileSettings` / `generalSettings` lists, and the `languageOptions` / language storage helpers. `settings-panel.tsx` renders `Profile Settings` + `General Settings`, **filtering out** `use_highways`, `use_tolls`, `use_ferry`, `alternates` since those moved to QuickSettings (params still flow through `filter-profile-settings.ts` for API requests). - `src/components/ui/` — shadcn/ui primitives (do not rename — they're tracked by `components.json`). `icon-enum-setting.tsx` (`IconEnumButton`), `date-time-button.tsx`, and `calendar.tsx` are the QuickSettings building blocks. diff --git a/src/components/directions/directions.spec.tsx b/src/components/directions/directions.spec.tsx index f7326ce2..6f03788a 100644 --- a/src/components/directions/directions.spec.tsx +++ b/src/components/directions/directions.spec.tsx @@ -3,21 +3,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DirectionsControl } from './directions'; -const mockNavigate = vi.fn(); -const mockRefetchDirections = vi.fn(); -const mockSetWaypointFromCoords = vi.fn().mockResolvedValue([]); const mockAddEmptyWaypointToEnd = vi.fn(); const mockClearWaypoints = vi.fn(); const mockClearRoutes = vi.fn(); -vi.mock('@tanstack/react-router', () => ({ - useNavigate: vi.fn(() => mockNavigate), -})); - -vi.mock('@/utils/parse-url-params', () => ({ - parseUrlParams: vi.fn(() => ({})), -})); - const mockWaypoints = [ { id: '0', geocodeResults: [], userInput: '' }, { id: '1', geocodeResults: [], userInput: '' }, @@ -61,15 +50,6 @@ vi.mock('@/stores/directions-store', () => { }; }); -vi.mock('@/hooks/use-directions-queries', () => ({ - useDirectionsQuery: vi.fn(() => ({ - refetch: mockRefetchDirections, - })), - useSetWaypointFromCoords: vi.fn(() => ({ - setWaypointFromCoords: mockSetWaypointFromCoords, - })), -})); - vi.mock('@/hooks/use-optimized-route-query', () => ({ useOptimizedRouteQuery: vi.fn(() => ({ optimizeRoute: vi.fn(), @@ -216,125 +196,4 @@ describe('DirectionsControl', () => { expect(screen.getByTestId('mock-route-card-1')).toBeInTheDocument(); expect(screen.getByTestId('mock-route-card-2')).toBeInTheDocument(); }); - - it('should sync waypoints to URL', () => { - mockWaypoints.length = 0; - mockWaypoints.push( - { - id: '0', - geocodeResults: [ - { selected: true, sourcelnglat: [13.4, 52.5] }, - ] as never[], - userInput: 'Berlin', - }, - { - id: '1', - geocodeResults: [ - { selected: true, sourcelnglat: [10.0, 48.0] }, - ] as never[], - userInput: 'Munich', - } - ); - - render(); - - expect(mockNavigate).toHaveBeenCalledWith({ - search: expect.any(Function), - replace: true, - }); - - const navigateCall = mockNavigate.mock.calls[0]?.[0] as { - search: (prev: Record) => Record; - }; - const searchFn = navigateCall.search; - const result = searchFn({}); - - expect(result.wps).toBe('13.4,52.5,10,48'); - }); -}); - -describe('DirectionsControl URL parsing', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockResults.data = null; - mockWaypoints.length = 0; - mockWaypoints.push( - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' } - ); - }); - - it('should process URL params with valid coordinates (Berlin)', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '13.365016850476763,52.483706198952575,13.422421655040836,52.49336042169804', - }); - - render(); - - expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( - 13.365016850476763, - 52.483706198952575, - 0, - { isPermalink: true } - ); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( - 13.422421655040836, - 52.49336042169804, - 1, - { isPermalink: true } - ); - }); - - it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '103.66492937866911,1.4827280571964963,103.66421854954496,1.4840285187178779', - }); - - render(); - - expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( - 103.66492937866911, - 1.4827280571964963, - 0, - { isPermalink: true } - ); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( - 103.66421854954496, - 1.4840285187178779, - 1, - { isPermalink: true } - ); - }); - - it('should skip truly invalid coordinates from URL', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '999,999', - }); - - render(); - - expect(mockSetWaypointFromCoords).not.toHaveBeenCalled(); - }); - - it('should handle coordinates near edge of valid range', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '179.9,89,-179.9,-89', - }); - - render(); - - expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith(179.9, 89, 0, { - isPermalink: true, - }); - expect(mockSetWaypointFromCoords).toHaveBeenCalledWith(-179.9, -89, 1, { - isPermalink: true, - }); - }); }); diff --git a/src/components/directions/directions.tsx b/src/components/directions/directions.tsx index eeca19f7..43c5585e 100644 --- a/src/components/directions/directions.tsx +++ b/src/components/directions/directions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { Waypoints } from './waypoints/waypoint-list'; @@ -9,14 +9,7 @@ import type { ParsedDirectionsGeometry } from '@/components/types'; import { Button } from '@/components/ui/button'; import { MapPinPlus, MapPinXInside } from 'lucide-react'; import { RouteCard } from './route-card'; -import { parseUrlParams } from '@/utils/parse-url-params'; -import { isValidCoordinates } from '@/utils/geom'; -import { useNavigate } from '@tanstack/react-router'; import { useDirectionsStore } from '@/stores/directions-store'; -import { - useDirectionsQuery, - useSetWaypointFromCoords, -} from '@/hooks/use-directions-queries'; import { useOptimizedRouteQuery } from '@/hooks/use-optimized-route-query'; import { Sparkles } from 'lucide-react'; import { @@ -33,11 +26,6 @@ export const DirectionsControl = () => { ); const clearWaypoints = useDirectionsStore((state) => state.clearWaypoints); const clearRoutes = useDirectionsStore((state) => state.clearRoutes); - const initialUrlParams = useRef(parseUrlParams()); - const urlParamsProcessed = useRef(false); - const navigate = useNavigate({ from: '/$activeTab' }); - const { refetch: refetchDirections } = useDirectionsQuery(); - const { setWaypointFromCoords } = useSetWaypointFromCoords(); const { optimizeRoute, isPending: isOptimizing } = useOptimizedRouteQuery(); const isOptimized = useDirectionsStore((state) => state.isOptimized); const activeRouteIndex = useDirectionsStore( @@ -47,58 +35,6 @@ export const DirectionsControl = () => { (state) => state.setActiveRouteIndex ); - useEffect(() => { - if (urlParamsProcessed.current) return; - - const alreadyHydrated = useDirectionsStore - .getState() - .waypoints.some((wp) => wp.geocodeResults.some((r) => r.selected)); - if (alreadyHydrated) { - urlParamsProcessed.current = true; - return; - } - - const wpsParam = initialUrlParams.current.wps; - - if (wpsParam) { - const coordinates = wpsParam.split(',').map(Number); - - for (let i = 0; i < coordinates.length; i += 2) { - const lng = coordinates[i]!; - const lat = coordinates[i + 1]!; - - if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue; - - const index = i / 2; - setWaypointFromCoords(lng, lat, index, { isPermalink: true }); - } - refetchDirections(); - } - - urlParamsProcessed.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - 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]); - } - } - } - - navigate({ - search: (prev) => ({ - ...prev, - wps: wps.length > 0 ? wps.join(',') : undefined, - }), - replace: true, - }); - }, [waypoints, navigate]); - const handleAddWaypoint = useCallback(() => { addEmptyWaypointToEnd(); }, [addEmptyWaypointToEnd]); diff --git a/src/components/isochrones/isochrones.spec.tsx b/src/components/isochrones/isochrones.spec.tsx index b45f86f0..7e4b38a7 100644 --- a/src/components/isochrones/isochrones.spec.tsx +++ b/src/components/isochrones/isochrones.spec.tsx @@ -2,27 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { IsochronesControl } from './isochrones'; -const mockNavigate = vi.fn(); -const mockRefetchIsochrones = vi.fn(); -const mockReverseGeocode = vi.fn().mockResolvedValue([]); -const mockFlyTo = vi.fn(); - -vi.mock('@tanstack/react-router', () => ({ - useNavigate: vi.fn(() => mockNavigate), -})); - -vi.mock('react-map-gl/maplibre', () => ({ - useMap: vi.fn(() => ({ - mainMap: { - flyTo: mockFlyTo, - }, - })), -})); - -vi.mock('@/utils/parse-url-params', () => ({ - parseUrlParams: vi.fn(() => ({})), -})); - const mockResults = { data: null as null | { features: { properties: { contour: number; area: number } }[]; @@ -30,38 +9,22 @@ const mockResults = { show: true, }; -const mockGeocodeResults: { - selected: boolean; - sourcelnglat: [number, number]; -}[] = []; - vi.mock('@/stores/isochrones-store', () => { const useIsochronesStore = Object.assign( vi.fn((selector) => selector({ results: mockResults, - geocodeResults: mockGeocodeResults, }) ), { getState: () => ({ results: mockResults, - geocodeResults: mockGeocodeResults, }), } ); return { useIsochronesStore }; }); -vi.mock('@/hooks/use-isochrones-queries', () => ({ - useIsochronesQuery: vi.fn(() => ({ - refetch: mockRefetchIsochrones, - })), - useReverseGeocodeIsochrones: vi.fn(() => ({ - reverseGeocode: mockReverseGeocode, - })), -})); - vi.mock('./waypoints', () => ({ Waypoints: () =>
Waypoints
, })); @@ -96,7 +59,6 @@ describe('IsochronesControl', () => { beforeEach(() => { vi.clearAllMocks(); mockResults.data = null; - mockGeocodeResults.length = 0; }); it('should render without crashing', () => { @@ -142,105 +104,8 @@ describe('IsochronesControl', () => { expect(card).toHaveAttribute('data-show-on-map', 'false'); }); - it('should sync geocode results to URL', () => { - mockGeocodeResults.push({ - selected: true, - sourcelnglat: [13.4, 52.5], - }); - - render(); - - expect(mockNavigate).toHaveBeenCalledWith({ - search: expect.any(Function), - replace: true, - }); - }); - - it('should call navigate with wps parameter when center exists', () => { - mockGeocodeResults.push({ - selected: true, - sourcelnglat: [13.4, 52.5], - }); - - render(); - - expect(mockNavigate.mock.calls.length).toBeGreaterThan(0); - const navigateCall = mockNavigate.mock.calls[0]?.[0] as { - search: (prev: Record) => Record; - }; - const searchFn = navigateCall.search; - const result = searchFn({}); - - expect(result.wps).toBe('13.4,52.5'); - }); - - it('should call navigate with undefined wps when no center', () => { - render(); - - expect(mockNavigate.mock.calls.length).toBeGreaterThan(0); - const navigateCall = mockNavigate.mock.calls[0]?.[0] as { - search: (prev: Record) => Record; - }; - const searchFn = navigateCall.search; - const result = searchFn({ wps: 'old-value' }); - - expect(result.wps).toBeUndefined(); - }); - it('should render QuickSettings component', () => { render(); expect(screen.getByTestId('mock-quick-settings')).toBeInTheDocument(); }); }); - -describe('IsochronesControl URL parsing', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockResults.data = null; - mockGeocodeResults.length = 0; - }); - - it('should process URL params with valid coordinates', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '13.4,52.5', - }); - - render(); - - expect(mockReverseGeocode).toHaveBeenCalledWith(13.4, 52.5); - expect(mockFlyTo).toHaveBeenCalledWith({ - center: [13.4, 52.5], - zoom: 12, - }); - }); - - it('should skip invalid coordinates from URL', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '999,999', - }); - - render(); - - expect(mockReverseGeocode).not.toHaveBeenCalled(); - }); - - it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '103.66492937866911,1.4827280571964963', - }); - - render(); - - expect(mockReverseGeocode).toHaveBeenCalledWith( - 103.66492937866911, - 1.4827280571964963 - ); - expect(mockFlyTo).toHaveBeenCalledWith({ - center: [103.66492937866911, 1.4827280571964963], - zoom: 12, - }); - }); -}); diff --git a/src/components/isochrones/isochrones.tsx b/src/components/isochrones/isochrones.tsx index 654098ee..d36c261c 100644 --- a/src/components/isochrones/isochrones.tsx +++ b/src/components/isochrones/isochrones.tsx @@ -1,80 +1,11 @@ -import { useEffect, useRef } from 'react'; import { Waypoints } from './waypoints'; import { SettingsFooter } from '@/components/settings-footer'; import { QuickSettings } from '@/components/quick-settings'; import { useIsochronesStore } from '@/stores/isochrones-store'; import { IsochroneCard } from './isochrone-card'; -import { parseUrlParams } from '@/utils/parse-url-params'; -import { isValidCoordinates } from '@/utils/geom'; -import { useNavigate } from '@tanstack/react-router'; -import { useMap } from 'react-map-gl/maplibre'; -import { - useIsochronesQuery, - useReverseGeocodeIsochrones, -} from '@/hooks/use-isochrones-queries'; export const IsochronesControl = () => { - const { mainMap } = useMap(); const results = useIsochronesStore((state) => state.results); - const geocodeResults = useIsochronesStore((state) => state.geocodeResults); - const initialUrlParams = useRef(parseUrlParams()); - const urlParamsProcessed = useRef(false); - const navigate = useNavigate({ from: '/$activeTab' }); - const { refetch: refetchIsochrones } = useIsochronesQuery(); - const { reverseGeocode } = useReverseGeocodeIsochrones(); - - useEffect(() => { - if (urlParamsProcessed.current || !mainMap) return; - - const alreadyHydrated = useIsochronesStore - .getState() - .geocodeResults.some((r) => r.selected); - if (alreadyHydrated) { - urlParamsProcessed.current = true; - return; - } - - const wpsParam = initialUrlParams.current.wps; - - if (wpsParam) { - const coordinates = wpsParam.split(',').map(Number); - - for (let i = 0; i < coordinates.length; i += 2) { - const lng = coordinates[i]!; - const lat = coordinates[i + 1]!; - - if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue; - - reverseGeocode(lng, lat).then(() => { - refetchIsochrones(); - }); - } - - mainMap.flyTo({ - center: [coordinates[0]!, coordinates[1]!], - zoom: 12, - }); - } - - urlParamsProcessed.current = true; - }, [mainMap, reverseGeocode, refetchIsochrones]); - - // Sync isochrone center to URL - useEffect(() => { - let center: string | undefined; - - for (const result of geocodeResults) { - if (result.selected && result.sourcelnglat) { - center = result.sourcelnglat.join(','); - } - } - - navigate({ - search: (prev) => ({ ...prev, wps: center || undefined }), - replace: true, - }); - }, [geocodeResults, navigate]); - return ( <> diff --git a/src/components/route-planner.spec.tsx b/src/components/route-planner.spec.tsx index 95b1d33d..9dd3fc22 100644 --- a/src/components/route-planner.spec.tsx +++ b/src/components/route-planner.spec.tsx @@ -39,6 +39,14 @@ vi.mock('@/hooks/use-isochrones-queries', () => ({ })), })); +vi.mock('@/hooks/use-directions-permalink', () => ({ + useDirectionsPermalink: vi.fn(), +})); + +vi.mock('@/hooks/use-isochrones-permalink', () => ({ + useIsochronesPermalink: vi.fn(), +})); + vi.mock('./directions/directions', () => ({ DirectionsControl: vi.fn(() => (
Directions Control
diff --git a/src/components/route-planner.tsx b/src/components/route-planner.tsx index 6f880548..99fe79ad 100644 --- a/src/components/route-planner.tsx +++ b/src/components/route-planner.tsx @@ -22,6 +22,8 @@ import { ProfilePicker } from './profile-picker'; import type { Profile } from '@/stores/common-store'; import { useDirectionsQuery } from '@/hooks/use-directions-queries'; import { useIsochronesQuery } from '@/hooks/use-isochrones-queries'; +import { useDirectionsPermalink } from '@/hooks/use-directions-permalink'; +import { useIsochronesPermalink } from '@/hooks/use-isochrones-permalink'; const TAB_CONFIG = { directions: { @@ -49,6 +51,10 @@ export const RoutePlanner = () => { const loading = useCommonStore((state) => state.loading); const toggleDirections = useCommonStore((state) => state.toggleDirections); + // Lives here rather than in the tab panels, which unmount with the sidebar + useDirectionsPermalink(activeTab === 'directions'); + useIsochronesPermalink(activeTab === 'isochrones'); + const tabConfig = TAB_CONFIG[activeTab as keyof typeof TAB_CONFIG]; const handleTabChange = (value: string) => { diff --git a/src/hooks/use-directions-permalink.spec.ts b/src/hooks/use-directions-permalink.spec.ts new file mode 100644 index 00000000..d45c96ba --- /dev/null +++ b/src/hooks/use-directions-permalink.spec.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useDirectionsPermalink } from './use-directions-permalink'; + +const mockNavigate = vi.fn(); +const mockRefetchDirections = vi.fn(); +const mockSetWaypointFromCoords = vi.fn().mockResolvedValue([]); + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => mockNavigate), +})); + +vi.mock('@/utils/parse-url-params', () => ({ + parseUrlParams: vi.fn(() => ({})), +})); + +const mockWaypoints: { + id: string; + geocodeResults: { selected: boolean; sourcelnglat: [number, number] }[]; + userInput: string; +}[] = []; + +vi.mock('@/stores/directions-store', () => { + const useDirectionsStore = Object.assign( + vi.fn((selector) => selector({ waypoints: mockWaypoints })), + { getState: () => ({ waypoints: mockWaypoints }) } + ); + return { useDirectionsStore }; +}); + +vi.mock('@/hooks/use-directions-queries', () => ({ + useDirectionsQuery: vi.fn(() => ({ + refetch: mockRefetchDirections, + })), + useSetWaypointFromCoords: vi.fn(() => ({ + setWaypointFromCoords: mockSetWaypointFromCoords, + })), +})); + +const mockUrlParams = async (params: Record) => { + const parseUrlParams = await import('@/utils/parse-url-params'); + vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue(params); +}; + +describe('useDirectionsPermalink', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWaypoints.length = 0; + }); + + it('should process URL params with valid coordinates (Berlin)', async () => { + await mockUrlParams({ + wps: '13.365016850476763,52.483706198952575,13.422421655040836,52.49336042169804', + }); + + renderHook(() => useDirectionsPermalink(true)); + + expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( + 13.365016850476763, + 52.483706198952575, + 0, + { isPermalink: true } + ); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( + 13.422421655040836, + 52.49336042169804, + 1, + { isPermalink: true } + ); + expect(mockRefetchDirections).toHaveBeenCalledTimes(1); + }); + + it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => { + await mockUrlParams({ + wps: '103.66492937866911,1.4827280571964963,103.66421854954496,1.4840285187178779', + }); + + renderHook(() => useDirectionsPermalink(true)); + + expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( + 103.66492937866911, + 1.4827280571964963, + 0, + { isPermalink: true } + ); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith( + 103.66421854954496, + 1.4840285187178779, + 1, + { isPermalink: true } + ); + }); + + it('should skip truly invalid coordinates from URL', async () => { + await mockUrlParams({ wps: '999,999' }); + + renderHook(() => useDirectionsPermalink(true)); + + expect(mockSetWaypointFromCoords).not.toHaveBeenCalled(); + }); + + it('should handle coordinates near edge of valid range', async () => { + await mockUrlParams({ wps: '179.9,89,-179.9,-89' }); + + renderHook(() => useDirectionsPermalink(true)); + + expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith(179.9, 89, 0, { + isPermalink: true, + }); + expect(mockSetWaypointFromCoords).toHaveBeenCalledWith(-179.9, -89, 1, { + isPermalink: true, + }); + }); + + it('should hydrate only once across re-renders', async () => { + await mockUrlParams({ wps: '13.4,52.5,10,48' }); + + const { rerender } = renderHook(() => useDirectionsPermalink(true)); + rerender(); + + expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); + expect(mockRefetchDirections).toHaveBeenCalledTimes(1); + }); + + it('should do nothing while disabled and hydrate once enabled', async () => { + await mockUrlParams({ wps: '13.4,52.5,10,48' }); + + const { rerender } = renderHook( + ({ enabled }) => useDirectionsPermalink(enabled), + { initialProps: { enabled: false } } + ); + + expect(mockSetWaypointFromCoords).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + expect(mockSetWaypointFromCoords).toHaveBeenCalledTimes(2); + }); + + it('should sync waypoints to URL', () => { + mockWaypoints.push( + { + id: '0', + geocodeResults: [{ selected: true, sourcelnglat: [13.4, 52.5] }], + userInput: 'Berlin', + }, + { + id: '1', + geocodeResults: [{ selected: true, sourcelnglat: [10.0, 48.0] }], + userInput: 'Munich', + } + ); + + renderHook(() => useDirectionsPermalink(true)); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.any(Function), + replace: true, + }); + + const navigateCall = mockNavigate.mock.calls[0]?.[0] as { + search: (prev: Record) => Record; + }; + expect(navigateCall.search({}).wps).toBe('13.4,52.5,10,48'); + }); +}); diff --git a/src/hooks/use-directions-permalink.ts b/src/hooks/use-directions-permalink.ts new file mode 100644 index 00000000..e92c518a --- /dev/null +++ b/src/hooks/use-directions-permalink.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useDirectionsStore } from '@/stores/directions-store'; +import { + useDirectionsQuery, + useSetWaypointFromCoords, +} from '@/hooks/use-directions-queries'; +import { parseUrlParams } from '@/utils/parse-url-params'; +import { isValidCoordinates } from '@/utils/geom'; + +/** + * Hydrates directions waypoints from the `wps` URL param once and keeps the + * URL in sync with the selected waypoints afterwards. + * + * Must be called from an always-mounted component: the directions panel + * content is unmounted while the sidebar is closed (default on mobile). + */ +export function useDirectionsPermalink(enabled: boolean) { + const waypoints = useDirectionsStore((state) => state.waypoints); + const urlParamsProcessed = useRef(false); + const navigate = useNavigate({ from: '/$activeTab' }); + const { refetch: refetchDirections } = useDirectionsQuery(); + const { setWaypointFromCoords } = useSetWaypointFromCoords(); + + useEffect(() => { + if (!enabled || urlParamsProcessed.current) return; + urlParamsProcessed.current = true; + + const alreadyHydrated = useDirectionsStore + .getState() + .waypoints.some((wp) => wp.geocodeResults.some((r) => r.selected)); + if (alreadyHydrated) return; + + const wpsParam = parseUrlParams().wps; + if (!wpsParam) return; + + const coordinates = wpsParam.split(',').map(Number); + for (let i = 0; i < coordinates.length; i += 2) { + const lng = coordinates[i]!; + const lat = coordinates[i + 1]!; + + if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue; + + setWaypointFromCoords(lng, lat, i / 2, { isPermalink: true }); + } + refetchDirections(); + }, [enabled, setWaypointFromCoords, refetchDirections]); + + useEffect(() => { + if (!enabled) return; + + const wps: number[] = []; + for (const wp of waypoints) { + for (const geocodeResult of wp.geocodeResults) { + if (geocodeResult.selected && geocodeResult.sourcelnglat) { + wps.push( + geocodeResult.sourcelnglat[0], + geocodeResult.sourcelnglat[1] + ); + } + } + } + + navigate({ + search: (prev) => ({ + ...prev, + wps: wps.length > 0 ? wps.join(',') : undefined, + }), + replace: true, + }); + }, [enabled, waypoints, navigate]); +} diff --git a/src/hooks/use-isochrones-permalink.spec.ts b/src/hooks/use-isochrones-permalink.spec.ts new file mode 100644 index 00000000..85182fe5 --- /dev/null +++ b/src/hooks/use-isochrones-permalink.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useMap } from 'react-map-gl/maplibre'; +import { useIsochronesPermalink } from './use-isochrones-permalink'; + +const mockNavigate = vi.fn(); +const mockRefetchIsochrones = vi.fn(); +const mockReverseGeocode = vi.fn().mockResolvedValue([]); +const mockFlyTo = vi.fn(); + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => mockNavigate), +})); + +vi.mock('react-map-gl/maplibre', () => ({ + useMap: vi.fn(() => ({ + mainMap: { + flyTo: mockFlyTo, + }, + })), +})); + +vi.mock('@/utils/parse-url-params', () => ({ + parseUrlParams: vi.fn(() => ({})), +})); + +const mockGeocodeResults: { + selected: boolean; + sourcelnglat: [number, number]; +}[] = []; + +vi.mock('@/stores/isochrones-store', () => { + const useIsochronesStore = Object.assign( + vi.fn((selector) => selector({ geocodeResults: mockGeocodeResults })), + { getState: () => ({ geocodeResults: mockGeocodeResults }) } + ); + return { useIsochronesStore }; +}); + +vi.mock('@/hooks/use-isochrones-queries', () => ({ + useIsochronesQuery: vi.fn(() => ({ + refetch: mockRefetchIsochrones, + })), + useReverseGeocodeIsochrones: vi.fn(() => ({ + reverseGeocode: mockReverseGeocode, + })), +})); + +const mockUrlParams = async (params: Record) => { + const parseUrlParams = await import('@/utils/parse-url-params'); + vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue(params); +}; + +const lastSearchFn = () => + ( + mockNavigate.mock.calls.at(-1)?.[0] as { + search: (prev: Record) => Record; + } + ).search; + +describe('useIsochronesPermalink', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGeocodeResults.length = 0; + }); + + it('should process URL params with valid coordinates', async () => { + await mockUrlParams({ wps: '13.4,52.5' }); + + renderHook(() => useIsochronesPermalink(true)); + + expect(mockReverseGeocode).toHaveBeenCalledWith(13.4, 52.5); + expect(mockFlyTo).toHaveBeenCalledWith({ + center: [13.4, 52.5], + zoom: 12, + }); + }); + + it('should skip invalid coordinates from URL', async () => { + await mockUrlParams({ wps: '999,999' }); + + renderHook(() => useIsochronesPermalink(true)); + + expect(mockReverseGeocode).not.toHaveBeenCalled(); + }); + + it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => { + await mockUrlParams({ wps: '103.66492937866911,1.4827280571964963' }); + + renderHook(() => useIsochronesPermalink(true)); + + expect(mockReverseGeocode).toHaveBeenCalledWith( + 103.66492937866911, + 1.4827280571964963 + ); + expect(mockFlyTo).toHaveBeenCalledWith({ + center: [103.66492937866911, 1.4827280571964963], + zoom: 12, + }); + }); + + it('should do nothing while disabled', async () => { + await mockUrlParams({ wps: '13.4,52.5' }); + + renderHook(() => useIsochronesPermalink(false)); + + expect(mockReverseGeocode).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should not touch the URL before the map is ready', async () => { + await mockUrlParams({ wps: '13.4,52.5' }); + vi.mocked(useMap).mockReturnValueOnce({} as ReturnType); + + const { rerender } = renderHook(() => useIsochronesPermalink(true)); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockReverseGeocode).not.toHaveBeenCalled(); + + rerender(); + + expect(mockReverseGeocode).toHaveBeenCalledWith(13.4, 52.5); + }); + + it('should call navigate with wps parameter when center exists', () => { + mockGeocodeResults.push({ selected: true, sourcelnglat: [13.4, 52.5] }); + + renderHook(() => useIsochronesPermalink(true)); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.any(Function), + replace: true, + }); + expect(lastSearchFn()({}).wps).toBe('13.4,52.5'); + }); + + it('should call navigate with undefined wps when no center', () => { + renderHook(() => useIsochronesPermalink(true)); + + expect(lastSearchFn()({ wps: 'old-value' }).wps).toBeUndefined(); + }); +}); diff --git a/src/hooks/use-isochrones-permalink.ts b/src/hooks/use-isochrones-permalink.ts new file mode 100644 index 00000000..bb0b9d58 --- /dev/null +++ b/src/hooks/use-isochrones-permalink.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useMap } from 'react-map-gl/maplibre'; +import { useIsochronesStore } from '@/stores/isochrones-store'; +import { + useIsochronesQuery, + useReverseGeocodeIsochrones, +} from '@/hooks/use-isochrones-queries'; +import { parseUrlParams } from '@/utils/parse-url-params'; +import { isValidCoordinates } from '@/utils/geom'; + +/** + * Hydrates the isochrone center from the `wps` URL param once and keeps the + * URL in sync with the selected center afterwards. + * + * Must be called from an always-mounted component: the isochrones panel + * content is unmounted while the sidebar is closed (default on mobile). + */ +export function useIsochronesPermalink(enabled: boolean) { + const { mainMap } = useMap(); + const geocodeResults = useIsochronesStore((state) => state.geocodeResults); + const urlParamsProcessed = useRef(false); + const navigate = useNavigate({ from: '/$activeTab' }); + const { refetch: refetchIsochrones } = useIsochronesQuery(); + const { reverseGeocode } = useReverseGeocodeIsochrones(); + + useEffect(() => { + if (!enabled || urlParamsProcessed.current || !mainMap) return; + urlParamsProcessed.current = true; + + const alreadyHydrated = useIsochronesStore + .getState() + .geocodeResults.some((r) => r.selected); + if (alreadyHydrated) return; + + const wpsParam = parseUrlParams().wps; + if (!wpsParam) return; + + const coordinates = wpsParam.split(',').map(Number); + for (let i = 0; i < coordinates.length; i += 2) { + const lng = coordinates[i]!; + const lat = coordinates[i + 1]!; + + if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue; + + reverseGeocode(lng, lat).then(() => { + refetchIsochrones(); + }); + } + + mainMap.flyTo({ + center: [coordinates[0]!, coordinates[1]!], + zoom: 12, + }); + }, [enabled, mainMap, reverseGeocode, refetchIsochrones]); + + useEffect(() => { + // Writing before hydration (e.g. map not ready yet) would wipe `wps` + if (!enabled || !urlParamsProcessed.current) return; + + let center: string | undefined; + for (const geocodeResult of geocodeResults) { + if (geocodeResult.selected && geocodeResult.sourcelnglat) { + center = geocodeResult.sourcelnglat.join(','); + } + } + + navigate({ + search: (prev) => ({ ...prev, wps: center || undefined }), + replace: true, + }); + }, [enabled, geocodeResults, navigate]); +}