diff --git a/client/src/components/Map/MapView.leaflet.test.tsx b/client/src/components/Map/MapView.leaflet.test.tsx index b5525a6d3..fa691f3c6 100644 --- a/client/src/components/Map/MapView.leaflet.test.tsx +++ b/client/src/components/Map/MapView.leaflet.test.tsx @@ -94,6 +94,17 @@ function clickOn(el: Element) { }) } +function pointer(type: string, x: number, y: number): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + button: { value: 0 }, + clientX: { value: x }, + clientY: { value: y }, + pointerId: { value: 1 }, + }) + return event +} + // Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster. const places = [ { ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null }, @@ -114,6 +125,143 @@ async function renderMap() { } describe('MapView on a real Leaflet map', () => { + it('RC-08: previews waypoint drags locally and commits only at pointer up without panning', async () => { + const waypoint = { id: 'via-1', lat: 34.689, lng: 135.8398, role: 'via' as const } + const waypoints = [waypoint] + const onMove = vi.fn(() => true) + const onSelect = vi.fn() + const onMapClick = vi.fn() + const view = render( + + ) + const map = maps[maps.length - 1] + await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull()) + const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement + let marker: L.Marker | null = null + map.eachLayer(layer => { + if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer + }) + expect(marker).not.toBeNull() + const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng]) + const end = start.add([30, 20]) + const expected = map.containerPointToLatLng(end) + const center = map.getCenter() + + act(() => { + pin.dispatchEvent(pointer('pointerdown', start.x, start.y)) + pin.dispatchEvent(mouse('mousedown', start.x, start.y)) + pin.dispatchEvent(pointer('pointermove', end.x, end.y)) + }) + expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6) + expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6) + expect(onMove).not.toHaveBeenCalled() + expect(waypoints[0]).toEqual(waypoint) + + view.rerender( + + ) + expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6) + expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6) + + act(() => { + pin.dispatchEvent(pointer('pointerup', end.x, end.y)) + pin.dispatchEvent(mouse('mouseup', end.x, end.y)) + pin.dispatchEvent(mouse('click', end.x, end.y)) + }) + expect(onMove).toHaveBeenCalledOnce() + expect(onMove).toHaveBeenCalledWith(waypoint.id, expected.lat, expected.lng) + expect(onSelect).not.toHaveBeenCalled() + expect(onMapClick).not.toHaveBeenCalled() + expect(map.getCenter().lat).toBeCloseTo(center.lat, 6) + expect(map.getCenter().lng).toBeCloseTo(center.lng, 6) + + view.rerender( + + ) + expect(map.getCenter().lat).toBeCloseTo(center.lat, 6) + expect(map.getCenter().lng).toBeCloseTo(center.lng, 6) + }) + + it('RC-08: cancellation restores the marker and a plain click still selects without affecting map-add', async () => { + const waypoint = { id: 'start', lat: 34.689, lng: 135.8398, role: 'start' as const } + const onMove = vi.fn(() => true) + const onSelect = vi.fn() + const onMapClick = vi.fn() + const view = render( + + ) + const map = maps[maps.length - 1] + await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull()) + const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement + let marker: L.Marker | null = null + map.eachLayer(layer => { + if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer + }) + const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng]) + const end = start.add([30, 20]) + + act(() => { + pin.dispatchEvent(pointer('pointerdown', start.x, start.y)) + pin.dispatchEvent(pointer('pointermove', end.x, end.y)) + pin.dispatchEvent(pointer('pointercancel', end.x, end.y)) + }) + expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6) + expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6) + expect(onMove).not.toHaveBeenCalled() + + clickOn(pin) + expect(onSelect).toHaveBeenCalledOnce() + expect(onSelect).toHaveBeenCalledWith(waypoint.id) + expect(onMapClick).not.toHaveBeenCalled() + act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) })) + expect(onMapClick).toHaveBeenCalledOnce() + }) + it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => { useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } }) const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null } diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index b3c12b30b..5dcddc861 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -442,6 +442,48 @@ describe('MapView', () => { expect(mapMock.fitBounds).toHaveBeenCalled() }) + it('honors a location-search camera intent and preserves it through later route updates', () => { + const searchPoint: [number, number] = [35.0116, 135.7681]; + const { rerender } = render( + + ); + expect(mapMock.fitBounds).toHaveBeenCalledOnce(); + expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 })); + const mapContainer = screen.getByTestId('map-container'); + mapMock.fitBounds.mockClear(); + + rerender( + + ); + expect(mapMock.fitBounds).not.toHaveBeenCalled(); + expect(screen.getByTestId('map-container')).toBe(mapContainer); + + rerender( + + ); + expect(mapMock.fitBounds).toHaveBeenCalledOnce(); + }); + it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => { const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]] const { rerender } = render( diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 4c468739a..52d8824ea 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -6,6 +6,7 @@ import MarkerClusterGroup from 'react-leaflet-cluster' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster' import RoadtripViaMarkers from './RoadtripViaMarkers' +import { wirePlannerWaypointDrag } from './plannerWaypointDrag' import HazardLayers from './HazardLayers' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' @@ -449,7 +450,7 @@ interface BoundsControllerProps { */ focusPoints?: [number, number][] /** Changes only when the caller intentionally wants a new initial frame. */ - focusKey?: number + focusKey?: number | string /** False while the map is locked (#2010): an arriving route no longer re-fits it. */ follow?: boolean /** @@ -757,16 +758,71 @@ const MemoMarker = memo(function MemoMarker({ ) }) -function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon { +function plannerWaypointIcon(index: number, selected: boolean, draggable: boolean): L.DivIcon { const size = selected ? 30 : 26 return L.divIcon({ - className: '', + className: 'tour-planner-waypoint-marker', iconSize: [size, size], iconAnchor: [size / 2, size / 2], - html: `${index + 1}`, + html: `${index + 1}`, }) } +function PlannerWaypointMarker({ + point, + index, + selected, + onClick, + onMove, +}: { + point: { id: string; lat: number; lng: number }; + index: number; + selected: boolean; + onClick?: (id: string) => void; + onMove?: (id: string, lat: number, lng: number) => boolean; +}) { + const map = useMap() + const [marker, setMarker] = useState(null) + const handlersRef = useRef({ onClick, onMove }) + handlersRef.current = { onClick, onMove } + const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]) + const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected]) + const eventHandlers = useMemo(() => ({ + click: (event: { originalEvent: MouseEvent }) => { + event.originalEvent.stopPropagation() + handlersRef.current.onClick?.(point.id) + }, + }), [point.id]) + + useEffect(() => { + if (!marker || !onMove) return + const element = marker.getElement() + return wirePlannerWaypointDrag({ + element, + initial: { lat: point.lat, lng: point.lng }, + coordinateAt: (clientX, clientY) => { + const rect = map.getContainer().getBoundingClientRect() + const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]) + return { lat: at.lat, lng: at.lng } + }, + setPosition: ({ lat, lng }) => { marker.setLatLng([lat, lng]) }, + onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false, + }) + }, [map, marker, onMove, point.id, point.lat, point.lng]) + + return ( + + {index + 1} + + ) +} + export const MapView = memo(function MapView({ places = [], dayPlaces = [], @@ -836,6 +892,7 @@ export const MapView = memo(function MapView({ plannerWaypoints = [], selectedPlannerWaypointId = null, onPlannerWaypointClick, + onPlannerWaypointMove, routeProfileFocus = null, viewBaseLayer, onViewBaseLayerChange, @@ -1307,20 +1364,14 @@ export const MapView = memo(function MapView({ ) : null} {(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => ( - { - event.originalEvent.stopPropagation() - onPlannerWaypointClick?.(point.id) - }, - }} - > - {index + 1} - + point={point} + index={index} + selected={point.id === selectedPlannerWaypointId} + onClick={onPlannerWaypointClick} + onMove={onPlannerWaypointMove} + /> ))} {/* GPX imported route geometries */} diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index d05411e1c..98a1fc14c 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -47,6 +47,7 @@ const glMap = vi.hoisted(() => ({ getStyle: vi.fn().mockReturnValue({ layers: [] }), isStyleLoaded: vi.fn().mockReturnValue(true), getCanvasContainer: vi.fn(() => glCanvasContainer), + getContainer: vi.fn(() => glCanvasContainer), getLayer: vi.fn().mockReturnValue(null), queryRenderedFeatures: vi.fn().mockReturnValue([]), querySourceFeatures: vi.fn().mockReturnValue([]), @@ -246,6 +247,17 @@ function buildMapPlace(overrides: Record = {}) { } as any } +function pointer(type: string, x: number, y: number): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + button: { value: 0 }, + clientX: { value: x }, + clientY: { value: y }, + pointerId: { value: 1 }, + }) + return event +} + beforeEach(() => { glMap.on.mockImplementation(() => glMap) glMap.off.mockImplementation(() => glMap) @@ -931,6 +943,55 @@ describe('MapViewGL', () => { expect(glMap.easeTo).not.toHaveBeenCalled() }) + it('honors a location-search camera intent and preserves it through later route updates', async () => { + loadOnAttach(); + const searchPoint: [number, number] = [35.0116, 135.7681]; + const { rerender } = render( + + ); + await act(async () => {}); + expect(glMap.fitBounds).toHaveBeenCalledOnce(); + expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 })); + const mapConstructions = (maplibregl.Map as any).mock.calls.length; + glMap.fitBounds.mockClear(); + + rerender( + + ); + await act(async () => {}); + expect(glMap.fitBounds).not.toHaveBeenCalled(); + expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions); + + rerender( + + ); + await act(async () => {}); + expect(glMap.fitBounds).toHaveBeenCalledOnce(); + }); + it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => { loadOnAttach() const { rerender } = render( @@ -982,6 +1043,126 @@ describe('MapViewGL', () => { expect(glMap.fitBounds).toHaveBeenCalledOnce() }) + it.each(['maplibre-gl', 'mapbox-gl'] as const)('RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera', async (provider) => { + loadOnAttach() + glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })) + glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 }) + glMap.getContainer.mockReturnValue(glCanvasContainer) + const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const } + const waypoints = [waypoint] + const onMove = vi.fn(() => true) + const onSelect = vi.fn() + const onMapClick = vi.fn() + const view = render( + + ) + await act(async () => {}) + glMap.fitBounds.mockClear() + const pin = provider === 'maplibre-gl' + ? glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement + : glMarkers.created.find(marker => marker.element.getAttribute('aria-label') === 'Waypoint 1')?.element as HTMLButtonElement + const startTransform = pin.style.transform + const libraryMarker = provider === 'mapbox-gl' ? glMarkers.created.find(marker => marker.element === pin) : undefined + const startLngLat = libraryMarker?.lngLat + + act(() => { + pin.dispatchEvent(pointer('pointerdown', 10, 10)) + pin.dispatchEvent(pointer('pointermove', 30, 40)) + }) + if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform) + else expect(libraryMarker?.lngLat).not.toEqual(startLngLat) + expect(onMove).not.toHaveBeenCalled() + expect(waypoints[0]).toEqual(waypoint) + + view.rerender( + + ) + if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform) + else expect(libraryMarker?.lngLat).not.toEqual(startLngLat) + + act(() => { + pin.dispatchEvent(pointer('pointerup', 30, 40)) + pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + expect(onMove).toHaveBeenCalledOnce() + expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522) + expect(onSelect).not.toHaveBeenCalled() + expect(onMapClick).not.toHaveBeenCalled() + view.rerender( + + ) + expect(glMap.fitBounds).not.toHaveBeenCalled() + expect(glMap.flyTo).not.toHaveBeenCalled() + expect(glMap.jumpTo).not.toHaveBeenCalled() + }) + + it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => { + loadOnAttach() + const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const } + const onMove = vi.fn(() => true) + const onSelect = vi.fn() + const onMapClick = vi.fn() + render( + + ) + await act(async () => {}) + const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement + const initialTransform = pin.style.transform + act(() => { + pin.dispatchEvent(pointer('pointerdown', 10, 10)) + pin.dispatchEvent(pointer('pointermove', 30, 40)) + pin.dispatchEvent(pointer('pointercancel', 30, 40)) + }) + expect(pin.style.transform).toBe(initialTransform) + expect(onMove).not.toHaveBeenCalled() + + act(() => { + pin.dispatchEvent(pointer('pointerdown', 10, 10)) + pin.dispatchEvent(pointer('pointerup', 10, 10)) + pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + expect(onSelect).toHaveBeenCalledOnce() + expect(onSelect).toHaveBeenCalledWith(waypoint.id) + expect(onMapClick).not.toHaveBeenCalled() + }) + it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => { loadOnAttach() const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 5547d26cd..31fe7e33b 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import { useStableVias } from './viaMarkerState' +import { wirePlannerWaypointDrag } from './plannerWaypointDrag' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import { renderIconMarkup } from '../../utils/iconMarkup' @@ -622,6 +623,7 @@ export function MapViewGL({ plannerWaypoints = [], selectedPlannerWaypointId = null, onPlannerWaypointClick, + onPlannerWaypointMove, routeProfileFocus = null, viewBaseLayer, onViewBaseLayerChange, @@ -708,6 +710,9 @@ export function MapViewGL({ /** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */ const viaPinsRef = useRef([]) const plannerWaypointPinsRef = useRef([]) + const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove }) + plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove } + const plannerWaypointsDraggable = !!onPlannerWaypointMove const profileFocusPinRef = useRef(null) /** The drive-time pills on the offered routes; same treatment. */ const altLabelsRef = useRef([]) @@ -744,6 +749,7 @@ export function MapViewGL({ if (!map || !mapReady) return plannerWaypointPinsRef.current.forEach(pin => pin.remove()) plannerWaypointPinsRef.current = [] + const dragCleanups: (() => void)[] = [] for (const [index, point] of plannerWaypoints.entries()) { const selected = point.id === selectedPlannerWaypointId @@ -754,18 +760,34 @@ export function MapViewGL({ el.setAttribute('aria-label', `Waypoint ${index + 1}`) el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0` const swallow = (event: Event) => event.stopPropagation() - const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) } + const select = (event: Event) => { event.stopPropagation(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) } el.addEventListener('pointerdown', swallow) el.addEventListener('touchstart', swallow, { passive: true }) el.addEventListener('click', select) - plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)) + const pin = attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat) + plannerWaypointPinsRef.current.push(pin) + if (plannerWaypointsDraggable) { + el.style.cursor = 'grab' + dragCleanups.push(wirePlannerWaypointDrag({ + element: el, + initial: { lat: point.lat, lng: point.lng }, + coordinateAt: (clientX, clientY) => { + const rect = map.getContainer().getBoundingClientRect() + const at = map.unproject([clientX - rect.left, clientY - rect.top]) + return { lat: at.lat, lng: at.lng } + }, + setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]), + onCommit: ({ lat, lng }) => plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false, + })) + } } return () => { + dragCleanups.forEach(cleanup => cleanup()) plannerWaypointPinsRef.current.forEach(pin => pin.remove()) plannerWaypointPinsRef.current = [] } - }, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId]) + }, [gl, mapReady, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId]) useEffect(() => () => { profileFocusPinRef.current?.remove() diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index e6f147cfb..a2134004e 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -111,6 +111,8 @@ export interface MapViewProps { plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>; selectedPlannerWaypointId?: string | null; onPlannerWaypointClick?: (id: string) => void; + /** Drops a dragged control point; returns false when the move was refused. */ + onPlannerWaypointMove?: (id: string, lat: number, lng: number) => boolean; routeProfileFocus?: RouteProfileFocus | null; viewBaseLayer?: TourBaseLayer; onViewBaseLayerChange?: (layer: TourBaseLayer) => void; @@ -123,7 +125,7 @@ export interface MapViewProps { */ focusPoints?: [number, number][]; /** Changes only when the caller intentionally wants a new initial frame. */ - focusKey?: number; + focusKey?: number | string; /** * What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge. * diff --git a/client/src/components/Map/plannerWaypointDrag.test.ts b/client/src/components/Map/plannerWaypointDrag.test.ts new file mode 100644 index 000000000..c72400358 --- /dev/null +++ b/client/src/components/Map/plannerWaypointDrag.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; +import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; + +function pointer(type: string, x: number, y: number, pointerId = 1, pointerType: 'mouse' | 'touch' = 'mouse'): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + button: { value: 0 }, + clientX: { value: x }, + clientY: { value: y }, + pointerId: { value: pointerId }, + pointerType: { value: pointerType }, + }); + return event; +} + +describe('Tour waypoint marker drag lifecycle', () => { + it('previews during a drag and commits once on pointer up, swallowing only the generated click', () => { + const element = document.createElement('button'); + const initial = { lat: 48, lng: 11 }; + const preview = vi.fn(); + const onCommit = vi.fn(() => true); + const cleanup = wirePlannerWaypointDrag({ + element, + initial, + coordinateAt: (clientX, clientY) => ({ lat: clientY / 100, lng: clientX / 100 }), + setPosition: preview, + onCommit, + }); + + element.dispatchEvent(pointer('pointerdown', 10, 10)); + element.dispatchEvent(pointer('pointermove', 11, 11)); + expect(preview).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + + element.dispatchEvent(pointer('pointermove', 20, 30)); + expect(preview).toHaveBeenLastCalledWith({ lat: 0.3, lng: 0.2 }); + expect(onCommit).not.toHaveBeenCalled(); + element.dispatchEvent(pointer('pointerup', 30, 40)); + expect(preview).toHaveBeenLastCalledWith({ lat: 0.4, lng: 0.3 }); + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith({ lat: 0.4, lng: 0.3 }); + + const generatedClick = new MouseEvent('click', { bubbles: true, cancelable: true }); + element.dispatchEvent(generatedClick); + expect(generatedClick.defaultPrevented).toBe(true); + const nextClick = new MouseEvent('click', { bubbles: true, cancelable: true }); + element.dispatchEvent(nextClick); + expect(nextClick.defaultPrevented).toBe(false); + cleanup(); + }); + + it('restores the starting coordinate on pointer cancellation and does not commit', () => { + const element = document.createElement('button'); + const initial = { lat: 48, lng: 11 }; + const preview = vi.fn(); + const onCommit = vi.fn(); + const cleanup = wirePlannerWaypointDrag({ + element, + initial, + coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }), + setPosition: preview, + onCommit, + }); + + element.dispatchEvent(pointer('pointerdown', 10, 10)); + element.dispatchEvent(pointer('pointermove', 20, 30)); + element.dispatchEvent(pointer('pointercancel', 20, 30)); + expect(preview).toHaveBeenLastCalledWith(initial); + expect(onCommit).not.toHaveBeenCalled(); + cleanup(); + }); + + it('restores the preview when the canonical commit is rejected', () => { + const element = document.createElement('button'); + const initial = { lat: 48, lng: 11 }; + const preview = vi.fn(); + const cleanup = wirePlannerWaypointDrag({ + element, + initial, + coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }), + setPosition: preview, + onCommit: () => false, + }); + + element.dispatchEvent(pointer('pointerdown', 10, 10)); + element.dispatchEvent(pointer('pointermove', 20, 30)); + element.dispatchEvent(pointer('pointerup', 20, 30)); + expect(preview).toHaveBeenLastCalledWith(initial); + cleanup(); + }); + + it('keeps pointer, mouse, and touch starts from bubbling into map pan handlers', () => { + const mapSurface = document.createElement('div'); + const element = document.createElement('button'); + mapSurface.append(element); + const mapPan = vi.fn(); + mapSurface.addEventListener('pointerdown', mapPan); + mapSurface.addEventListener('mousedown', mapPan); + mapSurface.addEventListener('touchstart', mapPan); + const cleanup = wirePlannerWaypointDrag({ + element, + initial: { lat: 48, lng: 11 }, + coordinateAt: () => ({ lat: 48, lng: 11 }), + setPosition: vi.fn(), + onCommit: vi.fn(), + }); + + element.dispatchEvent(pointer('pointerdown', 10, 10)); + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + element.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true })); + expect(mapPan).not.toHaveBeenCalled(); + cleanup(); + }); + + it('commits a touch-pointer drag once at release', () => { + const element = document.createElement('button'); + const onCommit = vi.fn(() => true); + const cleanup = wirePlannerWaypointDrag({ + element, + initial: { lat: 48, lng: 11 }, + coordinateAt: (clientX, clientY) => ({ lat: clientY / 10, lng: clientX / 10 }), + setPosition: vi.fn(), + onCommit, + }); + + element.dispatchEvent(pointer('pointerdown', 5, 5, 9, 'touch')); + element.dispatchEvent(pointer('pointermove', 25, 35, 9, 'touch')); + expect(onCommit).not.toHaveBeenCalled(); + element.dispatchEvent(pointer('pointerup', 25, 35, 9, 'touch')); + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith({ lat: 3.5, lng: 2.5 }); + cleanup(); + }); + + it('restores existing touch and selection styles when editing drag wiring is removed', () => { + const element = document.createElement('button'); + element.style.touchAction = 'pan-x'; + element.style.userSelect = 'text'; + const cleanup = wirePlannerWaypointDrag({ + element, + initial: { lat: 48, lng: 11 }, + coordinateAt: () => ({ lat: 48, lng: 11 }), + setPosition: vi.fn(), + onCommit: vi.fn(), + }); + expect(element.style.touchAction).toBe('none'); + expect(element.style.userSelect).toBe('none'); + cleanup(); + expect(element.style.touchAction).toBe('pan-x'); + expect(element.style.userSelect).toBe('text'); + }); +}); diff --git a/client/src/components/Map/plannerWaypointDrag.ts b/client/src/components/Map/plannerWaypointDrag.ts new file mode 100644 index 000000000..e8ac9ac0f --- /dev/null +++ b/client/src/components/Map/plannerWaypointDrag.ts @@ -0,0 +1,124 @@ +export interface PlannerWaypointCoordinate { + lat: number; + lng: number; +} + +interface PlannerWaypointDragOptions { + element: HTMLElement; + initial: PlannerWaypointCoordinate; + coordinateAt: (clientX: number, clientY: number) => PlannerWaypointCoordinate; + setPosition: (coordinate: PlannerWaypointCoordinate) => void; + onCommit: (coordinate: PlannerWaypointCoordinate) => boolean | void; +} + +const DRAG_THRESHOLD_PX = 4; + +export function wirePlannerWaypointDrag({ + element, + initial, + coordinateAt, + setPosition, + onCommit, +}: PlannerWaypointDragOptions): () => void { + let pointerId: number | null = null; + let start: { x: number; y: number } | null = null; + let dragged = false; + let suppressClick = false; + const originalTouchAction = element.style.touchAction; + const originalUserSelect = element.style.userSelect; + + const stopMapGesture = (event: Event) => event.stopPropagation(); + const restore = () => setPosition(initial); + const cancel = (event?: Event) => { + if (pointerId === null) return; + event?.stopPropagation(); + restore(); + pointerId = null; + start = null; + dragged = false; + suppressClick = false; + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || pointerId !== null) { + event.stopPropagation(); + return; + } + event.stopPropagation(); + pointerId = event.pointerId; + start = { x: event.clientX, y: event.clientY }; + dragged = false; + suppressClick = false; + try { + element.setPointerCapture(event.pointerId); + } catch { + // Pointer capture is unavailable in some test DOMs and older browsers. + } + }; + + const onPointerMove = (event: PointerEvent) => { + if (event.pointerId !== pointerId || !start) return; + event.stopPropagation(); + if (!dragged && Math.hypot(event.clientX - start.x, event.clientY - start.y) < DRAG_THRESHOLD_PX) return; + dragged = true; + event.preventDefault(); + setPosition(coordinateAt(event.clientX, event.clientY)); + }; + + const onPointerUp = (event: PointerEvent) => { + if (event.pointerId !== pointerId || !start) return; + event.stopPropagation(); + const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (dragged || travelled >= DRAG_THRESHOLD_PX) { + const coordinate = coordinateAt(event.clientX, event.clientY); + setPosition(coordinate); + const accepted = onCommit(coordinate) !== false; + suppressClick = true; + if (!accepted) restore(); + } + pointerId = null; + start = null; + dragged = false; + }; + + const onClickCapture = (event: MouseEvent) => { + if (!suppressClick) return; + suppressClick = false; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + const onTouchCancel = (event: Event) => cancel(event); + + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointermove', onPointerMove); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointercancel', cancel); + element.addEventListener('lostpointercapture', cancel); + element.addEventListener('click', onClickCapture, true); + element.addEventListener('mousedown', stopMapGesture); + element.addEventListener('touchstart', stopMapGesture, { passive: true }); + element.addEventListener('touchmove', stopMapGesture, { passive: true }); + element.addEventListener('touchend', stopMapGesture, { passive: true }); + element.addEventListener('touchcancel', onTouchCancel, { passive: true }); + element.style.touchAction = 'none'; + element.style.userSelect = 'none'; + + return () => { + cancel(); + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointercancel', cancel); + element.removeEventListener('lostpointercapture', cancel); + element.removeEventListener('click', onClickCapture, true); + element.removeEventListener('mousedown', stopMapGesture); + element.removeEventListener('touchstart', stopMapGesture); + element.removeEventListener('touchmove', stopMapGesture); + element.removeEventListener('touchend', stopMapGesture); + element.removeEventListener('touchcancel', onTouchCancel); + element.style.touchAction = originalTouchAction; + element.style.userSelect = originalUserSelect; + }; +} diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 54e6bc0b0..c5d02b697 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1534,6 +1534,58 @@ describe('DayPlanSidebar', () => { expect(onDeletePlace).not.toHaveBeenCalled() }) + it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => { + const description = 'A long description of the ridge route, the lake overlook, and the return path.' + const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place }) + const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false } + const props = makeDefaultProps({ days: [day], places: [place], tourPlaceIds: new Set([place.id]), plannedTourDurations: new Map([[place.id, times]]), assignments: { '10': [assignment] } }) + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] }) + const view = render() + const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement + expect(tourPreview).toHaveTextContent(description) + expect(tourPreview.style.display).toBe('-webkit-box') + expect(tourPreview.style.webkitLineClamp).toBe('2') + expect(tourPreview.style.maxHeight).toBe('2.6em') + expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h') + expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min') + expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min') + + view.rerender() + expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override') + + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] }) + view.rerender() + expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull() + expect(view.container.querySelector('[data-testid="trip-plan-tour-planned-duration"]')).toBeNull() + expect(screen.getByText(description).closest('.collab-note-md')).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' }) + }) + + it('RC-01: uses facet type and compact facts only for Tours, preserving Tours-off Place presentation', () => { + const place = buildPlace({ id: 45, name: 'Ridge walk', description: 'Above the lake', tour_place_id: 45 }) + const day = buildDay({ id: 10, title: 'Day 1' }) + const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place }) + const tour = { + place_id: 45, name: place.name, tour_type: 'hike' as const, distance: 4, elevation_gain: 100, elevation_loss: 80, + duration: 53, break_additional_minutes: 10, planned_duration_minutes: null, difficulty: null, + wanderer_ref: null, match_confidence: 1, max_hiking_difficulty: 2, planned: true, caution: false, + } + const props = makeDefaultProps({ days: [day], places: [place], tourPlaceIds: new Set([45]), assignments: { '10': [assignment] }, + plannedTourDurations: new Map([[45, { tour, walkingMinutes: 53, breakMinutes: 10, plannedTotalMinutes: 63, manuallyOverridden: false }]]) }) + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] }) + const view = render() + expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy() + expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4') + expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100') + expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min') + expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min') + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] }) + view.rerender() + expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument() + expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument() + }) + it('TRIP-PLAN preserves legacy route-backed Place Delete when Tours is disabled', async () => { const user = userEvent.setup() useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 8c0e4b75f..cc031aa96 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -80,6 +80,9 @@ import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { DayAddControls } from '../../utils/dayAdd' import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' +import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation' +import type { TourListItem } from '@trek/shared' +import TourDayRowFacts, { TourDayRowIcon } from '../Tours/TourDayRowFacts' export interface DayPlanSidebarProps { tripId: number @@ -87,6 +90,7 @@ export interface DayPlanSidebarProps { days: Day[] places: Place[] tourPlaceIds?: ReadonlySet + plannedTourDurations?: ReadonlyMap categories: Category[] assignments: AssignmentsMap selectedDayId: number | null @@ -1823,6 +1827,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const assignment = item.data const place = assignment.place if (!place) return null + const isTourPlace = toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id) + const tourTimes = isTourPlace ? props.plannedTourDurations?.get(place.id) : undefined + const dayRowTour = tourTimes?.tour const cat = categories.find(c => c.id === place.category_id) const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId const isDraggingThis = draggingId === assignment.id @@ -1994,7 +2001,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onMouseLeave={() => setLockHoverId(null)} className="relative flex flex-none rounded-full" > - + {dayRowTour ? : } {/* Hover/locked overlay */} {(lockHoverId === assignment.id || isLocked) && (
- {cat && (() => { + {!dayRowTour && cat && (() => { const CatIcon = getCategoryIcon(cat.icon) return ( @@ -2027,15 +2034,37 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP )}
- {(place.place_time || place.description || place.address || cat?.name) && ( -
+ {(dayRowTour || place.place_time || (tourTimes && (tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null)) || place.description || place.address || cat?.name) && ( +
{place.place_time && ( {formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} )} + {dayRowTour && } + {!dayRowTour && tourTimes?.walkingMinutes != null && ( + + + {t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + + + )} + {!dayRowTour && tourTimes?.breakMinutes != null && ( + + + {t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} + + + )} + {!dayRowTour && tourTimes?.plannedTotalMinutes != null && ( + + + {t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} + + + )} {(place.description || place.address || cat?.name) && ( -
+
{place.description || place.address || cat?.name || ''}
)} diff --git a/client/src/components/Planner/LocationSelect.test.tsx b/client/src/components/Planner/LocationSelect.test.tsx index 5589815cd..aebf7e535 100644 --- a/client/src/components/Planner/LocationSelect.test.tsx +++ b/client/src/components/Planner/LocationSelect.test.tsx @@ -364,4 +364,43 @@ describe('LocationSelect', () => { expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument(); expect(screen.getByText('Gare de Lyon')).toBeInTheDocument(); }); + + it('marks the keyboard-highlighted result as active and selects it with Enter', async () => { + const user = userEvent.setup(); + server.use(searchRoute([GARE, LYON])); + const onPick = vi.fn(); + + render(); + const input = screen.getByRole('combobox', { name: 'Search map location' }); + await user.type(input, 'Gare'); + const firstOption = await screen.findByRole('option', { name: /Gare du Nord/ }); + await user.keyboard('{ArrowDown}'); + + expect(firstOption).toHaveAttribute('aria-selected', 'true'); + expect(input).toHaveAttribute('aria-activedescendant', firstOption.id); + await user.keyboard('{Enter}'); + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Gare du Nord' })); + }); + + it('shows a localized no-result status after an empty successful search', async () => { + const user = userEvent.setup(); + server.use(searchRoute([])); + + render(); + await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Nowhere'); + + expect(await screen.findByText(/Nothing found for/)).toBeInTheDocument(); + }); + + it('shows a non-mutating error status when the provider request fails', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/maps/search', () => HttpResponse.json({ error: 'unavailable' }, { status: 503 }))); + const onChange = vi.fn(); + + render(); + await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Kyoto'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Error'); + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 8b8106ef9..6dd5fd717 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { MapPin } from 'lucide-react' import { mapsApi } from '../../api/client' import { useTranslation } from '../../i18n' @@ -18,6 +18,9 @@ interface Props { value: LocationPoint | null onChange: (loc: LocationPoint | null) => void placeholder?: string + ariaLabel?: string + /** Announces the field as a combobox and says "nothing found" or an error in its list. */ + showSearchStatus?: boolean style?: React.CSSProperties /** * The trip's own places, offered while the field is empty or holds fewer than three @@ -27,7 +30,7 @@ interface Props { places?: LocationPoint[] } -export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) { +export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, style, places }: Props) { const { t, locale } = useTranslation() const placeLang = usePlaceLanguage() // Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher. @@ -36,6 +39,10 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown() const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) + const [searchError, setSearchError] = useState(false) + // The text the last answer was for, so a status never describes older text. + const [searchedQuery, setSearchedQuery] = useState(null) + const listId = `${useId()}-location-results` const debounceRef = useRef | null>(null) useEffect(() => { @@ -47,6 +54,9 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl const trimmed = query.trim() if (trimmed.length < 3 || (value && trimmed === value.name)) { setResults([]) + setLoading(false) + setSearchError(false) + setSearchedQuery(null) return } // Clearing the timer does nothing to a request already out: an answer for @@ -54,13 +64,20 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl let stale = false debounceRef.current = setTimeout(async () => { setLoading(true) + setSearchError(false) + setSearchedQuery(null) try { const data = await mapsApi.search(trimmed, placeLang, locationBias) if (stale) return setResults(data.places || []) setHighlight(-1) + setSearchedQuery(trimmed) } catch { - if (!stale) setResults([]) + if (!stale) { + setResults([]) + setSearchError(true) + setSearchedQuery(trimmed) + } } finally { if (!stale) setLoading(false) } @@ -79,13 +96,21 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl onChange(loc) setQuery(loc.name) setOpen(false) - setResults([]) + resetSearch() } const clear = () => { onChange(null) setQuery('') + resetSearch() + } + + function resetSearch() { + if (debounceRef.current) clearTimeout(debounceRef.current) setResults([]) + setLoading(false) + setSearchError(false) + setSearchedQuery(null) } // The trip's places stand in for the search below three characters. Derived rather @@ -96,6 +121,30 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl const rows = showPicks ? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) : results + const showNoResults = + showSearchStatus && !showPicks && searchedQuery === typed && rows.length === 0 && !loading && !searchError + const showError = showSearchStatus && searchError && searchedQuery === typed + const listOpen = open && ((loading && !showPicks) || rows.length > 0 || showNoResults || showError) + const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation') + const statusLine = showError ? ( +
+ {t('common.error')} +
+ ) : showNoResults ? ( +
+ {t('help.center.searchEmpty', { query: typed })} +
+ ) : null + + const onKeyDown = (e: React.KeyboardEvent) => { + // Escape closes the list even while it only shows a status line. + if (e.key === 'Escape' && open) { + e.preventDefault() + setOpen(false) + return + } + handleKey(e, rows, pick) + } return (
@@ -103,14 +152,27 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl icon={} query={query} placeholder={placeholder ?? t('reservations.searchLocation')} - onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); if (value) onChange(null) }} + onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); setSearchError(false); setSearchedQuery(null); if (value) onChange(null) }} onOpen={() => setOpen(true)} - onKeyDown={(e) => handleKey(e, rows, pick)} + onKeyDown={onKeyDown} onClear={value ? clear : undefined} + inputAria={showSearchStatus ? { + role: 'combobox', + 'aria-label': accessibleLabel, + 'aria-autocomplete': 'list', + 'aria-expanded': listOpen, + 'aria-controls': listOpen ? listId : undefined, + 'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined, + } : undefined} /> {open && ( - + {rows.map((r, i) => ( pick(r)} onHover={() => setHighlight(i)} align="flex-start" + optionId={showSearchStatus ? `${listId}-option-${i}` : undefined} > diff --git a/client/src/components/Planner/SuggestionDropdown.tsx b/client/src/components/Planner/SuggestionDropdown.tsx index 2ea7a9499..f5005007e 100644 --- a/client/src/components/Planner/SuggestionDropdown.tsx +++ b/client/src/components/Planner/SuggestionDropdown.tsx @@ -15,6 +15,8 @@ interface SuggestionInputBoxProps { onKeyDown: (e: React.KeyboardEvent) => void; /** Shown as the clear button while a value is picked. */ onClear?: () => void; + /** Combobox wiring for a field that announces its list (role, aria-expanded, ...). */ + inputAria?: React.AriaAttributes & { role?: React.AriaRole }; } export function SuggestionInputBox({ @@ -25,6 +27,7 @@ export function SuggestionInputBox({ onOpen, onKeyDown, onClear, + inputAria, }: SuggestionInputBoxProps) { return (
onType(e.target.value)} @@ -76,13 +80,17 @@ interface SuggestionListProps { /** Shows the loading line while there are no rows yet. */ loading: boolean; rowCount: number; + /** A line above the rows, such as "nothing found"; keeps the list open without rows. */ + status?: React.ReactNode; + /** Wraps the rows in a listbox when the field is a combobox. */ + listbox?: { id: string; label: string }; children: React.ReactNode; } -/** The floating list under the field. Rendered only while it is loading or has rows; the caller checks `open`. */ -export function SuggestionList({ loading, rowCount, children }: SuggestionListProps) { +/** The floating list under the field. Rendered only while it is loading, has rows or a status; the caller checks `open`. */ +export function SuggestionList({ loading, rowCount, status, listbox, children }: SuggestionListProps) { const { t } = useTranslation(); - if (!loading && rowCount === 0) return null; + if (!loading && rowCount === 0 && !status) return null; return (
{loading && rowCount === 0 && ( -
+
{t('common.loading')}
)} - {children} + {status} + {listbox ? ( +
+ {children} +
+ ) : ( + children + )}
); } @@ -114,13 +133,18 @@ interface SuggestionRowProps { onHover: () => void; /** How the row lines up its icon and text. */ align: 'center' | 'flex-start'; + /** Set inside a listbox: the row becomes an option the input can point at. */ + optionId?: string; children: React.ReactNode; } -export function SuggestionRow({ active, onPick, onHover, align, children }: SuggestionRowProps) { +export function SuggestionRow({ active, onPick, onHover, align, optionId, children }: SuggestionRowProps) { return ( + + + + ); +} + +export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit); + const times = tourPlannedTimes(tour); + const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = []; + const add = (icon: LucideIcon, value: string, label: string, testId: string) => + facts.push({ icon, value, label, testId }); + if (tour.distance != null) + add(Ruler, formatDistance(tour.distance, unit), t('tours.detail.distance'), 'plan-tour-distance'); + if (tour.elevation_gain != null) + add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent'); + if (times.walkingMinutes != null) + add( + Footprints, + formatPlannedTourDuration(times.walkingMinutes), + t('tours.planner.inspector.duration'), + 'trip-plan-tour-walking-time' + ); + if (times.breakMinutes != null && times.breakMinutes > 0) + add( + Pause, + formatPlannedTourDuration(times.breakMinutes), + t('tours.planner.breaksAdditional'), + 'trip-plan-tour-breaks' + ); + if (times.plannedTotalMinutes != null) + add( + Clock, + formatPlannedTourDuration(times.plannedTotalMinutes), + `${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`, + 'trip-plan-tour-planned-duration' + ); + return ( + + {facts.map((fact) => ( + + ))} + + ); +} diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index c41025c53..7a65ff216 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -104,6 +104,43 @@ describe('TourDetailDialog', () => { expect(screen.queryByText('Participants')).not.toBeInTheDocument() }) + it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + render( + + ) + + expect(screen.getByText('Ridge above the lake')).toBeInTheDocument() + const link = screen.getByRole('link', { name: 'Komoot · komoot.com' }) + expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42') + expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow') + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('RS-02: labels calculated walking time and planned total as separate values', () => { + render() + + expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument() + expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument() + expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument() + }) + + it('RS-01: rejects an unsafe legacy Tour website for rendering', () => { + render( + + ) + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + it('uses existing assignment callbacks for add and remove actions', () => { const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: /Add to Day/i })) diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 55143b59c..59e9f55a1 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Check, ChevronDown, ChevronUp, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react' +import { AlertTriangle, Check, ChevronDown, ChevronUp, ExternalLink, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react' import Markdown from 'react-markdown' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' @@ -22,7 +22,7 @@ import { analyzeRouteGeometry } from '../../utils/routeGeometry' import { filesForPlace } from '../../utils/placeFiles' import { openFile } from '../../utils/fileDownload' import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation' -import { hikeSourceBadgeLabel, tourSource } from './tourPresentation' +import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' import { TourMetricFields, TourNotice, TourTile } from './tourParts' import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' @@ -137,6 +137,9 @@ export default function TourDetailDialog({ const navigationTargets = getNavigationTargets(place) const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation') const source = tourSource(tour) + const tourTimes = tourPlannedTimes(tour) + const description = tour.description !== undefined ? tour.description : place.description + const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website) const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto') const saveName = async () => { @@ -217,7 +220,27 @@ export default function TourDetailDialog({
}>{t(`tourTypes.${tour.tour_type}`)} {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} - {tour.duration != null && {t('tours.durationMinutes', { count: tour.duration })}} + {tourTimes.walkingMinutes != null && ( + + + {t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + + + )} + {tourTimes.breakMinutes != null && ( + + + {t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} + + + )} + {tourTimes.plannedTotalMinutes != null && ( + + + {t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} + + + )} {hikeSourceBadgeLabel(tour, t)} {tour.caution && ( @@ -313,7 +336,15 @@ export default function TourDetailDialog({ )} - {place.description && {place.description}} + {description && {description}} + {website && ( + + + + {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`} + + + )} {place.notes && {place.notes}} {assignment?.notes && {assignment.notes}} diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index 08c2d6376..30c072175 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -1,7 +1,9 @@ import { useId, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' import { fs } from '../shared/DialogShell' +import { useTranslation } from '../../i18n' import { TourFactPills, TourTile } from './tourParts' +import { tourWebsitePresentation } from './tourPresentation' interface TourListRowProps { tour: TourListItem @@ -14,8 +16,10 @@ interface TourListRowProps { /** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */ export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) { + const { t } = useTranslation() const nameId = useId() const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover' + const website = tourWebsitePresentation(tour.website) return (
  • {tour.name} + {tour.description && ( +
    + {tour.description} +
    + )} + {website && ( +
    + {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`} +
    + )}
  • {action && (
    event.stopPropagation()}> diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index e55607fd5..abe1454ec 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -56,6 +56,16 @@ describe('ToursSidebar', () => { expect(row).toHaveClass('bg-surface-selected') }) + it('RS-01: previews the Tour description and classified information-link host', () => { + const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 } + render() + const row = screen.getByRole('option', { name: /Selected ridge walk/i }) + expect(row).toHaveTextContent('A quiet path above the lake') + expect(row).toHaveTextContent('Komoot · komoot.com') + expect(row).toHaveTextContent('1 h 35 min') + expect(row.querySelector('[data-testid="tour-description-preview"]')).toHaveStyle({ WebkitLineClamp: '2' }) + }) + it('selects the tour from keyboard activation', () => { const onSelectTour = vi.fn() render() diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 8cab05d07..01a388897 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -1,5 +1,5 @@ import { act, useState } from 'react' -import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render' +import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render'; import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TourListItem } from '@trek/shared' @@ -48,6 +48,20 @@ function planner(overrides: Partial = {}): TourPlannerCon maxHikingDifficulty: 2, setMaxHikingDifficulty: vi.fn(), name: '', + description: '', + setDescription: vi.fn(), + website: '', + setWebsite: vi.fn(), + websiteInvalid: false, + plannedDurationMinutes: null, + setPlannedDurationMinutes: vi.fn(), + plannedDurationInvalid: false, + breakAdditionalMinutes: null, + setBreakAdditionalMinutes: vi.fn(), + breakAdditionalInvalid: false, + walkingDurationMinutes: null, + plannedTotalMinutes: null, + plannedTotalInvalid: false, startNewTour: vi.fn(), newTourConfirmationOpen: false, cancelNewTour: vi.fn(), @@ -65,6 +79,9 @@ function planner(overrides: Partial = {}): TourPlannerCon editingPlaceId: null, openingTourId: null, openTour: vi.fn().mockResolvedValue(true), + reorderWaypoint: vi.fn(), + moveWaypoint: vi.fn(), + removeWaypoint: vi.fn(), saveOutcome: null, hasUnsavedChanges: false, discard: vi.fn(), @@ -92,6 +109,16 @@ beforeEach(() => { }) describe('TourPlannerToursRail', () => { + it('RS-02: keeps calculated Walking time visible in the right Tour metrics rail', () => { + render() + + expect(screen.getByText('Walking time')).toBeInTheDocument() + expect(screen.getByText('1 h 20 min')).toBeInTheDocument() + }) + it('disables saved-tour switching and Plan another while a Save is pending', () => { const controller = planner({ isSaving: true, @@ -289,6 +316,227 @@ describe('TourPlannerToursRail', () => { }) describe('TourPlannerRail', () => { + it('RS-01: edits Tour description and HTTPS information link without exposing editable fields in GPX mode', () => { + const setDescription = vi.fn() + const setWebsite = vi.fn() + const { rerender } = render( + + ) + const description = screen.getByLabelText('Description') + const website = screen.getByLabelText('Website') + fireEvent.change(description, { target: { value: 'A quiet ridge route' } }) + fireEvent.change(website, { target: { value: 'https://www.komoot.com/tour/42' } }) + expect(setDescription).toHaveBeenCalledWith('A quiet ridge route') + expect(setWebsite).toHaveBeenCalledWith('https://www.komoot.com/tour/42') + + rerender( + + ) + expect(screen.queryByLabelText('Description')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Website')).not.toBeInTheDocument() + }) + + it('RS-02: edits breaks and planned total directly, and clearing total returns it to automatic', () => { + const setPlannedDurationMinutes = vi.fn() + const setBreakAdditionalMinutes = vi.fn() + const controller = planner({ + mode: { type: 'new-draft' }, hasUnsavedChanges: true, durationSeconds: 3600, + walkingDurationMinutes: 60, breakAdditionalMinutes: 35, setBreakAdditionalMinutes, + plannedDurationMinutes: null, plannedTotalMinutes: 95, setPlannedDurationMinutes, + }) + const { rerender } = render() + + expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument() + expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid') + expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))]') + const breaks = screen.getByLabelText('Breaks / additional time') + expect(breaks).toHaveValue(35) + fireEvent.change(breaks, { target: { value: '50' } }) + expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50) + fireEvent.change(breaks, { target: { value: '' } }) + expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null) + fireEvent.change(breaks, { target: { value: '50' } }) + const input = screen.getByLabelText('Planned total duration') + expect(input).toHaveValue(95) + expect(input).toHaveStyle({ width: '6rem' }) + expect(screen.getByText('Calculated total')).toBeInTheDocument() + expect(controller.durationSeconds).toBe(3600) + fireEvent.change(input, { target: { value: '125' } }) + expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125) + + rerender() + const overriddenInput = screen.getByLabelText(/Planned total duration/) + expect(overriddenInput).toHaveValue(125) + expect(document.querySelector('label[for="tour-planner-planned-duration"]')).toHaveTextContent('Manual override') + fireEvent.change(overriddenInput, { target: { value: '' } }) + expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null) + + rerender() + expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.') + + rerender() + expect(screen.getByLabelText('Breaks / additional time')).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.') + }) + + it('RS-02: Info controls are keyboard focusable, use shared tooltips, and expose touch disclosures', async () => { + render() + + const breaksInfo = screen.getByRole('button', { name: 'About breaks and additional time' }) + expect(breaksInfo.tabIndex).toBe(0) + expect(breaksInfo).toHaveAttribute('aria-expanded', 'false') + expect(breaksInfo).toHaveAttribute('aria-controls') + expect(breaksInfo).toHaveClass('focus-visible:outline') + const matches = vi.spyOn(breaksInfo, 'matches').mockImplementation(selector => selector === ':focus-visible') + fireEvent.focus(breaksInfo) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Optional whole minutes added to calculated walking time.') + fireEvent.blur(breaksInfo) + matches.mockRestore() + fireEvent.click(breaksInfo) + expect(breaksInfo).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('note')).toHaveTextContent('Optional whole minutes added to calculated walking time.') + + const totalInfo = screen.getByRole('button', { name: 'About planned total duration' }) + expect(totalInfo).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(totalInfo) + expect(screen.getByText('Calculated planned total duration.')).toBeInTheDocument() + }) + + it('RS-01: marks unsafe website values invalid and does not offer Save', () => { + render() + expect(screen.getByLabelText('Website')).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByRole('alert')).toHaveTextContent('TREK cannot use that address.') + }) + + const reorderWaypoints = [ + { id: 'a', lat: 48, lng: 11, role: 'start' as const }, + { id: 'b', lat: 48.1, lng: 11.1, role: 'via' as const }, + { id: 'c', lat: 48.2, lng: 11.2, role: 'via' as const }, + { id: 'd', lat: 48.3, lng: 11.3, role: 'end' as const }, + ]; + + it('RC-07: dragging before and after rows commits only on drop by stable waypoint ids', () => { + const reorderWaypoint = vi.fn(); + render( + + ); + const rows = screen.getAllByTestId('tour-planner-waypoint-row'); + const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' }; + const dispatchDrag = (type: string, target: HTMLElement, dataTransfer: typeof transfer, clientY = 0) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(event, 'clientY', { value: clientY }); + fireEvent(target, event); + }; + for (const row of [rows[1], rows[2]]) { + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + top: 0, + bottom: 20, + height: 20, + left: 0, + right: 100, + width: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }); + } + + dispatchDrag('dragstart', within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), transfer); + dispatchDrag('dragover', rows[2], transfer, 1); + expect(reorderWaypoint).not.toHaveBeenCalled(); + dispatchDrag('drop', rows[2], transfer, 1); + expect(reorderWaypoint).toHaveBeenCalledOnce(); + expect(reorderWaypoint).toHaveBeenLastCalledWith('a', 'c', 'before'); + + reorderWaypoint.mockClear(); + const lastTransfer = { ...transfer, getData: vi.fn(() => 'd') }; + dispatchDrag('dragstart', within(rows[3]).getByTestId('tour-planner-waypoint-drag-handle'), lastTransfer); + dispatchDrag('dragover', rows[1], lastTransfer, 15); + expect(reorderWaypoint).not.toHaveBeenCalled(); + dispatchDrag('drop', rows[1], lastTransfer, 15); + expect(reorderWaypoint).toHaveBeenCalledOnce(); + expect(reorderWaypoint).toHaveBeenLastCalledWith('d', 'b', 'after'); + }); + + it('RC-07: cancelled drags do not reorder and keyboard arrow controls remain buttons', async () => { + const user = userEvent.setup(); + const reorderWaypoint = vi.fn(); + const moveWaypoint = vi.fn(); + render( + + ); + const rows = screen.getAllByTestId('tour-planner-waypoint-row'); + const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' }; + Object.defineProperty(rows[2], 'getBoundingClientRect', { + configurable: true, + value: () => ({ + top: 0, + bottom: 20, + height: 20, + left: 0, + right: 100, + width: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.dragStart(within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), { dataTransfer: transfer }); + fireEvent.dragOver(rows[2], { dataTransfer: transfer, clientY: -1 }); + fireEvent.dragEnd(rows[0], { dataTransfer: transfer }); + expect(reorderWaypoint).not.toHaveBeenCalled(); + + const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0]; + expect(moveDown).toBeEnabled(); + moveDown.focus(); + await user.keyboard(' '); + expect(moveWaypoint).toHaveBeenCalledWith('a', 1); + expect(reorderWaypoint).not.toHaveBeenCalled(); + }); + it('disables draft mutation and replacement controls while saving', () => { const controller = planner({ mode: { type: 'new-draft' }, diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 5f4b72458..e5215d603 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -1,6 +1,6 @@ -import { useRef, useState, type ReactNode } from 'react' +import { useId, useRef, useState, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react' +import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, GripVertical, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react' import type { Day } from '../../../types' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' @@ -16,7 +16,7 @@ import { BOX } from '../../Planner/bookings/bookingParts' import { BarButton, SoftPill } from '../../Planner/planParts' import TourListRow from '../TourListRow' import ElevationProfile from '../../shared/ElevationProfile' -import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation' +import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation' import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts' import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner' @@ -62,12 +62,38 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid ) } +function DurationInfo({ description, label }: { description: string; label: string }) { + const id = useId() + const [open, setOpen] = useState(false) + return ( + + + + + + + ) +} + export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) { const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) const { t } = useTranslation() const distanceUnit = useSettingsStore(state => state.settings.distance_unit) const [confirmDiscard, setConfirmDiscard] = useState(false) const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null) + const [draggedWaypointId, setDraggedWaypointId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null); const alpineAcknowledged = useRef(false) const isSaving = planner.isSaving const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status] @@ -179,6 +205,111 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a className={INPUT} />
    +
    + +