feat(tours): complete planner follow-up scope

This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
This commit is contained in:
elmocito
2026-10-09 13:52:11 +02:00
committed by Maurice
parent af9f013c5e
commit 57ea219a43
56 changed files with 4315 additions and 462 deletions
@@ -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(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
/>
)
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(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
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(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[{ ...waypoint, lat: expected.lat, lng: expected.lng }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[expected.lat, expected.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
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(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
/>
)
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 }
@@ -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(
<MapView
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
const mapContainer = screen.getByTestId('map-container');
mapMock.fitBounds.mockClear();
rerender(
<MapView
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
/>
);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
expect(screen.getByTestId('map-container')).toBe(mapContainer);
rerender(
<MapView
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
/>
);
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(
+68 -17
View File
@@ -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: `<span style="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">${index + 1}</span>`,
html: `<span style="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:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
})
}
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<L.Marker | null>(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 (
<Marker
ref={setMarker}
position={position}
icon={icon}
zIndexOffset={selected ? 1200 : 1100}
eventHandlers={eventHandlers}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
)
}
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) => (
<Marker
<PlannerWaypointMarker
key={`planner-waypoint-${point.id}`}
position={[point.lat, point.lng]}
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
eventHandlers={{
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
onPlannerWaypointClick?.(point.id)
},
}}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
point={point}
index={index}
selected={point.id === selectedPlannerWaypointId}
onClick={onPlannerWaypointClick}
onMove={onPlannerWaypointMove}
/>
))}
{/* GPX imported route geometries */}
@@ -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<string, any> = {}) {
} 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(
<MapViewGL
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
glProvider="maplibre-gl"
/>
);
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(
<MapViewGL
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).not.toHaveBeenCalled();
expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions);
rerender(
<MapViewGL
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
glProvider="maplibre-gl"
/>
);
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(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
glProvider={provider}
/>
)
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(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
)
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(
<MapViewGL
places={[]}
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[48.4, 11.3]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
)
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(
<MapViewGL
places={[]}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
glProvider="maplibre-gl"
/>
)
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 })
+25 -3
View File
@@ -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<PlacePin[]>([])
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
const plannerWaypointsDraggable = !!onPlannerWaypointMove
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
@@ -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()
+3 -1
View File
@@ -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.
*
@@ -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');
});
});
@@ -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;
};
}
@@ -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(<DayPlanSidebar {...props} />)
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(<DayPlanSidebar {...{ ...props, plannedTourDurations: new Map([[place.id, { ...times, plannedTotalMinutes: 125, manuallyOverridden: true }]]) }} />)
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(<DayPlanSidebar {...props} />)
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(<DayPlanSidebar {...props} />)
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(<DayPlanSidebar {...props} />)
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 }] })
@@ -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<number>
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
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"
>
<PlaceAvatar place={place} category={cat} size={32} />
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || isLocked) && (
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
@@ -2006,7 +2013,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex min-w-0 items-center gap-1.5">
{cat && (() => {
{!dayRowTour && cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return (
<Tooltip label={cat.name}>
@@ -2027,15 +2034,37 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
{(place.place_time || place.description || place.address || cat?.name) && (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{(dayRowTour || place.place_time || (tourTimes && (tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null)) || place.description || place.address || cat?.name) && (
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour && <TourDayRowFacts tour={dayRowTour} />}
{!dayRowTour && tourTimes?.walkingMinutes != null && (
<TimePill>
<span data-testid="trip-plan-tour-walking-time" aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{!dayRowTour && tourTimes?.breakMinutes != null && (
<TimePill>
<span data-testid="trip-plan-tour-breaks" aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
{t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{!dayRowTour && tourTimes?.plannedTotalMinutes != null && (
<TimePill>
<span data-testid="trip-plan-tour-planned-duration" aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
{(place.description || place.address || cat?.name) && (
<div className="collab-note-md min-w-0 flex-1 text-content-faint" style={{ ...fs(10.5), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3, maxHeight: '1.3em' }}>
<div data-testid={isTourPlace ? 'trip-plan-tour-description' : undefined} className="collab-note-md min-w-0 flex-1 text-content-faint" style={{ ...fs(10.5), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: isTourPlace ? 'normal' : 'nowrap', lineHeight: 1.3, maxHeight: isTourPlace ? '2.6em' : '1.3em', ...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}) }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
@@ -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(<LocationSelect value={null} onChange={onPick} ariaLabel="Search map location" showSearchStatus />);
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(<LocationSelect value={null} onChange={vi.fn()} ariaLabel="Search map location" showSearchStatus />);
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(<LocationSelect value={null} onChange={onChange} ariaLabel="Search map location" showSearchStatus />);
await user.type(screen.getByRole('combobox', { name: 'Search map location' }), 'Kyoto');
expect(await screen.findByRole('alert')).toHaveTextContent('Error');
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -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<any[]>([])
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<string | null>(null)
const listId = `${useId()}-location-results`
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('common.error')}
</div>
) : showNoResults ? (
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('help.center.searchEmpty', { query: typed })}
</div>
) : null
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 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 (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
@@ -103,14 +152,27 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
icon={<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
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 && (
<SuggestionList loading={loading && !showPicks} rowCount={rows.length}>
<SuggestionList
loading={loading && !showPicks}
rowCount={rows.length}
status={statusLine}
listbox={showSearchStatus ? { id: listId, label: accessibleLabel } : undefined}
>
{rows.map((r, i) => (
<SuggestionRow
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
@@ -118,6 +180,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
onPick={() => pick(r)}
onHover={() => setHighlight(i)}
align="flex-start"
optionId={showSearchStatus ? `${listId}-option-${i}` : undefined}
>
<PlaceSuggestion name={r.name} address={r.address} />
</SuggestionRow>
@@ -15,6 +15,8 @@ interface SuggestionInputBoxProps {
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => 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 (
<div
@@ -41,6 +44,7 @@ export function SuggestionInputBox({
{icon}
<input
type="text"
{...inputAria}
value={query}
placeholder={placeholder}
onChange={(e) => 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 (
<div
className="bg-surface-card"
@@ -99,11 +107,22 @@ export function SuggestionList({ loading, rowCount, children }: SuggestionListPr
}}
>
{loading && rowCount === 0 && (
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
<div
role={listbox ? 'status' : undefined}
className="text-content-faint"
style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
>
{t('common.loading')}
</div>
)}
{children}
{status}
{listbox ? (
<div id={listbox.id} role="listbox" aria-label={listbox.label}>
{children}
</div>
) : (
children
)}
</div>
);
}
@@ -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 (
<button
type="button"
id={optionId}
role={optionId ? 'option' : undefined}
aria-selected={optionId ? active : undefined}
onClick={onPick}
onMouseEnter={onHover}
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
@@ -0,0 +1,75 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import TourDayRowFacts, { TourDayRowIcon, tourDayRowIcon } from './TourDayRowFacts';
const tour: TourListItem = {
place_id: 42,
name: 'Ridge',
tour_type: 'hike',
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,
};
describe('RC-01 Tour day-row facts', () => {
it('selects controlled type icons without enabling any types', () => {
expect(tourDayRowIcon('hike')).toBe(Mountain);
expect(tourDayRowIcon('bike')).toBe(Bike);
expect(tourDayRowIcon('walk')).toBe(Footprints);
expect(tourDayRowIcon('unknown')).toBe(Route);
render(<TourDayRowIcon type="hike" />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
});
it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
render(<TourDayRowFacts tour={tour} mobile={mobile} />);
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-breaks')).toHaveAccessibleName('Breaks / additional time: 10 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
expect(screen.queryByText('80 m')).not.toBeInTheDocument();
});
it.each([null, 0])('omits missing metrics and empty breaks (%s)', (breaks) => {
render(
<TourDayRowFacts tour={{ ...tour, distance: null, elevation_gain: null, break_additional_minutes: breaks }} />
);
expect(screen.queryByTestId('plan-tour-distance')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-ascent')).not.toBeInTheDocument();
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('53 min');
});
it('supports hover, keyboard focus and touch help without opening the containing row', async () => {
const openRow = vi.fn();
render(
<div onClick={openRow}>
<TourDayRowFacts tour={tour} />
</div>
);
const walking = screen.getByTestId('trip-plan-tour-walking-time');
fireEvent.mouseEnter(walking);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
fireEvent.mouseLeave(walking);
const matches = vi.spyOn(walking, 'matches').mockImplementation((selector) => selector === ':focus-visible');
fireEvent.focus(walking);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
fireEvent.blur(walking);
matches.mockRestore();
fireEvent.click(walking);
expect(screen.getByRole('note')).toHaveTextContent('Walking time: 53 min');
expect(openRow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,141 @@
import type { TourListItem } from '@trek/shared';
import {
ArrowUp,
Bike,
Clock,
Footprints,
Mountain,
Pause,
Route,
Ruler,
Sailboat,
type LucideIcon,
} from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { Tooltip } from '../shared/Tooltip';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
export function tourDayRowIcon(type: string): LucideIcon {
switch (type) {
case 'hike':
return Mountain;
case 'bike':
return Bike;
case 'walk':
return Footprints;
case 'kayak':
return Sailboat;
default:
return Route;
}
}
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
const Icon = tourDayRowIcon(type);
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
return (
<span
data-testid="plan-tour-icon"
aria-label={known ? t(`tourTypes.${type}`) : t('tours.mode.tours')}
className="grid flex-none place-items-center rounded-full"
style={{ width: size, height: size }}
>
<Icon size={Math.round(size * 0.6)} aria-hidden="true" />
</span>
);
}
function Fact({
icon: Icon,
value,
label,
testId,
mobile,
}: {
icon: LucideIcon;
value: string;
label: string;
testId: string;
mobile: boolean;
}) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<span className="relative inline-flex max-w-full">
<Tooltip label={label}>
<button
type="button"
data-testid={testId}
aria-label={label}
aria-expanded={open}
aria-controls={id}
onClick={(event) => {
event.stopPropagation();
setOpen((current) => !current);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') setOpen(false);
}}
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-[6px] px-1.5 py-0.5 text-[0.65625rem] tabular-nums focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${mobile ? 'bg-[color:var(--m-ic)] text-m-muted' : 'bg-surface-card text-content-secondary'}`}
>
<Icon size={11} aria-hidden="true" />
{value}
</button>
</Tooltip>
<span
id={id}
hidden={!open}
role="note"
className={`absolute start-0 top-full z-30 mt-1 w-40 whitespace-normal rounded-[6px] border p-2 text-[0.6875rem] shadow-sm ${mobile ? 'bg-[color:var(--m-ic)] text-m-ink' : 'border-edge bg-surface-card text-content'}`}
>
{label}
</span>
</span>
);
}
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 (
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
{facts.map((fact) => (
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
))}
</span>
);
}
@@ -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(
<TourDetailDialog
{...defaultProps}
tour={{ ...tour, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
place={{ ...place, description: 'Ridge above the lake', website: 'https://www.komoot.com/tour/42' }}
/>
)
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(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
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(
<TourDetailDialog
{...defaultProps}
tour={{ ...tour, website: 'javascript:alert(1)' }}
place={{ ...place, website: 'javascript:alert(1)' }}
/>
)
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
@@ -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({
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>{t(`tourTypes.${tour.tour_type}`)}</SoftPill>
{tour.difficulty && <SoftPill>{t('tours.detail.difficulty')}: {tour.difficulty}</SoftPill>}
{tour.duration != null && <TimePill>{t('tours.durationMinutes', { count: tour.duration })}</TimePill>}
{tourTimes.walkingMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{tourTimes.breakMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
@@ -313,7 +336,15 @@ export default function TourDetailDialog({
)}
</DialogSection>
{place.description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{place.description}</MarkdownBox></DialogSection>}
{description && <DialogSection className="flex-none" label={t('places.formDescription')}><MarkdownBox>{description}</MarkdownBox></DialogSection>}
{website && (
<DialogSection className="flex-none" label={t('places.formWebsite')}>
<a href={website.href} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1.5 text-content hover:underline">
<ExternalLink size={13} className="flex-none" />
<span className="truncate">{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}</span>
</a>
</DialogSection>
)}
{place.notes && <DialogSection className="flex-none" label={t('places.formNotes')}><MarkdownBox>{place.notes}</MarkdownBox></DialogSection>}
{assignment?.notes && <DialogSection className="flex-none" label={t('places.assignmentNotes')}><MarkdownBox>{assignment.notes}</MarkdownBox></DialogSection>}
@@ -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 (
<li
role="option"
@@ -38,6 +42,16 @@ export default function TourListRow({ tour, selected = false, disabled = false,
<div className="min-w-0 flex-1 pt-px">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
<TourFactPills tour={tour} className="mt-1.5" />
{tour.description && (
<div data-testid="tour-description-preview" className="mt-1 overflow-hidden text-ellipsis text-content-faint" style={{ ...fs(11), lineHeight: 1.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</div>
)}
{website && (
<div className="mt-1 truncate text-content-faint" style={fs(10.5)}>
{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}
</div>
)}
</div>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5 pt-1" onClick={event => event.stopPropagation()}>
@@ -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(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
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(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={null} onAssignToDay={vi.fn()} onSelectTour={onSelectTour} />)
@@ -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<TourPlannerController> = {}): 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<TourPlannerController> = {}): 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(<TourPlannerToursRail planner={planner({
mode: { type: 'new-draft' }, durationSeconds: 4800,
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
})} tours={[tour]} {...railProps} />)
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(
<TourPlannerRail
planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, setDescription, setWebsite })}
/>
)
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(
<TourPlannerRail
planner={planner({
mode: { type: 'view-gpx', placeId: 42, tour },
readOnlyGpxTour: { tour, routeGeometry: null },
waypoints: [],
websiteInvalid: true,
})}
/>
)
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(<TourPlannerRail planner={controller} />)
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(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 125,
setPlannedDurationMinutes,
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
})} />)
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(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, plannedDurationMinutes: 1441,
plannedDurationInvalid: true,
})} />)
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(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, breakAdditionalMinutes: 1441,
breakAdditionalInvalid: true,
})} />)
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(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
})} />)
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(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true, website: 'http://example.org', websiteInvalid: true })} />)
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(
<TourPlannerRail
planner={planner({
mode: { type: 'new-draft' },
hasUnsavedChanges: true,
waypoints: reorderWaypoints,
reorderWaypoint,
})}
/>
);
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(
<TourPlannerRail
planner={planner({
mode: { type: 'new-draft' },
hasUnsavedChanges: true,
waypoints: reorderWaypoints,
reorderWaypoint,
moveWaypoint,
})}
/>
);
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' },
@@ -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 (
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5">
<Tooltip label={description}>
<button
type="button"
aria-label={label}
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(value => !value)}
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Info size={13} strokeWidth={2} aria-hidden="true" />
</button>
</Tooltip>
<span id={id} hidden={!open} role="note" className="basis-full text-content-muted" style={fs(10.5, 'body')}>
{description}
</span>
</span>
)
}
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<string | null>(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}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
<textarea
id="tour-planner-description"
disabled={!canEdit || isSaving}
value={planner.description}
onChange={event => { if (canEdit && !isSaving) planner.setDescription(event.target.value) }}
maxLength={2000}
rows={3}
className={`${INPUT} min-h-[72px] resize-y`}
/>
</div>
<TourSection label={t('tours.planner.plannedTotalDuration')}>
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
<DurationInfo
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
</div>
<input
id="tour-planner-breaks"
aria-label={t('tours.planner.breaksAdditional')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={!canEdit || isSaving}
value={planner.breakAdditionalMinutes ?? ''}
onChange={event => { if (canEdit && !isSaving) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.breakAdditionalInvalid || undefined}
aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
className={INPUT}
/>
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
{t('tours.planner.breaksAdditionalHint')}
</span>
{planner.breakAdditionalInvalid && (
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-planned-duration">
{t('tours.planner.plannedTotalDuration')}
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
</label>
<DurationInfo
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
</div>
<div className="flex min-w-0 items-center gap-2">
<input
id="tour-planner-planned-duration"
aria-label={t('tours.planner.plannedTotalDuration')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={!canEdit || isSaving}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
onChange={event => { if (canEdit && !isSaving) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.plannedDurationInvalid || undefined}
aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
className={`${INPUT} min-w-0 flex-none`}
style={{ width: '6rem' }}
/>
{planner.plannedDurationMinutes == null && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
</div>
{planner.plannedDurationInvalid && (
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
</div>
</TourSection>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={!canEdit || isSaving}
value={planner.website}
onChange={event => { if (canEdit && !isSaving) planner.setWebsite(event.target.value) }}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('dawarich.error.invalid_url')}
</span>
)}
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
@@ -207,17 +338,74 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol className="m-0 flex list-none flex-col gap-px p-0">
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
<li key={point.id}>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className={`group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5 text-start outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
aria-current={selected ? 'true' : undefined}
>
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!canEdit || isSaving) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement
? current
: { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (
canEdit &&
!isSaving &&
draggedId &&
draggedId !== point.id &&
dropTarget?.id === point.id
) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
setDraggedWaypointId(null);
setDropTarget(null);
}}
onDragEnd={() => {
setDraggedWaypointId(null);
setDropTarget(null);
}}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={canEdit && !isSaving}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
@@ -226,26 +414,53 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
>
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>{roleLabel(point.role, t)}</span>
<span className="mt-0.5 block truncate font-geist tabular-nums text-content-faint" style={fs(10.5)}>{point.lat.toFixed(5)}, {point.lng.toFixed(5)}</span>
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</button>
{canEdit && (
<span className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100'}`}>
<WaypointAction label={t('tours.planner.moveUp')} disabled={isSaving || index === 0} onAct={() => planner.moveWaypoint(point.id, -1)}>
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction label={t('tours.planner.moveDown')} disabled={isSaving || index === lastIndex} onAct={() => planner.moveWaypoint(point.id, 1)}>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction label={t('tours.planner.remove')} disabled={isSaving} danger onAct={() => planner.removeWaypoint(point.id)}>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</button>
</div>
</li>
)
);
})}
</ol>
)}
@@ -316,17 +531,14 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
return (
<Tooltip label={label}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
<button type="button" disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={event => { event.stopPropagation(); if (!disabled) onAct() }}
onKeyDown={event => { if (!disabled && event.key === 'Enter') { event.stopPropagation(); onAct() } }}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</span>
</button>
</Tooltip>
)
}
@@ -421,7 +633,22 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
selectTour(tour)
}
const walkingTime = { label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }) }
const tourTimeFields = [
{
label: t('tours.planner.inspector.duration'),
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksAdditional'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedTotalDuration')}${planner.plannedDurationMinutes != null ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
@@ -433,7 +660,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
{hasRoute && (
<>
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={[walkingTime]} columns={2} />
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
{routeAnalysis && (
<ElevationSection
@@ -81,6 +81,544 @@ describe('useTourPlanner', () => {
expect(result.current.waypoints).toHaveLength(3)
})
it('RC-08: commits a dragged coordinate once and reroutes only after the completed drag', async () => {
detailTour.mockResolvedValue({
tour: savedTour,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.01, role: 'via', sequence: 1 },
{ lat: 48.02, lng: 11.02, role: 'end', sequence: 2 },
],
})
const { result } = renderHook(() => useTourPlanner({ tripId: 76 }))
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
const id = result.current.waypoints[1].id
const original = { lat: result.current.waypoints[1].lat, lng: result.current.waypoints[1].lng }
const callsBeforeDrag = routeWalkingTour.mock.calls.length
act(() => { expect(result.current.setWaypointPosition(id, 48.03, 11.04)).toBe(true) })
expect(result.current.waypoints[1]).toMatchObject({ lat: 48.03, lng: 11.04 })
expect(result.current.route).toEqual(routed.coordinates)
expect(routeWalkingTour).toHaveBeenCalledTimes(callsBeforeDrag)
expect(result.current.canUndo).toBe(true)
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(routeWalkingTour).toHaveBeenCalledTimes(callsBeforeDrag + 1)
act(() => result.current.undo())
expect(result.current.waypoints[1]).toMatchObject(original)
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(true)
act(() => result.current.redo())
expect(result.current.waypoints[1]).toMatchObject({ lat: 48.03, lng: 11.04 })
})
it('RC-08: preserves a dragged coordinate through draft recovery', () => {
const first = renderHook(() => useTourPlanner({ tripId: 75 }))
act(() => {
first.result.current.addWaypoint(48, 11)
first.result.current.addWaypoint(48.1, 11.1)
first.result.current.addWaypoint(48.2, 11.2)
})
const waypoint = first.result.current.waypoints[1]
act(() => { expect(first.result.current.setWaypointPosition(waypoint.id, 48.15, 11.16)).toBe(true) })
first.unmount()
const recovered = renderHook(() => useTourPlanner({ tripId: 75 }))
expect(recovered.result.current.waypoints[1]).toMatchObject({ id: waypoint.id, lat: 48.15, lng: 11.16 })
})
it('RC-08: saves and reopens the final dragged coordinate', async () => {
const initialWaypoints = [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 48.1, lng: 11.1, role: 'via' as const, sequence: 1 },
{ lat: 48.2, lng: 11.2, role: 'end' as const, sequence: 2 },
]
const finalWaypoints = [
initialWaypoints[0],
{ ...initialWaypoints[1], lat: 48.15, lng: 11.16 },
initialWaypoints[2],
]
detailTour
.mockResolvedValueOnce({ tour: savedTour, waypoints: initialWaypoints })
.mockResolvedValueOnce({ tour: savedTour, waypoints: finalWaypoints })
updateTour.mockResolvedValue({ tour: savedTour, waypoints: finalWaypoints })
const { result } = renderHook(() => useTourPlanner({ tripId: 74 }))
expect(result.current.breakAdditionalMinutes).toBeNull()
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
const id = result.current.waypoints[1].id
act(() => { expect(result.current.setWaypointPosition(id, 48.15, 11.16)).toBe(true) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
act(() => result.current.setName('Moved ridge'))
expect(result.current.canSave).toBe(true)
await act(async () => { await result.current.save() })
expect(updateTour).toHaveBeenCalledWith(
74,
savedTour.place_id,
expect.objectContaining({
waypoints: finalWaypoints.map((point, sequence) => ({ ...point, sequence })),
})
)
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
expect(result.current.waypoints[1]).toMatchObject({ lat: 48.15, lng: 11.16 })
})
it('RC-08: aborts an obsolete dragged-coordinate route and ignores its late response', async () => {
const staleRoute = deferred<typeof routed | null>()
const replacementRoute = { ...routed, coordinates: [[52, 14], [52.01, 14.01]] as [number, number][] }
routeWalkingTour
.mockResolvedValueOnce(routed)
.mockReturnValueOnce(staleRoute.promise)
.mockResolvedValueOnce(replacementRoute)
const { result } = renderHook(() => useTourPlanner({ tripId: 73 }))
act(() => {
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
result.current.addWaypoint(48.2, 11.2)
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
const [first, second] = result.current.waypoints
act(() => { expect(result.current.setWaypointPosition(second.id, 48.11, 11.12)).toBe(true) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
const obsoleteSignal = routeWalkingTour.mock.calls[1][1] as AbortSignal
expect(obsoleteSignal.aborted).toBe(false)
act(() => { expect(result.current.setWaypointPosition(first.id, 48.02, 11.03)).toBe(true) })
expect(obsoleteSignal.aborted).toBe(true)
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.route).toEqual(replacementRoute.coordinates)
await act(async () => { staleRoute.resolve(routed); await Promise.resolve() })
expect(result.current.route).toEqual(replacementRoute.coordinates)
})
it('RS-01: saves and reopens description and website with the Tour metadata', async () => {
createTour.mockResolvedValue({
tour: { ...savedTour, description: 'Lake ridge details', website: 'https://www.komoot.com/tour/42' },
waypoints: [],
})
detailTour.mockResolvedValue({
tour: { ...savedTour, description: 'Lake ridge details', website: 'https://www.komoot.com/tour/42' },
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.1, lng: 11.1, role: 'end', sequence: 1 },
],
})
updateTour.mockResolvedValue({
tour: { ...savedTour, name: 'Edited ridge', description: 'Updated details', website: 'https://alltrails.com/trail/42' },
waypoints: [],
})
const { result } = renderHook(() => useTourPlanner({ tripId: 72 }))
act(() => {
result.current.startNewTour()
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
result.current.setName('Ridge notes')
result.current.setDescription('Lake ridge details')
result.current.setWebsite(' HTTPS://WWW.KOMOOT.COM/tour/42 ')
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.canSave).toBe(true)
await act(async () => { await result.current.save() })
expect(createTour).toHaveBeenCalledWith(72, expect.objectContaining({
description: 'Lake ridge details',
website: 'https://www.komoot.com/tour/42',
}))
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
expect(result.current.description).toBe('Lake ridge details')
expect(result.current.website).toBe('https://www.komoot.com/tour/42')
act(() => {
result.current.setName('Edited ridge')
result.current.setDescription('Updated details')
result.current.setWebsite('https://alltrails.com/trail/42')
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
await act(async () => { await result.current.save() })
expect(updateTour).toHaveBeenCalledWith(72, savedTour.place_id, expect.objectContaining({
description: 'Updated details',
website: 'https://alltrails.com/trail/42',
}))
})
it('RS-01: restores informational metadata from draft and blocks an unsafe website', () => {
const first = renderHook(() => useTourPlanner({ tripId: 71 }))
act(() => {
first.result.current.addWaypoint(48, 11)
first.result.current.addWaypoint(48.1, 11.1)
first.result.current.setName('Ridge notes')
first.result.current.setDescription('Restored description')
first.result.current.setWebsite('https://example.org/route')
first.result.current.setPlannedDurationMinutes(95)
first.result.current.setBreakAdditionalMinutes(35)
})
first.unmount()
const recovered = renderHook(() => useTourPlanner({ tripId: 71 }))
expect(recovered.result.current.description).toBe('Restored description')
expect(recovered.result.current.website).toBe('https://example.org/route')
expect(recovered.result.current.plannedDurationMinutes).toBe(95)
expect(recovered.result.current.breakAdditionalMinutes).toBe(35)
act(() => recovered.result.current.setBreakAdditionalMinutes(-1))
expect(recovered.result.current.breakAdditionalInvalid).toBe(true)
act(() => recovered.result.current.setBreakAdditionalMinutes(35.5))
expect(recovered.result.current.breakAdditionalInvalid).toBe(true)
act(() => recovered.result.current.setPlannedDurationMinutes(1441))
expect(recovered.result.current.plannedDurationInvalid).toBe(true)
act(() => recovered.result.current.setPlannedDurationMinutes(95.5))
expect(recovered.result.current.plannedDurationInvalid).toBe(true)
act(() => recovered.result.current.setWebsite('http://example.org/route'))
expect(recovered.result.current.websiteInvalid).toBe(true)
expect(recovered.result.current.canSave).toBe(false)
})
it('RS-02: saves, reopens and clears planned total without replacing calculated route duration', async () => {
const saved = { ...savedTour, duration: 30, planned_duration_minutes: 95, break_additional_minutes: 40 }
createTour.mockResolvedValue({ tour: saved, waypoints: [] })
detailTour.mockResolvedValue({
tour: saved,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.1, lng: 11.1, role: 'end', sequence: 1 },
],
})
updateTour.mockResolvedValue({ tour: { ...saved, planned_duration_minutes: null }, waypoints: [] })
const { result } = renderHook(() => useTourPlanner({ tripId: 73 }))
act(() => {
result.current.startNewTour()
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
result.current.setName('Ridge notes')
result.current.setBreakAdditionalMinutes(40)
result.current.setPlannedDurationMinutes(95)
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.durationSeconds).toBe(routed.durationSeconds)
expect(result.current.canSave).toBe(true)
await act(async () => { await result.current.save() })
expect(createTour).toHaveBeenCalledWith(73, expect.objectContaining({
duration_seconds: routed.durationSeconds,
planned_duration_minutes: 95,
break_additional_minutes: 40,
}))
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
expect(result.current.plannedDurationMinutes).toBe(95)
expect(result.current.breakAdditionalMinutes).toBe(40)
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
act(() => result.current.setPlannedDurationMinutes(null))
await act(async () => { await result.current.save() })
expect(updateTour).toHaveBeenCalledWith(73, savedTour.place_id, expect.objectContaining({
duration_seconds: routed.durationSeconds,
planned_duration_minutes: null,
break_additional_minutes: 40,
}))
})
it('RS-02: recalculates automatic total when route changes but preserves a manual override', async () => {
routeWalkingTour
.mockResolvedValueOnce({ ...routed, durationSeconds: 3600 })
.mockResolvedValueOnce({ ...routed, durationSeconds: 5400 })
.mockResolvedValueOnce({ ...routed, durationSeconds: 7200 })
const { result } = renderHook(() => useTourPlanner({ tripId: 74 }))
act(() => {
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
result.current.setBreakAdditionalMinutes(30)
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.walkingDurationMinutes).toBe(60)
expect(result.current.plannedTotalMinutes).toBe(90)
const waypointId = result.current.waypoints[1].id
act(() => result.current.setWaypointPosition(waypointId, 48.2, 11.2))
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.walkingDurationMinutes).toBe(90)
expect(result.current.plannedTotalMinutes).toBe(120)
act(() => result.current.setPlannedDurationMinutes(110))
act(() => result.current.setWaypointPosition(waypointId, 48.3, 11.3))
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.walkingDurationMinutes).toBe(120)
expect(result.current.plannedTotalMinutes).toBe(110)
expect(result.current.plannedDurationMinutes).toBe(110)
act(() => result.current.setPlannedDurationMinutes(null))
expect(result.current.plannedTotalMinutes).toBe(150)
act(() => result.current.setBreakAdditionalMinutes(null))
expect(result.current.breakAdditionalMinutes).toBeNull()
expect(result.current.plannedTotalMinutes).toBe(120)
})
it('RS-01: changing informational metadata does not create waypoints or reroute', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 70 }))
act(() => {
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
const ids = result.current.waypoints.map(point => point.id)
const routeBeforeMetadataEdit = result.current.route
const routeCalls = routeWalkingTour.mock.calls.length
act(() => {
result.current.setDescription('A ridge above the lake')
result.current.setWebsite('https://example.org/ridge')
})
expect(result.current.waypoints.map(point => point.id)).toEqual(ids)
expect(result.current.route).toEqual(routeBeforeMetadataEdit)
expect(routeWalkingTour).toHaveBeenCalledTimes(routeCalls)
})
it('RC-07: reorders stable waypoint ids once, normalizes roles, and routes only after the drop', async () => {
detailTour.mockResolvedValue({
tour: savedTour,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.01, role: 'via', sequence: 1 },
{ lat: 48.02, lng: 11.02, role: 'via', sequence: 2 },
{ lat: 48.03, lng: 11.03, role: 'end', sequence: 3 },
],
});
const { result } = renderHook(() => useTourPlanner({ tripId: 77 }));
await act(async () => {
expect(await result.current.openTour(savedTour)).toBe(true);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
const original = result.current.waypoints.map((point) => point.id);
const routeCallsBeforeDrop = routeWalkingTour.mock.calls.length;
act(() => result.current.reorderWaypoint(original[0], original[2], 'after'));
expect(result.current.waypoints.map((point) => point.id)).toEqual([
original[1],
original[2],
original[0],
original[3],
]);
expect(result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'via', 'end']);
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
expect(result.current.route).toEqual(routed.coordinates);
expect(routeWalkingTour).toHaveBeenCalledTimes(routeCallsBeforeDrop);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeWalkingTour).toHaveBeenCalledTimes(routeCallsBeforeDrop + 1);
act(() => result.current.undo());
expect(result.current.waypoints.map((point) => point.id)).toEqual(original);
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(true);
act(() => result.current.redo());
expect(result.current.waypoints.map((point) => point.id)).toEqual([
original[1],
original[2],
original[0],
original[3],
]);
});
it('RC-07: preserves an earlier waypoint move through draft recovery', () => {
const first = renderHook(() => useTourPlanner({ tripId: 78 }));
act(() => {
first.result.current.addWaypoint(48, 11);
first.result.current.addWaypoint(48.1, 11.1);
first.result.current.addWaypoint(48.2, 11.2);
});
const ids = first.result.current.waypoints.map((point) => point.id);
act(() => first.result.current.reorderWaypoint(ids[2], ids[0], 'before'));
expect(first.result.current.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
expect(first.result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end']);
const stored = JSON.parse(localStorage.getItem('tour-draft-78')!) as { waypoints: { id: string }[] };
expect(stored.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
first.unmount();
const recovered = renderHook(() => useTourPlanner({ tripId: 78 }));
expect(recovered.result.current.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
expect(recovered.result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end']);
});
it('RC-07: saves reordered sequence and reopens the same waypoint order', async () => {
const initialWaypoints = [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 48.1, lng: 11.1, role: 'via' as const, sequence: 1 },
{ lat: 48.2, lng: 11.2, role: 'via' as const, sequence: 2 },
{ lat: 48.3, lng: 11.3, role: 'end' as const, sequence: 3 },
];
const reorderedWaypoints = [
initialWaypoints[0],
{ ...initialWaypoints[3], role: 'via' as const },
initialWaypoints[1],
{ ...initialWaypoints[2], role: 'end' as const },
];
detailTour
.mockResolvedValueOnce({ tour: savedTour, waypoints: initialWaypoints })
.mockResolvedValueOnce({ tour: savedTour, waypoints: reorderedWaypoints });
updateTour.mockResolvedValue({ tour: savedTour, waypoints: reorderedWaypoints });
const { result } = renderHook(() => useTourPlanner({ tripId: 79 }));
await act(async () => {
expect(await result.current.openTour(savedTour)).toBe(true);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
const ids = result.current.waypoints.map((point) => point.id);
act(() => result.current.reorderWaypoint(ids[3], ids[1], 'before'));
expect(result.current.waypoints.map((point) => [point.lat, point.lng])).toEqual(
reorderedWaypoints.map((point) => [point.lat, point.lng])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
act(() => result.current.setName('Reordered ridge'));
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
expect(updateTour).toHaveBeenCalledWith(
79,
savedTour.place_id,
expect.objectContaining({
waypoints: reorderedWaypoints.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
role: point.role,
sequence,
})),
})
);
await act(async () => {
expect(await result.current.openTour(savedTour)).toBe(true);
});
expect(result.current.waypoints.map((point) => [point.lat, point.lng])).toEqual(
reorderedWaypoints.map((point) => [point.lat, point.lng])
);
});
it('RC-07: aborts obsolete reorder routing and ignores its late response', async () => {
const staleRoute = deferred<typeof routed | null>();
const replacementRoute = {
...routed,
coordinates: [
[52, 14],
[52.01, 14.01],
] as [number, number][],
};
routeWalkingTour
.mockResolvedValueOnce(routed)
.mockReturnValueOnce(staleRoute.promise)
.mockResolvedValueOnce(replacementRoute);
const { result } = renderHook(() => useTourPlanner({ tripId: 80 }));
act(() => {
result.current.addWaypoint(48, 11);
result.current.addWaypoint(48.1, 11.1);
result.current.addWaypoint(48.2, 11.2);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
const ids = result.current.waypoints.map((point) => point.id);
act(() => result.current.reorderWaypoint(ids[0], ids[1], 'after'));
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
const obsoleteSignal = routeWalkingTour.mock.calls[1][1] as AbortSignal;
expect(obsoleteSignal.aborted).toBe(false);
act(() => result.current.reorderWaypoint(ids[1], ids[2], 'after'));
expect(obsoleteSignal.aborted).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(result.current.route).toEqual(replacementRoute.coordinates);
await act(async () => {
staleRoute.resolve(routed);
await Promise.resolve();
});
expect(result.current.route).toEqual(replacementRoute.coordinates);
});
it('RS-01: saves and reopens description and website with the Tour metadata', async () => {
createTour.mockResolvedValue({
tour: { ...savedTour, description: 'Lake ridge details', website: 'https://www.komoot.com/tour/42' },
waypoints: [],
})
detailTour.mockResolvedValue({
tour: { ...savedTour, description: 'Lake ridge details', website: 'https://www.komoot.com/tour/42' },
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.1, lng: 11.1, role: 'end', sequence: 1 },
],
})
updateTour.mockResolvedValue({
tour: { ...savedTour, name: 'Edited ridge', description: 'Updated details', website: 'https://alltrails.com/trail/42' },
waypoints: [],
})
const { result } = renderHook(() => useTourPlanner({ tripId: 72 }))
act(() => {
result.current.startNewTour()
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.1, 11.1)
result.current.setName('Ridge notes')
result.current.setDescription('Lake ridge details')
result.current.setWebsite(' HTTPS://WWW.KOMOOT.COM/tour/42 ')
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.canSave).toBe(true)
await act(async () => { await result.current.save() })
expect(createTour).toHaveBeenCalledWith(72, expect.objectContaining({
description: 'Lake ridge details',
website: 'https://www.komoot.com/tour/42',
}))
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
expect(result.current.description).toBe('Lake ridge details')
expect(result.current.website).toBe('https://www.komoot.com/tour/42')
act(() => {
result.current.setName('Edited ridge')
result.current.setDescription('Updated details')
result.current.setWebsite('https://alltrails.com/trail/42')
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
await act(async () => { await result.current.save() })
expect(updateTour).toHaveBeenCalledWith(72, savedTour.place_id, expect.objectContaining({
description: 'Updated details',
website: 'https://alltrails.com/trail/42',
}))
})
it('RS-01: restores informational metadata from draft and blocks an unsafe website', () => {
const first = renderHook(() => useTourPlanner({ tripId: 71 }))
act(() => {
first.result.current.addWaypoint(48, 11)
first.result.current.addWaypoint(48.1, 11.1)
first.result.current.setName('Ridge notes')
first.result.current.setDescription('Restored description')
first.result.current.setWebsite('https://example.org/route')
})
first.unmount()
const recovered = renderHook(() => useTourPlanner({ tripId: 71 }))
expect(recovered.result.current.description).toBe('Restored description')
expect(recovered.result.current.website).toBe('https://example.org/route')
act(() => recovered.result.current.setWebsite('http://example.org/route'))
expect(recovered.result.current.websiteInvalid).toBe(true)
expect(recovered.result.current.canSave).toBe(false)
})
it('changes the map focus intent only for explicit Tour context changes', async () => {
detailTour.mockResolvedValue({
tour: savedTour,
@@ -944,7 +1482,7 @@ describe('useTourPlanner', () => {
expect(result.current.mode).toEqual({ type: 'new-draft' })
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
version: 2,
version: 6,
name: '',
waypoints: [],
maxHikingDifficulty: 2,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourCreateResponse, TourListItem, TourWaypointRole, TourMaxHikingDifficulty } from '@trek/shared'
import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
@@ -26,8 +26,12 @@ export type TourPlannerStatus =
| 'saved'
interface DraftPayload {
version: 1 | 2
version: 1 | 2 | 3 | 4 | 5 | 6
name: string
description?: string
website?: string
plannedDurationMinutes?: number | null
breakAdditionalMinutes?: number | null
waypoints: TourPlannerWaypoint[]
maxHikingDifficulty?: TourMaxHikingDifficulty
editingPlaceId?: number | null
@@ -63,7 +67,13 @@ function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]
function validDraft(value: unknown): value is DraftPayload {
if (!value || typeof value !== 'object') return false
const draft = value as Partial<DraftPayload>
return (draft.version === 1 || draft.version === 2) && typeof draft.name === 'string'
return (draft.version === 1 || draft.version === 2 || draft.version === 3 || draft.version === 4 || draft.version === 5 || draft.version === 6) && typeof draft.name === 'string'
&& (draft.description === undefined || typeof draft.description === 'string')
&& (draft.website === undefined || typeof draft.website === 'string')
&& (draft.plannedDurationMinutes === undefined || draft.plannedDurationMinutes === null
|| (Number.isInteger(draft.plannedDurationMinutes) && draft.plannedDurationMinutes >= 0 && draft.plannedDurationMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
&& (draft.breakAdditionalMinutes === undefined || draft.breakAdditionalMinutes === null
|| (Number.isInteger(draft.breakAdditionalMinutes) && draft.breakAdditionalMinutes >= 0 && draft.breakAdditionalMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
&& (draft.editingPlaceId == null || (Number.isInteger(draft.editingPlaceId) && draft.editingPlaceId > 0))
&& (draft.maxHikingDifficulty == null || (Number.isInteger(draft.maxHikingDifficulty) && draft.maxHikingDifficulty >= 1 && draft.maxHikingDifficulty <= 6))
&& Array.isArray(draft.waypoints)
@@ -112,6 +122,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [storageKey])
const [name, setNameState] = useState(restored?.name ?? '')
const [description, setDescriptionState] = useState(restored?.description ?? '')
const [website, setWebsiteState] = useState(restored?.website ?? '')
const [plannedDurationMinutes, setPlannedDurationMinutesState] = useState<number | null>(restored?.plannedDurationMinutes ?? null)
const [breakAdditionalMinutes, setBreakAdditionalMinutesState] = useState<number | null>(restored?.breakAdditionalMinutes ?? null)
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
@@ -238,6 +252,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setSelectedWaypointId(id)
}, [commitWaypoints])
const setWaypointPosition = useCallback((id: string, lat: number, lng: number) => {
if (!editPermissionRef.current || saveInFlightRef.current || !Number.isFinite(lat) || !Number.isFinite(lng)) return false
const current = waypointsRef.current.find(point => point.id === id)
if (!current) return false
if (current.lat === lat && current.lng === lng) return true
commitWaypoints(points => points.map(point => point.id === id ? { ...point, lat, lng } : point))
return true
}, [commitWaypoints])
const removeWaypoint = useCallback((id: string) => {
if (!editPermissionRef.current) return
commitWaypoints(current => current.filter(point => point.id !== id))
@@ -255,6 +278,24 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
})
}, [commitWaypoints])
const reorderWaypoint = useCallback(
(id: string, targetId: string, placement: 'before' | 'after') => {
if (!editPermissionRef.current || saveInFlightRef.current || id === targetId) return;
commitWaypoints((current) => {
const fromIndex = current.findIndex((point) => point.id === id);
const targetIndex = current.findIndex((point) => point.id === targetId);
if (fromIndex < 0 || targetIndex < 0) return current;
const next = [...current];
const [moved] = next.splice(fromIndex, 1);
const targetAfterRemoval = next.findIndex((point) => point.id === targetId);
const insertIndex = targetAfterRemoval + (placement === 'after' ? 1 : 0);
next.splice(insertIndex, 0, moved);
return next;
});
},
[commitWaypoints]
);
const undo = useCallback(() => {
if (!editPermissionRef.current) return
const previous = history.past[history.past.length - 1]
@@ -304,6 +345,60 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [invalidateDetailRequest, name, status])
const setDescription = useCallback((value: string) => {
if (!editPermissionRef.current || value === description) return
invalidateDetailRequest()
draftRevisionRef.current += 1
setDescriptionState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [description, invalidateDetailRequest, status])
const setWebsite = useCallback((value: string) => {
if (!editPermissionRef.current || value === website) return
invalidateDetailRequest()
draftRevisionRef.current += 1
setWebsiteState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [invalidateDetailRequest, status, website])
const setPlannedDurationMinutes = useCallback((value: number | null) => {
if (!editPermissionRef.current || value === plannedDurationMinutes) return
invalidateDetailRequest()
draftRevisionRef.current += 1
setPlannedDurationMinutesState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [invalidateDetailRequest, plannedDurationMinutes, status])
const setBreakAdditionalMinutes = useCallback((value: number | null) => {
if (!editPermissionRef.current || value === breakAdditionalMinutes) return
invalidateDetailRequest()
draftRevisionRef.current += 1
setBreakAdditionalMinutesState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [breakAdditionalMinutes, invalidateDetailRequest, status])
const websiteInvalid = website.trim().length > 0 && !tourWebsiteSchema.safeParse(website).success
const plannedDurationInvalid = plannedDurationMinutes !== null
&& (!Number.isInteger(plannedDurationMinutes) || plannedDurationMinutes < 0 || plannedDurationMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
const walkingDurationMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60)
const automaticPlannedTotalMinutes = walkingDurationMinutes === null
? null
: walkingDurationMinutes + (breakAdditionalMinutes ?? 0)
const breakAdditionalInvalid = breakAdditionalMinutes !== null
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
const plannedTotalInvalid = plannedDurationMinutes === null
&& automaticPlannedTotalMinutes !== null
&& automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES
const returnToNeutral = useCallback(() => {
if (saveInFlightRef.current) return
invalidateDetailRequest()
@@ -313,6 +408,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRouteProfileFocus(null)
localStorage.removeItem(storageKey)
setNameState('')
setDescriptionState('')
setWebsiteState('')
setPlannedDurationMinutesState(null)
setBreakAdditionalMinutesState(null)
setWaypoints([])
setMaxHikingDifficultyState(2)
waypointsRef.current = []
@@ -430,6 +529,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
contextGenerationRef.current += 1
draftRevisionRef.current += 1
setNameState(result.tour.name)
setDescriptionState(result.tour.description ?? '')
setWebsiteState(result.tour.website ?? '')
setPlannedDurationMinutesState(result.tour.planned_duration_minutes ?? null)
setBreakAdditionalMinutesState(result.tour.break_additional_minutes ?? null)
setWaypoints(nextWaypoints)
setMaxHikingDifficultyState(result.tour.max_hiking_difficulty ?? 2)
waypointsRef.current = nextWaypoints
@@ -543,9 +646,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 2, name, waypoints, maxHikingDifficulty, editingPlaceId }
const draft: DraftPayload = { version: 6, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId }
localStorage.setItem(storageKey, JSON.stringify(draft))
}, [editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, storageKey, waypoints])
}, [breakAdditionalMinutes, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website])
useEffect(() => {
if (!hasUnsavedChanges) return
@@ -556,6 +659,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeForKey === waypointKey && hasCompleteElevation(enrichedGeometry)
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
const save = useCallback(async () => {
if (!mountedRef.current || saveInFlightRef.current || !editPermissionRef.current || !canSave || !hasCompleteElevation(enrichedGeometry)) return null
@@ -569,6 +673,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const submittedPlaceId = editingPlaceId
const payload = {
name: name.trim(),
description: description.trim() || null,
website: website.trim() ? tourWebsiteSchema.parse(website) : null,
tour_type: 'hike',
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: waypoints.map((point, sequence) => ({
@@ -579,6 +685,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
})),
max_hiking_difficulty: maxHikingDifficulty,
duration_seconds: durationSeconds,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes,
} as const
setStatus('saving')
setIsSaving(true)
@@ -618,13 +726,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setIsSaving(false)
}
}
}, [canSave, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, storageKey, tripId, waypoints])
}, [breakAdditionalMinutes, canSave, description, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
return {
canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty,
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, removeWaypoint, moveWaypoint,
addWaypoint, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
status, error, retry, isSaving,
route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
+24 -2
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Mountain, Plus, Ruler } from 'lucide-react'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler } from 'lucide-react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
@@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip'
import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill } from '../Planner/planParts'
import { hikeSourceBadgeLabel } from './tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes } from './tourPresentation'
type Translate = (key: string, params?: Record<string, unknown>) => string
@@ -31,6 +31,7 @@ export function TourFactPills({ tour, className = '' }: { tour: TourListItem; cl
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const difficulty = t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)
const times = tourPlannedTimes(tour)
return (
<div data-testid="tour-metrics" className={`flex min-w-0 flex-wrap items-center gap-1 ${className}`}>
<SoftPill className="tabular-nums" icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
@@ -46,6 +47,27 @@ export function TourFactPills({ tour, className = '' }: { tour: TourListItem; cl
<span aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, unit)}`}>{formatElevation(tour.elevation_loss, unit)}</span>
</SoftPill>
)}
{times.walkingMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`}>
{t('tours.planner.walkingShort')} {formatPlannedTourDuration(times.walkingMinutes)}
</span>
</SoftPill>
)}
{times.breakMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`}>
{t('tours.planner.breaksShort')} {formatPlannedTourDuration(times.breakMinutes)}
</span>
</SoftPill>
)}
{times.plannedTotalMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedShort')} {formatPlannedTourDuration(times.plannedTotalMinutes)}{times.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</SoftPill>
)}
<Tooltip label={difficulty}>
<span aria-label={difficulty} className="inline-flex">
<SoftPill>T{tour.max_hiking_difficulty}</SoftPill>
@@ -1,7 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { filterTours, hikeSourceBadgeLabel, tourSource } from './tourPresentation'
import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
describe('tourPresentation', () => {
it('formats planned Tour totals using the shared compact duration formatter', () => {
expect(formatPlannedTourDuration(0)).toBe('0 min')
expect(formatPlannedTourDuration(45)).toBe('45 min')
expect(formatPlannedTourDuration(95)).toBe('1 h 35 min')
})
it('calculates planned total from walking and breaks unless a manual override exists', () => {
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: null, break_additional_minutes: 25 })).toEqual({
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false,
})
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: 130, break_additional_minutes: 25 })).toEqual({
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true,
})
expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: 25 }).plannedTotalMinutes).toBeNull()
})
it('uses the source abstraction for TREK, GPX import, and wanderer badges', () => {
const t = vi.fn((key: string) => key)
@@ -27,4 +43,29 @@ describe('tourPresentation', () => {
expect(filterTours(tours, 'planned').map(tour => tour.id)).toEqual([1, 3])
expect(filterTours([], 'planned')).toEqual([])
})
it.each([
['https://www.komoot.com/tour/42', 'Komoot', 'komoot.com'],
['https://trail.alltrails.com/route/42', 'AllTrails', 'trail.alltrails.com'],
['https://www.outdooractive.de/route/42', 'Outdooractive', 'outdooractive.de'],
['https://demo.wanderer.to/trail/view/42', 'Wanderer', 'demo.wanderer.to'],
])('classifies %s by its hostname', (href, provider, domain) => {
expect(tourWebsitePresentation(href)).toEqual({ href, provider, domain })
})
it('labels an unknown HTTPS host as an external website domain', () => {
expect(tourWebsitePresentation('https://www.example.org/path')).toEqual({
href: 'https://www.example.org/path',
provider: null,
domain: 'example.org',
})
expect(tourWebsitePresentation('https://komoot.com.evil.test/route')).toMatchObject({
provider: null,
domain: 'komoot.com.evil.test',
})
})
it.each(['http://komoot.com/route', 'https://user:pass@komoot.com/route', 'javascript:alert(1)'])
('does not classify unsafe URL %s', value => {
expect(tourWebsitePresentation(value)).toBeNull()
})
})
@@ -1,9 +1,61 @@
import type { TourListItem } from '@trek/shared'
import { tourWebsiteSchema } from '@trek/shared'
import { formatDurationShort } from '@trek/shared/roadtrip'
export type TourSource = 'trek' | 'gpx' | 'wanderer'
/** All / Unplanned / Planned, the same three states as the Places pool. */
export type TourFilter = 'all' | 'unplanned' | 'planned'
export interface TourWebsitePresentation {
href: string
domain: string
provider: 'Komoot' | 'AllTrails' | 'Outdooractive' | 'Wanderer' | null
}
export function formatPlannedTourDuration(minutes: number): string {
return formatDurationShort(minutes * 60)
}
export interface TourPlannedTimes {
walkingMinutes: number | null
breakMinutes: number | null
plannedTotalMinutes: number | null
manuallyOverridden: boolean
}
export function tourPlannedTimes(tour: Pick<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>): TourPlannedTimes {
const manualOverride = tour.planned_duration_minutes ?? null
const breaks = tour.break_additional_minutes ?? 0
const plannedTotal = manualOverride ?? (tour.duration == null ? null : tour.duration + breaks)
return {
walkingMinutes: tour.duration,
breakMinutes: tour.break_additional_minutes ?? null,
plannedTotalMinutes: plannedTotal,
manuallyOverridden: manualOverride !== null,
}
}
const TOUR_WEBSITE_PROVIDERS = [
{ provider: 'Komoot', domains: ['komoot.com'] },
{ provider: 'AllTrails', domains: ['alltrails.com'] },
{ provider: 'Outdooractive', domains: ['outdooractive.com', 'outdooractive.de'] },
{ provider: 'Wanderer', domains: ['wanderer.to'] },
] as const
/** Classifies only the parsed host. It never resolves or requests the URL. */
export function tourWebsitePresentation(value: string | null | undefined): TourWebsitePresentation | null {
const parsed = tourWebsiteSchema.safeParse(value)
if (!parsed.success) return null
const hostname = new URL(parsed.data).hostname.toLowerCase().replace(/^www\./, '')
const match = TOUR_WEBSITE_PROVIDERS.find(({ domains }) =>
domains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`))
)
return {
href: parsed.data,
domain: hostname,
provider: match?.provider ?? null,
}
}
type Translate = (key: string, params?: Record<string, unknown>) => string
@@ -71,6 +71,10 @@ describe('Tours English and German copy', () => {
expect(de['tours.planner.safetyNoteDetails']).toMatch(/OSM.*Wetter.*Sperrungen/)
expect(de['tours.planner.difficulty.t3Warning']).toMatch(/Trittsicherheit/)
expect(de['tours.planner.difficulty.alpineBody']).toMatch(/OSM/)
expect(toursText(en, 'tours.planner.breaksAdditionalInfo')).toBe('Optional whole minutes added to calculated walking time.')
expect(toursText(en, 'tours.planner.plannedTotalInfo')).toBe('Calculated planned total duration.')
expect(toursText(de, 'tours.planner.breaksAdditionalInfo')).toBe('Optionale ganze Minuten, die zur berechneten Gehzeit hinzukommen.')
expect(toursText(de, 'tours.planner.plannedTotalInfo')).toBe('Berechnete geplante Gesamtdauer.')
})
for (const [language, neutral, newTitle, editTitle, gpxTitle, back] of [
@@ -199,6 +199,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
<Fragment key={row.key}>
<PlaceRow
assignment={row.assignment}
tour={planner.toursEnabled ? planner.tours.find(tour => tour.place_id === row.assignment.place_id) : undefined}
fullPlace={tl.fullPlaceOf(row.assignment)}
linkedReservations={row.linkedReservations}
chrome={chrome}
@@ -0,0 +1,59 @@
import type { TourListItem } from '@trek/shared';
import en from '@trek/shared/i18n/en';
import { describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildPlace } from '../../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { PlaceRow } from './MPlanTimelineRows';
const place = buildPlace({ id: 42, name: 'Ridge', route_geometry: '[[48,11],[49,12]]' });
const tour: TourListItem = {
place_id: 42,
name: 'Ridge',
tour_type: 'hike',
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 = {
assignment: buildAssignment({ id: 9, day_id: 1, place }),
fullPlace: place,
linkedReservations: [],
chrome: { editing: false, t: (key: string) => en[key] ?? key, language: 'en', timeFormat: '24h' },
reorder: null,
onOpen: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn(),
};
describe('RC-01 mobile PLAN Tour rows', () => {
it.each([false, true])(
'renders Tour facet icon and facts without changing row activation (editing=%s)',
(editing) => {
const onOpen = vi.fn();
render(<PlaceRow {...props} tour={tour} chrome={{ ...props.chrome, editing }} onOpen={onOpen} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
fireEvent.click(screen.getByTestId('trip-plan-tour-walking-time'));
expect(screen.getByRole('note')).toHaveTextContent('Walking time: 53 min');
expect(onOpen).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Ridge'));
expect(onOpen).toHaveBeenCalledOnce();
}
);
it('leaves a route-backed ordinary Place/Legacy Track on the existing avatar path', () => {
render(<PlaceRow {...props} />);
expect(screen.getByText('Ridge')).toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
});
});
@@ -17,6 +17,8 @@ import type { TransitLegDisplay } from '../../../../components/Planner/transitDi
import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
import { formatScheduleMinutes } from '../../../../components/Plugins/PluginDaySchedule'
import type { PluginDayScheduleItem } from '../../../../api/client'
import type { TourListItem } from '@trek/shared'
import TourDayRowFacts, { TourDayRowIcon } from '../../../../components/Tours/TourDayRowFacts'
/**
* The five row types of the mobile day timeline (place / manual transport /
@@ -120,9 +122,10 @@ const ROW_CODE = 'flex-none whitespace-nowrap font-geist text-[0.71875rem] text-
// ── b3) Place row ────────────────────────────────────────────────────────────
export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
assignment: Assignment
fullPlace: Place | undefined
tour?: TourListItem
linkedReservations: Reservation[]
chrome: RowChrome
reorder: ReactNode
@@ -171,7 +174,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
>
{!chrome.editing && (
<AvatarRing className="shadow-[0_3px_8px_-3px_rgba(0,0,0,.4)]">
<PlaceAvatar
{tour ? <TourDayRowIcon type={tour.tour_type} /> : <PlaceAvatar
place={fullPlace ?? {
id: place?.id ?? assignment.place_id,
name: place?.name ?? '',
@@ -183,12 +186,12 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
}}
size={27}
category={place?.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null}
/>
/>}
</AvatarRing>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<CatIcon size={12} strokeWidth={2.2} className="flex-none text-m-muted" />
{tour ? (chrome.editing && <TourDayRowIcon type={tour.tour_type} size={20} />) : <CatIcon size={12} strokeWidth={2.2} className="flex-none text-m-muted" />}
<span className="min-w-0 truncate text-[0.875rem] font-semibold">{place?.name}</span>
{bookingLines.length > 0 && (
<span className="flex flex-none items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] text-[0.5625rem] font-bold tracking-[.04em]">
@@ -197,6 +200,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
</span>
)}
</div>
{tour && <div className="mt-[2px]"><TourDayRowFacts tour={tour} mobile /></div>}
{(time || sub) && (
<div className="mt-[2px] flex min-w-0 items-center gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
+28 -1
View File
@@ -6,9 +6,11 @@ import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import type { CompassMap } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import LocationSelect from '../components/Planner/LocationSelect';
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import PlacesToursModeSwitch from '../components/Tours/PlacesToursModeSwitch'
import ToursSidebar from '../components/Tours/ToursSidebar'
import { tourPlannedTimes as projectTourPlannedTimes } from '../components/Tours/tourPresentation'
import TourDetailDialog from '../components/Tours/TourDetailDialog'
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -146,7 +148,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
permissions: { canPlaceEdit, canDayEdit },
tourDetails,
tourMap,
locationSearch,
} = pageState
const plannedTourDurations = new globalThis.Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
// Tablets run this very layout but cannot start an HTML5 drag with a finger,
// so a long press stands in for one (#1616). Only where the pointer is
// coarse — a hybrid laptop loads drag-drop-touch instead.
@@ -355,6 +359,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
days={days}
places={places}
tourPlaceIds={tourPlaceIds}
plannedTourDurations={plannedTourDurations}
categories={categories}
assignments={storedAssignments}
selectedDayId={selectedDayId}
@@ -622,7 +627,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} plannedTourDurations={plannedTourDurations} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} toursEnabled={toursEnabled} excludePlaceIds={toursEnabled ? tourPlaceIds : undefined} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -651,10 +656,32 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
plannerWaypoints={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.waypoints : []}
selectedPlannerWaypointId={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.selectedWaypointId : null}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
onPlannerWaypointMove={canPlaceEdit && !tourPlanner.isSaving && (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
? tourPlanner.setWaypointPosition
: undefined}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
/>
<div
style={{
position: 'absolute',
top: 12,
left: `calc(50% + ${((leftHidden ? 56 : leftPanelPx) - (rightHidden ? 56 : rightPanelPx)) / 2}px)`,
transform: 'translateX(-50%)',
width: `min(360px, calc(100vw - ${(leftHidden ? 56 : leftPanelPx) + (rightHidden ? 56 : rightPanelPx) + 32}px))`,
zIndex: 19,
}}
>
<LocationSelect
value={locationSearch.value}
onChange={locationSearch.onChange}
placeholder={t('reservations.searchLocation')}
ariaLabel={t('reservations.searchLocation')}
showSearchStatus
style={{ width: '100%' }}
/>
</div>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
<div style={{
@@ -5,17 +5,29 @@
// Here the hook is replaced by a controllable fixture so every branch of the
// layout — panels, portals, tabs, modals — and every inline callback the page
// hands its children can be driven directly.
import React from 'react'
import { render, screen, fireEvent, waitFor, act, cleanup } from '../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../tests/helpers/store'
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation, buildPackingItem, buildTodoItem } from '../../tests/helpers/factories'
import { useAuthStore } from '../store/authStore'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { assignmentsApi } from '../api/client'
import { analyzeRouteGeometry } from '../utils/routeGeometry'
import TripPlannerPage from './TripPlannerPage'
import type { Day, Place, Reservation, Settings } from '../types'
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import React from 'react';
import {
buildAssignment,
buildDay,
buildPackingItem,
buildPlace,
buildReservation,
buildTodoItem,
buildTrip,
buildUser,
} from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server';
import { act, cleanup, fireEvent, render, screen, waitFor } from '../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { assignmentsApi } from '../api/client';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTripStore } from '../store/tripStore';
import type { Day, Place, Reservation, Settings } from '../types';
import { analyzeRouteGeometry } from '../utils/routeGeometry';
import TripPlannerPage from './TripPlannerPage';
// ── Component stubs ───────────────────────────────────────────────────────────
// Each stub records the props it was rendered with so the page's inline
@@ -25,6 +37,7 @@ const captured: Record<string, Props> = {}
const plannerActions = vi.hoisted(() => ({
viewGpxTour: vi.fn(),
addWaypoint: vi.fn(),
setWaypointPosition: vi.fn(() => true),
openTour: vi.fn(),
startNewTour: vi.fn(),
forgetDeletedTour: vi.fn((_placeId: number) => undefined),
@@ -129,6 +142,7 @@ vi.mock('../components/Tours/planner/useTourPlanner', () => ({
openTour: plannerActions.openTour,
viewGpxTour: plannerActions.viewGpxTour,
addWaypoint: plannerActions.addWaypoint,
setWaypointPosition: plannerActions.setWaypointPosition,
closeGpxTour: vi.fn(),
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
@@ -458,6 +472,7 @@ beforeEach(() => {
confirmDialogs.length = 0
plannerActions.viewGpxTour.mockReset()
plannerActions.addWaypoint.mockReset()
plannerActions.setWaypointPosition.mockReset().mockReturnValue(true)
plannerActions.openTour.mockReset()
plannerActions.startNewTour.mockReset()
plannerActions.forgetDeletedTour.mockReset()
@@ -562,6 +577,66 @@ describe('TripPlannerPage — shell', () => {
expect(plannerActions.openTour).not.toHaveBeenCalled()
})
it('shows location search only in TOUR-PLANNER and focuses a result without writing planner data', async () => {
const user = userEvent.setup();
const createAssignment = vi.spyOn(assignmentsApi, 'create');
server.use(
http.post('/api/maps/search', async ({ request }) => {
const { query } = (await request.json()) as { query: string };
expect(query).toBe('Kyoto');
return HttpResponse.json({
places: [{ name: 'Kyoto', address: 'Japan', lat: 35.0116, lng: 135.7681, osm_id: 'kyoto' }],
});
})
);
const view = renderPage({
activeTab: 'tour-planner',
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
toursEnabled: true,
places: [],
});
const input = screen.getByRole('combobox', { name: 'reservations.searchLocation' });
const mapElement = screen.getByTestId('map-view');
await user.type(input, 'Kyoto');
await screen.findByRole('option', { name: /Kyoto/ });
await user.keyboard('{ArrowDown}{Enter}');
await waitFor(() => expect(props('map').focusPoints).toEqual([[35.0116, 135.7681]]));
expect(props('map').focusKey).toBe('tour:0:search:1');
expect(props('map').places).toEqual([]);
expect(props('map').plannerWaypoints).toEqual([]);
expect(props('map').onPlannerWaypointMove).toBeUndefined();
expect(screen.getByTestId('map-view')).toBe(mapElement);
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
expect(plannerActions.openTour).not.toHaveBeenCalled();
expect(plannerActions.startNewTour).not.toHaveBeenCalled();
plannerActions.mode = { type: 'new-draft' };
plannerActions.mapFocusKey = 1;
view.rerender(<TripPlannerPage />);
expect(props('map').focusPoints).toEqual([]);
expect(props('map').focusKey).toBe('tour:1');
expect(props('map').onPlannerWaypointMove).toBe(plannerActions.setWaypointPosition);
expect(screen.getByTestId('map-view')).toBe(mapElement);
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }));
expect(plannerActions.addWaypoint).toHaveBeenCalledOnce();
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
act(() => props('map').onPlannerWaypointMove('via-1', 35.03, 135.78));
expect(plannerActions.setWaypointPosition).toHaveBeenCalledOnce();
expect(plannerActions.setWaypointPosition).toHaveBeenCalledWith('via-1', 35.03, 135.78);
expect(createAssignment).not.toHaveBeenCalled();
expect(plannerActions.openTour).not.toHaveBeenCalled();
createAssignment.mockRestore();
view.unmount();
renderPage({ activeTab: 'plan', showPlaceForm: true });
expect(screen.queryByRole('combobox', { name: 'reservations.searchLocation' })).not.toBeInTheDocument();
});
it('preserves the visible TOUR-PLANNER camera when the Plan a Tour button starts an empty draft', async () => {
const place = buildPlace({ id: 81, lat: 35.01, lng: 135.76 })
mockStartNewTourTransition()
@@ -579,7 +654,7 @@ describe('TripPlannerPage — shell', () => {
expect(props('map').focusPoints).toEqual([])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').focusKey).toBe(1)
expect(props('map').focusKey).toBe('tour:1');
expect(screen.getByTestId('map-view')).toBe(mapElement)
expect(props('map').onMapClick).toBeTypeOf('function')
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }))
@@ -626,7 +701,7 @@ describe('TripPlannerPage — shell', () => {
expect(props('map').focusPoints).toEqual([])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').focusKey).toBe(9)
expect(props('map').focusKey).toBe('tour:9');
expect(screen.getByTestId('map-view')).toBe(mapElement)
expect(props('map').onMapClick).toBeTypeOf('function')
})
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourListItem } from '@trek/shared'
import type { LocationPoint } from '../../components/Planner/LocationSelect';
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
@@ -39,6 +40,27 @@ export function useTripPlannerPage() {
},
})
const [locationSearchValue, setLocationSearchValue] = useState<LocationPoint | null>(null);
const [searchCameraIntent, setSearchCameraIntent] = useState<{
plannerFocusKey: number;
sequence: number;
point: [number, number];
} | null>(null);
const searchCameraSequenceRef = useRef(0);
const onLocationSearchChange = useCallback(
(location: LocationPoint | null) => {
setLocationSearchValue(location);
if (!location) return;
searchCameraSequenceRef.current += 1;
setSearchCameraIntent({
plannerFocusKey: tourPlanner.mapFocusKey,
sequence: searchCameraSequenceRef.current,
point: [location.lat, location.lng],
});
},
[tourPlanner.mapFocusKey]
);
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState<Bbox | null>(lastPlanMapViewportRef.current)
@@ -71,6 +93,12 @@ export function useTripPlannerPage() {
return plannerFallbackFocusPoints
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === tourPlanner.mapFocusKey;
const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints;
const mapFocusKey = searchFocusIsCurrent
? `tour:${tourPlanner.mapFocusKey}:search:${searchCameraIntent.sequence}`
: `tour:${tourPlanner.mapFocusKey}`;
const route = useMemo<[number, number][][] | null>(() => {
if (tourPlanner.mode.type === 'view-gpx') {
const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates
@@ -111,6 +139,7 @@ export function useTripPlannerPage() {
permissions: { canPlaceEdit, canDayEdit },
tourPlanner,
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints, focusKey: tourPlanner.mapFocusKey, captureViewport },
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
locationSearch: { value: locationSearchValue, onChange: onLocationSearchChange },
}
}
+21 -3
View File
@@ -24,6 +24,8 @@ function tour(overrides: Partial<TourListItem> = {}): TourListItem {
const body: TourCreateRequest = {
name: 'Lake loop',
description: 'A quiet loop above the lake',
website: 'https://www.komoot.com/tour/42',
tour_type: 'hike',
route_geometry: [[47, 11, 1000], [47.01, 11, 1100], [47.02, 11, 1050]],
waypoints: [
@@ -32,6 +34,8 @@ const body: TourCreateRequest = {
],
max_hiking_difficulty: 3,
duration_seconds: 5430,
planned_duration_minutes: 95,
break_additional_minutes: 35,
}
beforeEach(async () => {
@@ -132,9 +136,10 @@ describe('tourRepo.create', () => {
expect(id).toBeLessThan(0)
expect(result.waypoints).toEqual(body.waypoints)
expect(result.tour).toMatchObject({ name: 'Lake loop', elevation_gain: 100, elevation_loss: 50, duration: 91, max_hiking_difficulty: 3, has_waypoints: true, planned: false })
expect(result.tour).toMatchObject({ name: 'Lake loop', elevation_gain: 100, elevation_loss: 50, duration: 91, planned_duration_minutes: 95, break_additional_minutes: 35, max_hiking_difficulty: 3, has_waypoints: true, planned: false })
expect(result.tour).toMatchObject({ description: 'A quiet loop above the lake', website: 'https://www.komoot.com/tour/42' })
expect(result.tour.distance).toBeGreaterThan(2)
expect(await offlineDb.places.get(id)).toMatchObject({ trip_id: 3, name: 'Lake loop', lat: 47, lng: 11, route_geometry: JSON.stringify(body.route_geometry) })
expect(await offlineDb.places.get(id)).toMatchObject({ trip_id: 3, name: 'Lake loop', description: body.description, website: body.website, lat: 47, lng: 11, route_geometry: JSON.stringify(body.route_geometry) })
expect(await offlineDb.tours.get(id)).toMatchObject({ trip_id: 3, waypoints: body.waypoints })
const [mutation] = await offlineDb.mutationQueue.toArray()
@@ -161,7 +166,8 @@ describe('tourRepo.update', () => {
const result = await tourRepo.update(3, 40, body)
expect(result.tour).toMatchObject({ place_id: 40, name: 'Lake loop', planned: true, elevation_gain: 100 })
expect(await offlineDb.places.get(40)).toMatchObject({ name: 'Lake loop', notes: 'keep me', lat: 47 })
expect(result.tour).toMatchObject({ description: body.description, website: body.website })
expect(await offlineDb.places.get(40)).toMatchObject({ name: 'Lake loop', description: body.description, website: body.website, notes: 'keep me', lat: 47 })
const [mutation] = await offlineDb.mutationQueue.toArray()
expect(mutation).toMatchObject({ method: 'PUT', url: '/trips/3/tours/40', resource: 'tours', entityId: 40 })
expect(mutation.tempEntityId).toBeUndefined()
@@ -177,6 +183,18 @@ describe('tourRepo.update', () => {
expect(queued[1]).toMatchObject({ url: '/trips/3/tours/{id}', tempEntityId: created.tour.place_id })
})
it('RS-01: offline route edits that omit old metadata preserve cached description and website', async () => {
setOnline(false)
await offlineDb.tours.put({ ...tour({ description: 'Keep description', website: 'https://example.org/old', planned_duration_minutes: 95, break_additional_minutes: 35 }), trip_id: 3 })
await offlineDb.places.put(buildPlace({ id: 40, trip_id: 3, description: 'Keep description', website: 'https://example.org/old' }))
const { description: _description, website: _website, planned_duration_minutes: _planned, break_additional_minutes: _breaks, ...legacyBody } = body
const result = await tourRepo.update(3, 40, legacyBody)
expect(result.tour).toMatchObject({ description: 'Keep description', website: 'https://example.org/old', planned_duration_minutes: 95, break_additional_minutes: 35 })
expect(await offlineDb.places.get(40)).toMatchObject({ description: 'Keep description', website: 'https://example.org/old' })
})
it('FE-REPO-TOUR-012: online edits and GPX imports refresh the cache', async () => {
server.use(
http.put('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour({ name: 'Edited' }), waypoints: body.waypoints })),
+18 -3
View File
@@ -37,7 +37,7 @@ function endpointsOf(routeGeometry: unknown): TourWaypoint[] {
*/
function routeFields(body: TourCreateRequest) {
const metrics = computeTourMetrics(body.route_geometry as GeometryPoint[])
return {
const fields = {
name: body.name,
tour_type: body.tour_type,
distance: metrics.distanceKm,
@@ -48,13 +48,27 @@ function routeFields(body: TourCreateRequest) {
caution: false,
max_hiking_difficulty: body.max_hiking_difficulty,
has_waypoints: true,
...(body.planned_duration_minutes !== undefined ? { planned_duration_minutes: body.planned_duration_minutes } : {}),
...(body.break_additional_minutes !== undefined ? { break_additional_minutes: body.break_additional_minutes } : {}),
}
return {
...fields,
...(body.description !== undefined ? { description: body.description } : {}),
...(body.website !== undefined ? { website: body.website } : {}),
}
}
/** The owning place's route columns, which a create or edit replaces with the tour's. */
function routePlaceFields(body: TourCreateRequest) {
const start = body.route_geometry[0]
return { name: body.name, lat: start[0], lng: start[1], route_geometry: JSON.stringify(body.route_geometry) }
return {
name: body.name,
lat: start[0],
lng: start[1],
route_geometry: JSON.stringify(body.route_geometry),
...(body.description !== undefined ? { description: body.description } : {}),
...(body.website !== undefined ? { website: body.website } : {}),
}
}
/**
@@ -106,9 +120,10 @@ export const tourRepo = {
const tempId = nextTempId()
const tour: CachedTour = {
place_id: tempId, trip_id: tid, difficulty: null, wanderer_ref: null, planned: false,
description: null, website: null, planned_duration_minutes: null, break_additional_minutes: null,
...routeFields(body), waypoints: body.waypoints,
}
const place = { id: tempId, trip_id: tid, ...routePlaceFields(body) } as Place
const place = { id: tempId, trip_id: tid, description: null, website: null, ...routePlaceFields(body) } as Place
await offlineDb.transaction('rw', offlineDb.places, offlineDb.tours, async () => {
await offlineDb.places.put(place)
await offlineDb.tours.put(tour)
+5 -1
View File
@@ -1,8 +1,8 @@
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { ToursRepository } from '../repositories/Tours.repository';
import { Places } from './Places.entity';
import { TourTypes } from './TourTypes.entity';
import { TourWaypoints } from './TourWaypoints.entity';
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
export class Tours {
[EntityRepositoryType]?: ToursRepository;
@@ -14,6 +14,8 @@ export class Tours {
elevation_gain?: number | null;
elevation_loss?: number | null;
duration?: number | null;
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
difficulty?: string | null;
wanderer_ref?: string | null;
match_confidence?: number | null;
@@ -34,6 +36,8 @@ export const ToursSchema = defineEntity({
elevation_gain: p.double().nullable(),
elevation_loss: p.double().nullable(),
duration: p.double().nullable(),
planned_duration_minutes: p.integer().nullable(),
break_additional_minutes: p.integer().nullable(),
difficulty: p.text().nullable(),
wanderer_ref: p.text().nullable(),
match_confidence: p.double().nullable(),
+2
View File
@@ -14,4 +14,6 @@ export interface ToursTable {
match_confidence: InsertOptional<number | null>;
created_at: InsertOptional<string | null>;
max_hiking_difficulty: InsertOptional<number>;
planned_duration_minutes: InsertOptional<number | null>;
break_additional_minutes: InsertOptional<number | null>;
}
@@ -0,0 +1,16 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/** A Tour-owned manual total, separate from the route engine's duration. */
export class Migration20200101042900_tour_planned_total_duration extends Migration {
override name = 'Migration20200101042900_tour_planned_total_duration';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'tours',
'planned_duration_minutes',
'planned_duration_minutes INTEGER CHECK(planned_duration_minutes BETWEEN 0 AND 1440)',
);
}
}
@@ -0,0 +1,16 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/** Breaks and additional planned time stay separate from walking ETA and total override. */
export class Migration20200101043000_tour_break_additional_duration extends Migration {
override name = 'Migration20200101043000_tour_break_additional_duration';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'tours',
'break_additional_minutes',
'break_additional_minutes INTEGER CHECK(break_additional_minutes BETWEEN 0 AND 1440)',
);
}
}
+288 -137
View File
@@ -1,13 +1,13 @@
import type { Places } from '../entities/Places.entity';
import { absDifference, coalesceParam, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { Places } from '../entities/Places.entity';
import type { DB } from '../kysely/db';
// Task 9 fix wave (B-L2): `TagRow` is Tags.repository.ts's own row type,
// byte-identical to what used to be hand-mirrored here — a second
// declaration of the same shape is exactly the duplication Sonar flags and
// the program's "single source of truth" rule (root CLAUDE.md) forbids.
import type { TagRow } from './Tags.repository';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/** A `places` row as the API emits it — every scalar column of the entity. */
export interface PlaceRow {
@@ -75,9 +75,29 @@ type PlacesTripFileKyselyDB = Pick<DB, 'trip_files'>;
*/
export type SharePublicPlaceRow = Pick<
PlaceWithCategoryRow,
| 'id' | 'trip_id' | 'name' | 'description' | 'lat' | 'lng' | 'address' | 'category_id' | 'price' | 'currency'
| 'place_time' | 'end_time' | 'duration_minutes' | 'notes' | 'image_url' | 'website' | 'phone' | 'transport_mode'
| 'created_at' | 'updated_at' | 'category_name' | 'category_color' | 'category_icon'
| 'id'
| 'trip_id'
| 'name'
| 'description'
| 'lat'
| 'lng'
| 'address'
| 'category_id'
| 'price'
| 'currency'
| 'place_time'
| 'end_time'
| 'duration_minutes'
| 'notes'
| 'image_url'
| 'website'
| 'phone'
| 'transport_mode'
| 'created_at'
| 'updated_at'
| 'category_name'
| 'category_color'
| 'category_icon'
>;
type SharePublicPlaceKyselyDB = Pick<DB, 'places' | 'categories'>;
@@ -142,7 +162,10 @@ export class PlacesRepository extends TrekRepository<Places> {
.leftJoin('p.category', 'c')
.leftJoin('p.tours', 't')
.select([
'p.*', 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
'p.*',
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
columnRef(platform, 't.place_id').as('tour_place_id'),
])
.where('p.id = ?', [place_id])
@@ -281,10 +304,7 @@ export class PlacesRepository extends TrekRepository<Places> {
* the 3g plan's task split — every other method here belongs to Plan 3c.
*/
async findRaw(id: number): Promise<PlaceRow | undefined> {
return this.qb('p')
.select(['p.*'])
.where({ 'p.id': id })
.execute<PlaceRow | undefined>('get', false);
return this.qb('p').select(['p.*']).where({ 'p.id': id }).execute<PlaceRow | undefined>('get', false);
}
/**
@@ -294,7 +314,10 @@ export class PlacesRepository extends TrekRepository<Places> {
* callers immediately follow a `true` result with `deleteById(id)` on the
* SAME numeric id.
*/
async reclaimInputs(id: number, trip_id: number): Promise<{ google_place_id: string | null; image_url: string | null } | undefined> {
async reclaimInputs(
id: number,
trip_id: number,
): Promise<{ google_place_id: string | null; image_url: string | null } | undefined> {
return this.qb('p')
.select(['p.google_place_id', 'p.image_url'])
.where({ 'p.id': id, 'p.trip_id': trip_id })
@@ -428,67 +451,73 @@ export class PlacesRepository extends TrekRepository<Places> {
* `findInTrip` first) — this method writes exactly what it is handed,
* plus the timestamp stamp every write gets.
*/
async updatePlace(id: number, write: {
// Task 9 fix wave (B-L10): `places.name` is `NOT NULL` (`PlaceRow.name:
// string`); the caller's `(name || null) ?? existingPlace.name` fold
// always resolves to a real string before this is called — `string |
// null` here was a widened lie the type checker could never catch a
// real NULL write through.
name: string;
description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
currency: string | null;
place_time: string | null;
end_time: string | null;
duration_minutes: number | null;
notes: string | null;
image_url: string | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
amap_poi_id: string | null;
website: string | null;
phone: string | null;
transport_mode: string | null;
route_color: string | null;
stop_type: string | null;
fill_percent: number | null;
email: string | null;
opening_hours: string | null;
}): Promise<void> {
async updatePlace(
id: number,
write: {
// Task 9 fix wave (B-L10): `places.name` is `NOT NULL` (`PlaceRow.name:
// string`); the caller's `(name || null) ?? existingPlace.name` fold
// always resolves to a real string before this is called — `string |
// null` here was a widened lie the type checker could never catch a
// real NULL write through.
name: string;
description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
currency: string | null;
place_time: string | null;
end_time: string | null;
duration_minutes: number | null;
notes: string | null;
image_url: string | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
amap_poi_id: string | null;
website: string | null;
phone: string | null;
transport_mode: string | null;
route_color: string | null;
stop_type: string | null;
fill_percent: number | null;
email: string | null;
opening_hours: string | null;
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, {
name: write.name,
description: write.description,
lat: write.lat,
lng: write.lng,
address: write.address,
category: write.category_id,
price: write.price,
currency: write.currency,
place_time: write.place_time,
end_time: write.end_time,
duration_minutes: write.duration_minutes,
notes: write.notes,
image_url: write.image_url,
google_place_id: write.google_place_id,
google_ftid: write.google_ftid,
osm_id: write.osm_id,
amap_poi_id: write.amap_poi_id,
website: write.website,
phone: write.phone,
transport_mode: write.transport_mode,
route_color: write.route_color,
stop_type: write.stop_type,
fill_percent: write.fill_percent,
email: write.email,
opening_hours: write.opening_hours,
updated_at: currentTimestamp(platform),
});
await this.nativeUpdate(
{ id },
{
name: write.name,
description: write.description,
lat: write.lat,
lng: write.lng,
address: write.address,
category: write.category_id,
price: write.price,
currency: write.currency,
place_time: write.place_time,
end_time: write.end_time,
duration_minutes: write.duration_minutes,
notes: write.notes,
image_url: write.image_url,
google_place_id: write.google_place_id,
google_ftid: write.google_ftid,
osm_id: write.osm_id,
amap_poi_id: write.amap_poi_id,
website: write.website,
phone: write.phone,
transport_mode: write.transport_mode,
route_color: write.route_color,
stop_type: write.stop_type,
fill_percent: write.fill_percent,
email: write.email,
opening_hours: write.opening_hours,
updated_at: currentTimestamp(platform),
},
);
}
/**
@@ -498,7 +527,10 @@ export class PlacesRepository extends TrekRepository<Places> {
* 1` — the same candidate id bound in all four positions, exactly as the
* legacy statement does.
*/
async findDuplicateByExternalId(trip_id: string, external_id: string): Promise<{ id: number; google_ftid: string | null } | undefined> {
async findDuplicateByExternalId(
trip_id: string,
external_id: string,
): Promise<{ id: number; google_ftid: string | null } | undefined> {
return this.qb('p')
.select(['p.id', 'p.google_ftid'])
.where('p.trip_id = ?', [trip_id])
@@ -530,7 +562,10 @@ export class PlacesRepository extends TrekRepository<Places> {
* :609-619`) already accepts and this repository reproduces literally,
* not fixes).
*/
async findDuplicateByName(trip_id: string, lowered_trimmed_name: string): Promise<{ id: number; google_ftid: string | null } | undefined> {
async findDuplicateByName(
trip_id: string,
lowered_trimmed_name: string,
): Promise<{ id: number; google_ftid: string | null } | undefined> {
const platform = this.getEntityManager().getPlatform();
return this.qb('p')
.select(['p.id', 'p.google_ftid'])
@@ -552,7 +587,12 @@ export class PlacesRepository extends TrekRepository<Places> {
* `ABS(lat - ?) <= ?` then `ABS(lng - ?) <= ?`, the SAME `tolerance` value
* on both.
*/
async findDuplicateByCoords(trip_id: string, lat: number, lng: number, tolerance: number): Promise<{ id: number; google_ftid: string | null } | undefined> {
async findDuplicateByCoords(
trip_id: string,
lat: number,
lng: number,
tolerance: number,
): Promise<{ id: number; google_ftid: string | null } | undefined> {
const platform = this.getEntityManager().getPlatform();
return this.qb('p')
.select(['p.id', 'p.google_ftid'])
@@ -576,17 +616,33 @@ export class PlacesRepository extends TrekRepository<Places> {
* is `DayAssignmentsRepository.listItineraryForGpx` — a different table
* root, kept there per the task-4 brief's own pointer.
*/
async listForGpx(trip_id: string): Promise<{
name: string; description: string | null; address: string | null;
lat: number | null; lng: number | null; route_geometry: string | null;
category: string | null;
}[]> {
async listForGpx(trip_id: string): Promise<
{
name: string;
description: string | null;
address: string | null;
lat: number | null;
lng: number | null;
route_geometry: string | null;
category: string | null;
}[]
> {
return this.qb('p')
.leftJoin('p.category', 'c')
.select(['p.name', 'p.description', 'p.address', 'p.lat', 'p.lng', 'p.route_geometry', 'c.name as category'])
.where('p.trip_id = ?', [trip_id])
.orderBy({ 'p.id': 'asc' })
.execute<{ name: string; description: string | null; address: string | null; lat: number | null; lng: number | null; route_geometry: string | null; category: string | null }[]>('all', false);
.execute<
{
name: string;
description: string | null;
address: string | null;
lat: number | null;
lng: number | null;
route_geometry: string | null;
category: string | null;
}[]
>('all', false);
}
/**
@@ -603,17 +659,31 @@ export class PlacesRepository extends TrekRepository<Places> {
* folding logic, which stays in `PlacesService.buildDedupSet` exactly as
* it was.
*/
async listDedupInputs(trip_id: string): Promise<{
name: string | null; lat: number | null; lng: number | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]> {
async listDedupInputs(trip_id: string): Promise<
{
name: string | null;
lat: number | null;
lng: number | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
amap_poi_id: string | null;
}[]
> {
return this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
.where('p.trip_id = ?', [trip_id])
.execute<{
name: string | null; lat: number | null; lng: number | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]>('all', false);
.execute<
{
name: string | null;
lat: number | null;
lng: number | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
amap_poi_id: string | null;
}[]
>('all', false);
}
/**
@@ -658,12 +728,15 @@ export class PlacesRepository extends TrekRepository<Places> {
* with the legacy statement's own `SELECT DISTINCT`, not a functional
* requirement.
*/
async listForTrip(trip_id: string, filters: {
searchPattern?: string;
category?: string;
tag?: string;
assignment?: 'all' | 'unassigned' | 'assigned';
}): Promise<PlaceListRow[]> {
async listForTrip(
trip_id: string,
filters: {
searchPattern?: string;
category?: string;
tag?: string;
assignment?: 'all' | 'unassigned' | 'assigned';
},
): Promise<PlaceListRow[]> {
const platform = this.getEntityManager().getPlatform();
const qb = this.qb('p')
.leftJoin('p.category', 'c')
@@ -673,18 +746,26 @@ export class PlacesRepository extends TrekRepository<Places> {
// places. `columnRef`, because the bare `t.place_id` twin is
// persist(false) and selects nothing.
.leftJoin('p.tours', 't')
.select([
'p.*', columnRef(platform, 't.place_id').as('tour_place_id'),
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
'pr.country_code as country_code', 'pr.region_name as region_name',
], true)
.select(
[
'p.*',
columnRef(platform, 't.place_id').as('tour_place_id'),
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
'pr.country_code as country_code',
'pr.region_name as region_name',
],
true,
)
.where('p.trip_id = ?', [trip_id]);
if (filters.searchPattern) {
qb.andWhere(
"(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\')",
[filters.searchPattern, filters.searchPattern, filters.searchPattern],
);
qb.andWhere("(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\')", [
filters.searchPattern,
filters.searchPattern,
filters.searchPattern,
]);
}
if (filters.category) {
qb.andWhere('p.category_id = ?', [filters.category]);
@@ -715,7 +796,12 @@ export class PlacesRepository extends TrekRepository<Places> {
* trip_id = ? AND deleted_at IS NULL`: the attachment a place takes its
* picture from, trash excluded.
*/
async findActiveTripFile(id: number, trip_id: number): Promise<{ filename: string; original_name: string; mime_type: string | null; file_size: number | null } | undefined> {
async findActiveTripFile(
id: number,
trip_id: number,
): Promise<
{ filename: string; original_name: string; mime_type: string | null; file_size: number | null } | undefined
> {
return await this.kysely<PlacesTripFileKyselyDB>()
.selectFrom('trip_files')
.select(['filename', 'original_name', 'mime_type', 'file_size'])
@@ -798,14 +884,18 @@ export class PlacesRepository extends TrekRepository<Places> {
* - PL44 (`enrichOne`'s photo update) — `image_url` alone.
* - PL46 (`backfillMissingAddresses`) — `address` alone.
*/
async fillIfEmpty(id: number, trip_id: number, fields: {
google_place_id?: string | null;
google_ftid?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
image_url?: string | null;
}): Promise<void> {
async fillIfEmpty(
id: number,
trip_id: number,
fields: {
google_place_id?: string | null;
google_ftid?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
image_url?: string | null;
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
const data: {
google_place_id?: ReturnType<typeof coalesceParam>;
@@ -816,7 +906,8 @@ export class PlacesRepository extends TrekRepository<Places> {
image_url?: ReturnType<typeof coalesceParam>;
updated_at: ReturnType<typeof currentTimestamp>;
} = { updated_at: currentTimestamp(platform) };
if (fields.google_place_id !== undefined) data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id);
if (fields.google_place_id !== undefined)
data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id);
if (fields.google_ftid !== undefined) data.google_ftid = coalesceParam(platform, 'google_ftid', fields.google_ftid);
if (fields.address !== undefined) data.address = coalesceParam(platform, 'address', fields.address);
if (fields.website !== undefined) data.website = coalesceParam(platform, 'website', fields.website);
@@ -869,10 +960,29 @@ export class PlacesRepository extends TrekRepository<Places> {
.selectFrom('places as p')
.leftJoin('categories as c', 'c.id', 'p.category_id')
.select([
'p.id', 'p.trip_id', 'p.name', 'p.description', 'p.lat', 'p.lng', 'p.address', 'p.category_id',
'p.price', 'p.currency', 'p.place_time', 'p.end_time', 'p.duration_minutes', 'p.notes',
'p.image_url', 'p.website', 'p.phone', 'p.transport_mode', 'p.created_at', 'p.updated_at',
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
'p.id',
'p.trip_id',
'p.name',
'p.description',
'p.lat',
'p.lng',
'p.address',
'p.category_id',
'p.price',
'p.currency',
'p.place_time',
'p.end_time',
'p.duration_minutes',
'p.notes',
'p.image_url',
'p.website',
'p.phone',
'p.transport_mode',
'p.created_at',
'p.updated_at',
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
])
.where('p.trip_id', '=', trip_id as number)
.orderBy('p.created_at', 'desc')
@@ -912,10 +1022,7 @@ export class PlacesRepository extends TrekRepository<Places> {
* relatively ordered) the same way the legacy loop did.
*/
async listAllForTrip(trip_id: number | string): Promise<PlaceRow[]> {
return await this.qb('p')
.select(['p.*'])
.where('p.trip_id = ?', [trip_id])
.execute<PlaceRow[]>('all', false);
return await this.qb('p').select(['p.*']).where('p.trip_id = ?', [trip_id]).execute<PlaceRow[]>('all', false);
}
/**
@@ -1015,11 +1122,17 @@ export class PlacesRepository extends TrekRepository<Places> {
}
/** CH1 (`ChargingService.read`) — `SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?`. */
async findChargingProbe(id: number, trip_id: number): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> {
async findChargingProbe(
id: number,
trip_id: number,
): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> {
const row = await this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.stop_type'])
.where({ id, trip: trip_id })
.execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>('get', false);
.execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>(
'get',
false,
);
return row ?? undefined;
}
@@ -1044,10 +1157,7 @@ export class PlacesRepository extends TrekRepository<Places> {
/** AC31 (`AccommodationsService.createAccommodation`) — `SELECT name FROM places WHERE id = ?`, the auto-created partner booking's title source. */
async getName(id: number): Promise<{ name: string } | undefined> {
return await this.qb('p')
.select(['p.name'])
.where({ id })
.execute<{ name: string } | undefined>('get', false);
return await this.qb('p').select(['p.name']).where({ id }).execute<{ name: string } | undefined>('get', false);
}
// ---------------------------------------------------------------------------
@@ -1119,7 +1229,9 @@ export class PlacesRepository extends TrekRepository<Places> {
* city apart from the region sitting right above it in the same address
* (#1115) — unchanged from the legacy projection.
*/
async listAddressesForUser(user_id: number): Promise<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }[]> {
async listAddressesForUser(
user_id: number,
): Promise<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }[]> {
return await this.kysely<PlacesAddressesForUserKyselyDB>()
.selectFrom('places as p')
.innerJoin('trips as t', 't.id', 'p.trip_id')
@@ -1178,8 +1290,16 @@ export class PlacesRepository extends TrekRepository<Places> {
return await this.kysely<ImportablePlacesKyselyDB>()
.selectFrom('places as p')
.select((eb) => [
'p.id as place_id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.category_id', 'p.image_url',
'p.google_place_id', 'p.google_ftid', 'p.osm_id',
'p.id as place_id',
'p.name',
'p.address',
'p.lat',
'p.lng',
'p.category_id',
'p.image_url',
'p.google_place_id',
'p.google_ftid',
'p.osm_id',
eb
.selectFrom('day_assignments as da')
.innerJoin('days as d', 'd.id', 'da.day_id')
@@ -1265,7 +1385,11 @@ export class PlacesRepository extends TrekRepository<Places> {
phone: string | null;
osm_id: string | null;
}): Promise<number> {
const inserted = await this.kysely<PlacesNarrowInsertKyselyDB>().insertInto('places').values(row).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<PlacesNarrowInsertKyselyDB>()
.insertInto('places')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -1316,8 +1440,16 @@ export class PlacesRepository extends TrekRepository<Places> {
.leftJoin('categories as c', 'c.id', 'p.category_id')
.select([
'da.day_id',
'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time',
'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category',
'p.name',
'p.address',
'p.lat',
'p.lng',
'p.place_time',
'p.end_time',
'p.duration_minutes',
'p.notes',
'p.transport_mode',
'c.name as category',
])
.where('p.trip_id', '=', trip_id)
.where('da.accommodation_id', 'is', null)
@@ -1333,7 +1465,15 @@ export class PlacesRepository extends TrekRepository<Places> {
* ?)`. The place a drawn tour lives on starts at the route's first point;
* every other column keeps its default. Returns the generated id.
*/
async insertTourPlace(input: { trip_id: number; name: string; lat: number; lng: number; route_geometry: string }): Promise<number> {
async insertTourPlace(input: {
trip_id: number;
name: string;
lat: number;
lng: number;
route_geometry: string;
description?: string | null;
website?: string | null;
}): Promise<number> {
return await this.insert({
trip: input.trip_id,
name: input.name,
@@ -1341,6 +1481,8 @@ export class PlacesRepository extends TrekRepository<Places> {
lng: input.lng,
transport_mode: 'walking',
route_geometry: input.route_geometry,
description: input.description ?? null,
website: input.website ?? null,
});
}
@@ -1354,7 +1496,14 @@ export class PlacesRepository extends TrekRepository<Places> {
async updateTourRoute(
id: number,
trip_id: number,
input: { name: string; lat: number; lng: number; route_geometry: string },
input: {
name: string;
lat: number;
lng: number;
route_geometry: string;
description?: string | null;
website?: string | null;
},
): Promise<boolean> {
const platform = this.getEntityManager().getPlatform();
const updated = await this.nativeUpdate(
@@ -1365,6 +1514,8 @@ export class PlacesRepository extends TrekRepository<Places> {
lng: input.lng,
transport_mode: 'walking',
route_geometry: input.route_geometry,
...(input.description !== undefined ? { description: input.description } : {}),
...(input.website !== undefined ? { website: input.website } : {}),
updated_at: currentTimestamp(platform),
},
);
+24 -1
View File
@@ -12,11 +12,15 @@ import { TrekRepository } from './_shared/trek-repository';
export interface TourListRow {
place_id: number;
name: string;
description?: string | null;
website?: string | null;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
planned_duration_minutes: number | null;
break_additional_minutes: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
@@ -33,6 +37,8 @@ export interface TourRow {
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
planned_duration_minutes: number | null;
break_additional_minutes: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
@@ -49,12 +55,17 @@ export interface TourInsert {
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
planned_duration_minutes: number | null;
break_additional_minutes?: number | null;
match_confidence: number | null;
max_hiking_difficulty: number;
}
/** What a route edit replaces on an existing tour. */
export type TourUpdate = Omit<TourInsert, 'place_id'>;
export type TourUpdate = Omit<TourInsert, 'place_id' | 'planned_duration_minutes' | 'break_additional_minutes'> & {
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
};
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints'>;
@@ -81,11 +92,15 @@ export class ToursRepository extends TrekRepository<Tours> {
.select((eb) => [
'p.id as place_id',
'p.name as name',
'p.description as description',
'p.website as website',
't.tour_type as tour_type',
't.distance as distance',
't.elevation_gain as elevation_gain',
't.elevation_loss as elevation_loss',
't.duration as duration',
't.planned_duration_minutes as planned_duration_minutes',
't.break_additional_minutes as break_additional_minutes',
't.difficulty as difficulty',
't.wanderer_ref as wanderer_ref',
't.match_confidence as match_confidence',
@@ -177,6 +192,8 @@ export class ToursRepository extends TrekRepository<Tours> {
elevation_gain: input.elevation_gain,
elevation_loss: input.elevation_loss,
duration: input.duration,
planned_duration_minutes: input.planned_duration_minutes,
break_additional_minutes: input.break_additional_minutes ?? null,
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
});
@@ -207,6 +224,12 @@ export class ToursRepository extends TrekRepository<Tours> {
elevation_gain: input.elevation_gain,
elevation_loss: input.elevation_loss,
duration: input.duration,
...(input.planned_duration_minutes !== undefined
? { planned_duration_minutes: input.planned_duration_minutes }
: {}),
...(input.break_additional_minutes !== undefined
? { break_additional_minutes: input.break_additional_minutes }
: {}),
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
})
+66 -7
View File
@@ -13,12 +13,15 @@ import { PlacesService } from '../places/places.service';
import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type {
TourCreateRequest,
TourCreateResponse,
TourDetailResponse,
TourListItem,
TourWaypoint,
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type TourCreateRequest,
type TourCreateResponse,
type TourDetailResponse,
type TourListItem,
type TourWaypoint,
} from '@trek/shared';
export interface ImportGpxAsTourResult {
@@ -52,11 +55,15 @@ export class ToursService {
return {
place_id: r.place_id,
name: r.name,
description: r.description,
website: r.website,
tour_type: r.tour_type as TourListItem['tour_type'],
distance: r.distance,
elevation_gain: r.elevation_gain,
elevation_loss: r.elevation_loss,
duration: r.duration,
planned_duration_minutes: r.planned_duration_minutes,
break_additional_minutes: r.break_additional_minutes,
difficulty: r.difficulty,
wanderer_ref: r.wanderer_ref,
match_confidence: r.match_confidence,
@@ -76,6 +83,12 @@ export class ToursService {
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
...(input.planned_duration_minutes !== undefined
? { planned_duration_minutes: input.planned_duration_minutes }
: {}),
...(input.break_additional_minutes !== undefined
? { break_additional_minutes: input.break_additional_minutes }
: {}),
match_confidence: 1,
max_hiking_difficulty: input.max_hiking_difficulty,
};
@@ -90,6 +103,33 @@ export class ToursService {
if (!(await this.tourTypesRepo.isEnabled(key))) throw new BadRequestException('Tour type is not available');
}
private validateInformationalMetadata(input: TourCreateRequest): void {
if (input.description != null && input.description.length > 2000) {
throw new BadRequestException('description must be 2000 characters or less');
}
if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) {
throw new BadRequestException('website must be a valid HTTPS URL without credentials');
}
}
private validatePlannedTimes(input: TourCreateRequest): void {
for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) {
if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) {
throw new BadRequestException('planned Tour times must be whole minutes from 0 to 1440');
}
}
if (
input.planned_duration_minutes == null &&
input.duration_seconds != null &&
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) >
MAX_PLANNED_TOUR_DURATION_MINUTES
) {
throw new BadRequestException(
'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
);
}
}
/** All tours (the facet + owning place) for a trip, newest first. */
async listTours(tripId: string): Promise<TourListItem[]> {
const tid = toRowId(tripId);
@@ -131,6 +171,8 @@ export class ToursService {
// The controller's TripAccessGuard already resolved this trip id.
const tid = toRowId(tripId)!;
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validatePlannedTimes(input);
const start = input.route_geometry[0];
const fields = this.routeFields(input);
@@ -141,8 +183,15 @@ export class ToursService {
lat: start[0],
lng: start[1],
route_geometry: JSON.stringify(input.route_geometry),
description: input.description ?? null,
website: input.website ?? null,
});
await this.toursRepo.insertTour({
place_id: id,
...fields,
planned_duration_minutes: fields.planned_duration_minutes ?? null,
break_additional_minutes: fields.break_additional_minutes ?? null,
});
await this.toursRepo.insertTour({ place_id: id, ...fields });
await this.waypointsRepo.insertForPlace(id, input.waypoints);
return id;
});
@@ -174,6 +223,8 @@ export class ToursService {
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validatePlannedTimes(input);
const start = input.route_geometry[0];
const fields = this.routeFields(input);
@@ -188,6 +239,8 @@ export class ToursService {
lat: start[0],
lng: start[1],
route_geometry: JSON.stringify(input.route_geometry),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.website !== undefined ? { website: input.website } : {}),
});
if (!placeUpdated) throw new NotFoundException('Tour not found');
if (!(await this.toursRepo.updateInTrip(tid, pid, fields))) throw new NotFoundException('Tour not found');
@@ -253,6 +306,8 @@ export class ToursService {
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: 2,
});
@@ -260,11 +315,15 @@ export class ToursService {
this.toItem({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: 'hike',
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
@@ -11,6 +11,8 @@ describe('known migrations', () => {
const known = knownMigrationNames();
expect(known.has('Migration20200101000000_baseline_schema')).toBe(true);
expect(known.has('Migration20200101042000_tours')).toBe(true);
expect(known.has('Migration20200101042900_tour_planned_total_duration')).toBe(true);
expect(known.has('Migration20200101043000_tour_break_additional_duration')).toBe(true);
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
});
@@ -40,6 +40,8 @@ const UNNUMBERED = [
'Migration20200101042600_a_session_row_per_sign_in',
'Migration20200101042700_timestamps_stored_in_one_spelling',
'Migration20200101042800_an_email_the_account_typed_in_itself',
'Migration20200101042900_tour_planned_total_duration',
'Migration20200101043000_tour_break_additional_duration',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,8 @@ const METRICS = {
elevation_gain: 420,
elevation_loss: 380,
duration: 150,
planned_duration_minutes: 125,
break_additional_minutes: 35,
match_confidence: 1,
max_hiking_difficulty: 3,
};
@@ -57,7 +59,8 @@ describe('ToursRepository reads', () => {
it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => {
const { trip, other } = fixture();
const older = createPlace(testDb, trip.id, { name: 'Older' });
const newer = createPlace(testDb, trip.id, { name: 'Newer' });
const newer = createPlace(testDb, trip.id, { name: 'Newer', description: 'Lake ridge' });
testDb.prepare('UPDATE places SET website = ? WHERE id = ?').run('https://www.komoot.com/tour/42', newer.id);
const plain = createPlace(testDb, trip.id, { name: 'Not a tour' });
const foreign = createPlace(testDb, other.id, { name: 'Foreign' });
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 });
@@ -70,11 +73,15 @@ describe('ToursRepository reads', () => {
{
place_id: newer.id,
name: 'Newer',
description: 'Lake ridge',
website: 'https://www.komoot.com/tour/42',
tour_type: 'hike',
distance: null,
elevation_gain: null,
elevation_loss: null,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
@@ -85,11 +92,15 @@ describe('ToursRepository reads', () => {
{
place_id: older.id,
name: 'Older',
description: null,
website: null,
tour_type: 'hike',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 0.3,
@@ -164,6 +175,8 @@ describe('ToursRepository writes', () => {
expect(await repo.findInTrip(trip.id, place.id)).toEqual({
place_id: place.id,
name: 'Drawn',
description: null,
website: null,
...METRICS,
difficulty: null,
wanderer_ref: null,
@@ -182,13 +195,34 @@ describe('ToursRepository writes', () => {
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
const {
planned_duration_minutes: _omitted,
break_additional_minutes: _omittedBreaks,
...routeOnlyUpdate
} = METRICS;
expect(await repo.updateInTrip(trip.id, place.id, routeOnlyUpdate)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
duration: 150,
planned_duration_minutes: 125,
break_additional_minutes: 35,
});
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
await updateRows(t, Tours, { place: source.id }, { difficulty: 'T3', wanderer_ref: 'w-1' });
await updateRows(
t,
Tours,
{ place: source.id },
{
difficulty: 'T3',
wanderer_ref: 'w-1',
planned_duration_minutes: 95,
break_additional_minutes: 30,
},
);
const rows = await repo.listRowsForTrip(trip.id);
expect(rows).toEqual([
@@ -199,6 +233,8 @@ describe('ToursRepository writes', () => {
elevation_gain: null,
elevation_loss: null,
duration: null,
planned_duration_minutes: 95,
break_additional_minutes: 30,
difficulty: 'T3',
wanderer_ref: 'w-1',
match_confidence: null,
@@ -0,0 +1,57 @@
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101043000_tour_break_additional_duration';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const index = names.indexOf(TARGET);
expect(index).toBeGreaterThan(0);
await migrateTo(orm, names[index - 1]);
return orm;
}
describe('Tour breaks/additional-duration migration', () => {
it('adds nullable breaks without changing calculated or overridden Tour values', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(
orm,
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
);
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')");
await rawExec(
orm,
"INSERT INTO tours (place_id, tour_type, duration, planned_duration_minutes) VALUES (100, 'hike', 83, 125)",
);
await migrateTo(orm, TARGET);
expect(
await rawQuery(
orm,
'SELECT duration, planned_duration_minutes, break_additional_minutes FROM tours WHERE place_id = 100',
),
).toEqual([{ duration: 83, planned_duration_minutes: 125, break_additional_minutes: null }]);
await rawExec(orm, 'UPDATE tours SET break_additional_minutes = 35 WHERE place_id = 100');
expect(
await rawQuery(
orm,
'SELECT duration, planned_duration_minutes, break_additional_minutes FROM tours WHERE place_id = 100',
),
).toEqual([{ duration: 83, planned_duration_minutes: 125, break_additional_minutes: 35 }]);
await expect(
rawExec(orm, 'UPDATE tours SET break_additional_minutes = 1441 WHERE place_id = 100'),
).rejects.toThrow(/CHECK/);
await expect(rawExec(orm, 'UPDATE tours SET break_additional_minutes = -1 WHERE place_id = 100')).rejects.toThrow(
/CHECK/,
);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -0,0 +1,48 @@
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101042900_tour_planned_total_duration';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const index = names.indexOf(TARGET);
expect(index).toBeGreaterThan(0);
await migrateTo(orm, names[index - 1]);
return orm;
}
describe('Tour planned-duration migration', () => {
it('adds a nullable bounded column without changing existing Tour rows', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(
orm,
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
);
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')");
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, duration) VALUES (100, 'hike', 83)");
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, 'SELECT duration, planned_duration_minutes FROM tours WHERE place_id = 100')).toEqual([
{ duration: 83, planned_duration_minutes: null },
]);
await rawExec(orm, 'UPDATE tours SET planned_duration_minutes = 125 WHERE place_id = 100');
expect(await rawQuery(orm, 'SELECT duration, planned_duration_minutes FROM tours WHERE place_id = 100')).toEqual([
{ duration: 83, planned_duration_minutes: 125 },
]);
await expect(
rawExec(orm, 'UPDATE tours SET planned_duration_minutes = 1441 WHERE place_id = 100'),
).rejects.toThrow(/CHECK/);
await expect(rawExec(orm, 'UPDATE tours SET planned_duration_minutes = -1 WHERE place_id = 100')).rejects.toThrow(
/CHECK/,
);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -214,6 +214,9 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
has_waypoints: false,
caution: true,
match_confidence: 0.3,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
});
expect(result.tours[1]).toMatchObject({
name: 'Ridge',
@@ -246,8 +249,19 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
(await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' })).map((tour) => ({
place_id: tour.place_id,
tour_type: tour.tour_type,
duration: tour.duration,
planned_duration_minutes: tour.planned_duration_minutes,
break_additional_minutes: tour.break_additional_minutes,
})),
).toEqual(rows.map((row) => ({ place_id: row.id, tour_type: 'hike' })));
).toEqual(
rows.map((row) => ({
place_id: row.id,
tour_type: 'hike',
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
})),
);
expect(await count('tour_waypoints')).toBe(0);
expect(seen).toEqual([
@@ -324,6 +324,8 @@ describe('Real ToursService update trip boundary (stubbed repositories)', () =>
lat: 48,
lng: 11,
route_geometry: JSON.stringify(input.route_geometry),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.website !== undefined ? { website: input.website } : {}),
});
expect(f.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(
7,
@@ -141,6 +141,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
lat: 48,
lng: 11,
route_geometry: JSON.stringify(request.route_geometry),
description: request.description ?? null,
website: request.website ?? null,
});
expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({
place_id: 42,
@@ -149,6 +151,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
elevation_gain: 50,
elevation_loss: 20,
duration: 60,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: 1,
max_hiking_difficulty: 2,
});
@@ -236,6 +240,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
lat: 49,
lng: 12,
route_geometry: JSON.stringify(update.route_geometry),
...(update.description !== undefined ? { description: update.description } : {}),
...(update.website !== undefined ? { website: update.website } : {}),
});
expect(setup.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, {
tour_type: 'hike',
+135 -1
View File
@@ -37,6 +37,8 @@ vi.mock('../../../src/db/database', async () => {
const request: TourCreateRequest = {
name: 'Ridge walk',
description: 'A quiet ridge above the lake.',
website: 'https://www.komoot.com/tour/42',
tour_type: 'hike',
route_geometry: [
[48, 11, 600],
@@ -49,6 +51,8 @@ const request: TourCreateRequest = {
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
planned_duration_minutes: 90,
break_additional_minutes: 25,
};
/** Two waypoints on the same sequence: the UNIQUE(place_id, sequence) index rejects the second one. */
@@ -99,6 +103,24 @@ afterAll(() => {
});
describe('ToursService planner creation', () => {
it.each([
'http://example.org/route',
'javascript:alert(1)',
'data:text/plain,route',
'file:///route.gpx',
'https://user:secret@example.org/route',
])('rejects invalid informational website %s before creating rows', async (website) => {
const parsed = tourCreateRequestSchema.safeParse({ ...request, website });
expect(parsed.success).toBe(false);
await expect(service.createTour(tripId, { ...request, website } as TourCreateRequest)).rejects.toThrow();
expect(await count(Places)).toBe(0);
expect(await count(Tours)).toBe(0);
});
it('safely reports malformed website input through schema safeParse', () => {
expect(tourCreateRequestSchema.safeParse({ ...request, website: 'not a url' }).success).toBe(false);
});
it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', async () => {
const writeStates: boolean[] = [];
broadcast.mockImplementation(() => {
@@ -123,18 +145,36 @@ describe('ToursService planner creation', () => {
lat: 48,
lng: 11,
transport_mode: 'walking',
duration_minutes: 60,
});
expect(place).toMatchObject({
description: 'A quiet ridge above the lake.',
website: 'https://www.komoot.com/tour/42',
});
expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry);
expect(tour).toMatchObject({ tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2 });
expect(tour).toMatchObject({
tour_type: 'hike',
match_confidence: 1,
max_hiking_difficulty: 2,
planned_duration_minutes: 90,
break_additional_minutes: 25,
});
expect(tour.distance).toBeGreaterThan(0);
expect(tour.elevation_gain).toBe(50);
expect(tour.elevation_loss).toBe(20);
expect(tour.duration).toBe(60);
expect(result.tour.duration).toBe(60);
expect(result.tour.planned_duration_minutes).toBe(90);
expect(result.tour.break_additional_minutes).toBe(25);
expect(points).toEqual([
{ role: 'start', sequence: 0 },
{ role: 'end', sequence: 1 },
]);
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true, caution: false });
expect(result.tour).toMatchObject({
description: 'A quiet ridge above the lake.',
website: 'https://www.komoot.com/tour/42',
});
expect(result.waypoints).toEqual(request.waypoints);
expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1');
expect(broadcast).toHaveBeenNthCalledWith(
@@ -176,6 +216,35 @@ describe('ToursService planner creation', () => {
expect(result.tour.duration).toBeNull();
});
it('RS-02: leaves planned total empty when omitted and preserves the calculated duration', async () => {
const { planned_duration_minutes: _omitted, ...withoutPlannedDuration } = request;
const result = await service.createTour(tripId, withoutPlannedDuration);
expect(result.tour.duration).toBe(60);
expect(result.tour.planned_duration_minutes).toBeNull();
});
it.each([-1, 1.5, 1441])('RS-02: rejects invalid breaks %s before writing Tour rows', async (breakMinutes) => {
await expect(
service.createTour(tripId, { ...request, break_additional_minutes: breakMinutes } as TourCreateRequest),
).rejects.toThrow();
expect(await count(Places)).toBe(0);
expect(await count(Tours)).toBe(0);
});
it('RS-02: rejects overlong automatic totals but permits a bounded manual override', async () => {
const tooLong = {
...request,
duration_seconds: 1430 * 60,
break_additional_minutes: 11,
planned_duration_minutes: null,
};
await expect(service.createTour(tripId, tooLong)).rejects.toThrow();
const overridden = await service.createTour(tripId, { ...tooLong, planned_duration_minutes: 1200 });
expect(overridden.tour.duration).toBe(1430);
expect(overridden.tour.planned_duration_minutes).toBe(1200);
expect(overridden.tour.break_additional_minutes).toBe(11);
});
it('TOURS-SVC-010: a type the catalogue holds but has not enabled is a 400 without a write', async () => {
// The contract only lets `hike` through today; this pins what happens the
// day it lets more keys in before the planner offers them.
@@ -285,6 +354,8 @@ describe('ToursService updates', () => {
const update: TourCreateRequest = {
...request,
name: 'Updated ridge walk',
description: 'Updated description',
website: 'https://alltrails.com/trail/42',
route_geometry: [
[49, 12, 700],
[49.02, 12.04, 760],
@@ -295,10 +366,13 @@ describe('ToursService updates', () => {
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
],
duration_seconds: 2700.5,
planned_duration_minutes: 110,
break_additional_minutes: 40,
};
it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', async () => {
const created = await service.createTour(tripId, request);
testDb.prepare('UPDATE places SET duration_minutes = 35 WHERE id = ?').run(created.tour.place_id);
broadcast.mockReset();
const writeStates: boolean[] = [];
broadcast.mockImplementation(() => {
@@ -312,22 +386,30 @@ describe('ToursService updates', () => {
const storedPlace = await firstPlace();
expect({
name: storedPlace.name,
description: storedPlace.description,
website: storedPlace.website,
lat: storedPlace.lat,
lng: storedPlace.lng,
transport_mode: storedPlace.transport_mode,
duration_minutes: storedPlace.duration_minutes,
}).toEqual({
name: 'Updated ridge walk',
description: 'Updated description',
website: 'https://alltrails.com/trail/42',
lat: 49,
lng: 12,
transport_mode: 'walking',
duration_minutes: 35,
});
const storedTour = await firstTour();
expect({
duration: storedTour.duration,
planned_duration_minutes: storedTour.planned_duration_minutes,
elevation_gain: storedTour.elevation_gain,
elevation_loss: storedTour.elevation_loss,
}).toEqual({
duration: 45,
planned_duration_minutes: 110,
elevation_gain: 60,
elevation_loss: 0,
});
@@ -348,6 +430,58 @@ describe('ToursService updates', () => {
expect(writeStates).toEqual([false, false]);
});
it('preserves description and website when an older Tour update omits optional metadata', async () => {
const created = await service.createTour(tripId, request);
const {
description: _description,
website: _website,
planned_duration_minutes: _planned,
break_additional_minutes: _breaks,
...legacyUpdate
} = update;
const result = await service.updateTour(tripId, String(created.tour.place_id), legacyUpdate);
expect(result.tour).toMatchObject({ description: request.description, website: request.website });
expect(result.tour.planned_duration_minutes).toBe(request.planned_duration_minutes);
expect(result.tour.break_additional_minutes).toBe(request.break_additional_minutes);
expect(testDb.prepare('SELECT description, website FROM places WHERE id = ?').get(created.tour.place_id)).toEqual({
description: request.description,
website: request.website,
});
});
it('clears planned total duration only when an update explicitly sends null', async () => {
const created = await service.createTour(tripId, request);
const result = await service.updateTour(tripId, String(created.tour.place_id), {
...update,
planned_duration_minutes: null,
});
expect(result.tour.duration).toBe(45);
expect(result.tour.planned_duration_minutes).toBeNull();
});
it('RS-01: updates informational fields without overwriting Place or Wanderer provenance', async () => {
const created = await service.createTour(tripId, request);
testDb.prepare('UPDATE places SET source = ? WHERE id = ?').run('gpx-import:source-42', created.tour.place_id);
testDb
.prepare('UPDATE tours SET wanderer_ref = ? WHERE place_id = ?')
.run('wanderer:trail-42', created.tour.place_id);
const metadataUpdate = { ...update, description: 'Updated details', website: 'https://outdooractive.com/route/42' };
await service.updateTour(tripId, String(created.tour.place_id), metadataUpdate);
expect(
testDb.prepare('SELECT description, website, source FROM places WHERE id = ?').get(created.tour.place_id),
).toEqual({
description: 'Updated details',
website: 'https://outdooractive.com/route/42',
source: 'gpx-import:source-42',
});
expect(testDb.prepare('SELECT wanderer_ref FROM tours WHERE place_id = ?').get(created.tour.place_id)).toEqual({
wanderer_ref: 'wanderer:trail-42',
});
});
it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', async () => {
const created = await service.createTour(tripId, request);
broadcast.mockReset();
+16
View File
@@ -69,6 +69,22 @@ const tours: TranslationStrings = {
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
'tours.durationMinutes.one': '{count} Min.',
'tours.planner.plannedDurationHint': 'Ganze Minuten, einschließlich Pausen und geplanter Aufenthalte (0–1.440).',
'tours.planner.plannedDurationInvalid': 'Ganze Minuten zwischen 0 und 1.440 eingeben.',
'tours.planner.breaksAdditionalInfo': 'Optionale ganze Minuten, die zur berechneten Gehzeit hinzukommen.',
'tours.planner.breaksAdditionalInfoLabel': 'Informationen zu Pausen und zusätzlicher Zeit',
'tours.planner.plannedTotalDuration': 'Geplante Gesamtdauer',
'tours.planner.breaksAdditional': 'Pausen / zusätzliche Zeit',
'tours.planner.breaksAdditionalHint': 'Optionale ganze Minuten zusätzlich zur berechneten Gehzeit.',
'tours.planner.plannedTotalAutomatic': 'Automatisch berechnet',
'tours.planner.plannedTotalManual': 'Manuell angepasst',
'tours.planner.timeValueInvalid': 'Ganze Minuten von 0 bis 1.440 eingeben.',
'tours.planner.walkingTimeUnknown': 'Noch nicht berechnet',
'tours.planner.walkingShort': 'Gehzeit',
'tours.planner.breaksShort': 'Pausen',
'tours.planner.plannedShort': 'Gesamt',
'tours.planner.plannedTotalInfo': 'Berechnete geplante Gesamtdauer.',
'tours.planner.plannedTotalInfoLabel': 'Informationen zur geplanten Gesamtdauer',
'tours.planner.empty': 'Setze mindestens zwei Punkte, um eine Wanderroute zu berechnen.',
'tours.planner.start': 'Start',
'tours.planner.via': 'Zwischenstopp',
+16
View File
@@ -68,6 +68,22 @@ const tours: TranslationStrings = {
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
'tours.durationMinutes.one': '{count} min',
'tours.planner.plannedTotalDuration': 'Planned total duration',
'tours.planner.plannedDurationHint': 'Whole minutes, including breaks and planned stays (0–1,440).',
'tours.planner.plannedDurationInvalid': 'Enter a whole duration from 0 to 1,440 minutes.',
'tours.planner.breaksAdditional': 'Breaks / additional time',
'tours.planner.breaksAdditionalHint': 'Optional whole minutes added to calculated walking time.',
'tours.planner.breaksAdditionalInfo': 'Optional whole minutes added to calculated walking time.',
'tours.planner.breaksAdditionalInfoLabel': 'About breaks and additional time',
'tours.planner.plannedTotalInfo': 'Calculated planned total duration.',
'tours.planner.plannedTotalInfoLabel': 'About planned total duration',
'tours.planner.plannedTotalAutomatic': 'Calculated total',
'tours.planner.plannedTotalManual': 'Manual override',
'tours.planner.timeValueInvalid': 'Enter a whole value from 0 to 1,440 minutes.',
'tours.planner.walkingTimeUnknown': 'Not calculated',
'tours.planner.walkingShort': 'Walk',
'tours.planner.breaksShort': 'Breaks',
'tours.planner.plannedShort': 'Planned',
'tours.planner.empty': 'Add at least two points to calculate a walking route.',
'tours.planner.start': 'Start',
'tours.planner.via': 'Via',
+94 -1
View File
@@ -1,4 +1,10 @@
import { tourCreateRequestSchema, tourMaxHikingDifficultySchema } from './tours.schema';
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTourDurationMinutesSchema,
tourCreateRequestSchema,
tourMaxHikingDifficultySchema,
tourWebsiteSchema,
} from './tours.schema';
import { describe, expect, it } from 'vitest';
@@ -28,3 +34,90 @@ describe('Tours maximum hiking difficulty', () => {
}
});
});
describe('Tour planned total duration', () => {
it('allows an empty value and bounded whole minutes independently of route duration', () => {
expect(plannedTourDurationMinutesSchema.parse(null)).toBeNull();
expect(plannedTourDurationMinutesSchema.parse(0)).toBe(0);
expect(plannedTourDurationMinutesSchema.parse(MAX_PLANNED_TOUR_DURATION_MINUTES)).toBe(
MAX_PLANNED_TOUR_DURATION_MINUTES,
);
expect(
tourCreateRequestSchema.parse({ ...baseRequest, duration_seconds: 1800, planned_duration_minutes: 95 }),
).toMatchObject({
duration_seconds: 1800,
planned_duration_minutes: 95,
});
expect(plannedTourDurationMinutesSchema.parse(0)).toBe(0);
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: 35 })).toMatchObject({
break_additional_minutes: 35,
});
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: 0 })).toMatchObject({
break_additional_minutes: 0,
});
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: null })).toMatchObject({
break_additional_minutes: null,
});
});
it.each([-1, 1441, 1.5, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid planned minutes %s', (value) => {
expect(() => plannedTourDurationMinutesSchema.parse(value)).toThrow();
});
it('rejects automatic walking plus breaks beyond one day but accepts an explicit bounded override', () => {
expect(() =>
tourCreateRequestSchema.parse({
...baseRequest,
duration_seconds: 1430 * 60,
break_additional_minutes: 11,
}),
).toThrow();
expect(
tourCreateRequestSchema.parse({
...baseRequest,
duration_seconds: 1430 * 60,
break_additional_minutes: 11,
planned_duration_minutes: 1200,
}),
).toMatchObject({ planned_duration_minutes: 1200 });
});
it.each([-1, 1.5, 1441, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid breaks %s', (value) => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
});
});
describe('Tour informational website', () => {
it('accepts and normalizes a credential-free HTTPS URL', () => {
expect(tourWebsiteSchema.parse(' HTTPS://WWW.KOMOOT.COM/tour/42 ')).toBe('https://www.komoot.com/tour/42');
});
it.each([
'http://komoot.com/tour/42',
'javascript:alert(1)',
'data:text/plain,hello',
'file:///tmp/route.gpx',
'not a url',
'https://user:password@komoot.com/tour/42',
'https://user@komoot.com/tour/42',
])('rejects unsafe or malformed website %s', (value) => {
expect(() => tourWebsiteSchema.parse(value)).toThrow();
});
it('carries description and website in a Tour create request without making them facet fields', () => {
const parsed = tourCreateRequestSchema.parse({
...baseRequest,
description: ' A quiet ridge walk ',
website: 'https://alltrails.com/trail/42',
});
expect(parsed).toMatchObject({
description: 'A quiet ridge walk',
website: 'https://alltrails.com/trail/42',
});
expect(parsed).not.toHaveProperty('source_url');
expect(parsed).not.toHaveProperty('source_ref');
});
it.each([-1, 1.5, 1441, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid breaks %s', (value) => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
});
});
+51
View File
@@ -18,6 +18,37 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6);
export type TourMaxHikingDifficulty = z.infer<typeof tourMaxHikingDifficultySchema>;
/** Tours are single-day activities, so a planned total is bounded to 24 hours. */
export const MAX_PLANNED_TOUR_DURATION_MINUTES = 24 * 60;
export const plannedTourDurationMinutesSchema = z
.number()
.int()
.min(0)
.max(MAX_PLANNED_TOUR_DURATION_MINUTES)
.nullable();
export const tourWebsiteSchema = z
.string()
.trim()
.max(500)
.url()
.superRefine((value, ctx) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid URL' });
return;
}
if (parsed.protocol !== 'https:') {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be an HTTPS URL' });
}
if (parsed.username || parsed.password) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must not contain URL credentials' });
}
})
.transform((value) => new URL(value).toString());
export const tourTypeSchema = z.object({
key: tourTypeKeySchema,
label_key: z.string(),
@@ -38,6 +69,8 @@ export const tourSchema = z.object({
elevation_gain: z.number().nullable(),
elevation_loss: z.number().nullable(),
duration: z.number().nullable(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
difficulty: z.string().nullable(),
// Nullable reference to a tour in wanderer.
wanderer_ref: z.string().nullable(),
@@ -62,6 +95,8 @@ export type TourTypeListResponse = z.infer<typeof tourTypeListResponseSchema>;
*/
export const tourListItemSchema = tourSchema.extend({
name: z.string(),
description: z.string().nullable().optional(),
website: z.string().nullable().optional(),
planned: z.boolean(),
caution: z.boolean(),
/** Read-model provenance signal: planner-authored tours persist routing controls; GPX imports do not. */
@@ -99,8 +134,24 @@ export const tourCreateRequestSchema = z
waypoints: z.array(tourWaypointSchema).min(2),
max_hiking_difficulty: tourMaxHikingDifficultySchema.default(2),
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
description: z.string().trim().max(2000).nullable().optional(),
website: tourWebsiteSchema.nullable().optional(),
})
.superRefine((value, ctx) => {
if (
value.planned_duration_minutes == null &&
value.duration_seconds != null &&
value.break_additional_minutes != null &&
Math.round(value.duration_seconds / 60) + value.break_additional_minutes > MAX_PLANNED_TOUR_DURATION_MINUTES
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['break_additional_minutes'],
message: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
});
}
value.waypoints.forEach((point, index) => {
const expectedRole: TourWaypointRole =
index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via';