refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline

This commit is contained in:
Maurice
2026-10-14 10:00:08 +02:00
parent 09227ec0d1
commit 7795061630
18 changed files with 1763 additions and 776 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"collection.ts": 35,
"common.ts": 7,
"dashboard.ts": 65,
"dawarich.ts": 16,
"dawarich.ts": 15,
"dayplan.ts": 18,
"docsync.ts": 25,
"files.ts": 4,
+4 -5
View File
@@ -5,14 +5,14 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1461,
"src/components/Map/MapView.tsx": 1453,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2425,
"src/components/Map/MapViewGL.tsx": 2414,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
"src/components/Planner/DayPlanSidebar.tsx": 2978,
"src/components/Planner/DayPlanSidebar.tsx": 2973,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
@@ -24,8 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1168,
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
"src/pages/TripPlannerPage.tsx": 1153,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+6 -66
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
@@ -6,7 +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 PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -758,70 +758,9 @@ const MemoMarker = memo(function MemoMarker({
)
})
function plannerWaypointIcon(index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26
return L.divIcon({
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:${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 })
useLayoutEffect(() => { handlersRef.current = { onClick, onMove } }, [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()
if (!element) return
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>
)
/** The look of a planner control point, drawn by PlannerWaypointMarker and kept beside the route blue. */
function plannerWaypointPinCss(size: number, selected: boolean): string {
return `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`
}
export const MapView = memo(function MapView({
@@ -1372,6 +1311,7 @@ export const MapView = memo(function MapView({
selected={point.id === selectedPlannerWaypointId}
onClick={onPlannerWaypointClick}
onMove={onPlannerWaypointMove}
pinCss={plannerWaypointPinCss}
/>
))}
@@ -0,0 +1,480 @@
import { act } from '@testing-library/react';
import maplibregl from 'maplibre-gl';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
import { useSettingsStore } from '../../store/settingsStore';
// The planner-waypoint and location-search camera cases of MapViewGL, with the same
// map doubles as MapViewGL.test.tsx.
// Stable fake map so fitBounds call counts survive re-renders. The canvas
// container is a single element so listeners registered by the component are
// reachable from tests via dispatchEvent.
const glCanvasContainer = vi.hoisted(() => document.createElement('div'));
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
// Every overlay that adds a layer takes it away again on cleanup; a real
// MapLibre/Mapbox map has both halves, and a double with only the adding half
// fails the teardown rather than the behaviour under test.
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
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([]),
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
// The MapLibre place pins position themselves with this, so a mock without it
// never exercises the path the real map takes.
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
easeTo: vi.fn(),
getCanvas: vi.fn(() => document.createElement('canvas')),
getBearing: vi.fn(() => 0),
setTerrain: vi.fn(),
setConfigProperty: vi.fn(),
queryTerrainElevation: vi.fn((): number | null => null),
}));
const glBounds = vi.hoisted(() => {
const state = {
instances: [] as Array<{ extend: ReturnType<typeof vi.fn> }>,
};
return {
get instances() {
return state.instances;
},
clear: () => {
state.instances = [];
},
create: () => {
const bounds = {
extend: vi.fn(() => bounds),
};
state.instances.push(bounds);
return bounds;
},
};
});
// Markers and popups are created through the mocked GL constructors, so the
// tests can reach the very instances the component built (their DOM element,
// the coordinates they were placed at, the popup content they were given).
interface FakeMarker {
element: HTMLElement;
lngLat: number[] | null;
setLngLat: (ll: number[]) => FakeMarker;
getLngLat: () => { lng: number; lat: number };
addTo: (map: unknown) => FakeMarker;
remove: () => void;
getElement: () => HTMLElement;
}
interface FakePopup {
setLngLat: (ll: unknown) => FakePopup;
setHTML: (html: string) => FakePopup;
setText: (text: string) => FakePopup;
setDOMContent: (node: HTMLElement) => FakePopup;
addTo: (map: unknown) => FakePopup;
remove: () => void;
}
const glMarkers = vi.hoisted(() => {
const created: FakeMarker[] = [];
return {
get created() {
return created;
},
clear: () => {
created.length = 0;
},
make: (opts?: { element?: HTMLElement }): FakeMarker => {
const element = opts?.element ?? document.createElement('div');
const marker: FakeMarker = {
element,
lngLat: null,
setLngLat: vi.fn((ll: number[]) => {
marker.lngLat = ll;
return marker;
}),
getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })),
addTo: vi.fn((_map: unknown) => marker),
remove: vi.fn(),
getElement: vi.fn(() => element),
};
created.push(marker);
return marker;
},
};
});
const glPopup = vi.hoisted((): FakePopup => {
const popup: FakePopup = {
setLngLat: vi.fn((_ll: unknown) => popup),
setHTML: vi.fn((_html: string) => popup),
setText: vi.fn((_text: string) => popup),
setDOMContent: vi.fn((_node: HTMLElement) => popup),
addTo: vi.fn((_map: unknown) => popup),
remove: vi.fn(),
};
return popup;
});
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
}));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}));
const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() }));
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => locationMarker),
}));
const reservationOverlay = vi.hoisted(() => ({
update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}),
destroy: vi.fn(),
}));
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () {
return reservationOverlay;
}),
}));
const geoStub = vi.hoisted(() => ({
position: null as GeoPosition | null,
mode: 'off' as TrackingMode,
error: null as string | null,
errorCode: null as string | null,
cycleMode: vi.fn(async () => {}),
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
}));
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => geoStub),
}));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}));
import mapboxgl from 'mapbox-gl';
import * as photoService from '../../services/photoService';
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
import * as mapboxSetup from './mapboxSetup';
// The engine is a prop, not a module import (see MapViewGL.test.tsx). This shim makes
// the same choice glLazy.tsx makes in production.
function MapViewGL(props: Omit<React.ComponentProps<typeof MapViewGLWithEngine>, 'gl'>) {
return <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
}
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);
glMap.once.mockImplementation(() => glMap);
glMap.loaded.mockReturnValue(true);
glMap.getSource.mockReturnValue(null);
glMap.getLayer.mockReturnValue(null);
glMap.queryRenderedFeatures.mockReturnValue([]);
glMap.querySourceFeatures.mockReturnValue([]);
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'));
glMap.getBearing.mockReturnValue(0);
glMap.queryTerrainElevation.mockReturnValue(null);
// A busy style, a refused source and a custom layer list are each set by one test
// for the case it covers. Put back here, or every test after it would silently run
// against a map that never settles.
glMap.isStyleLoaded.mockReturnValue(true);
glMap.getStyle.mockReturnValue({ layers: [] });
glMap.addSource.mockImplementation(() => undefined);
glMarkers.clear();
// clearAllMocks() wipes call history but keeps implementations, so anything a
// test overrides with mockReturnValue has to be put back here.
vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false);
vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false);
vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false);
vi.mocked(photoService.getCached).mockReturnValue(undefined);
vi.mocked(photoService.isLoading).mockReturnValue(false);
vi.mocked(photoService.onThumbReady).mockReturnValue(() => {});
vi.mocked(photoService.getAllThumbs).mockReturnValue({});
geoStub.position = null;
geoStub.mode = 'off';
geoStub.error = null;
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
});
});
afterEach(() => {
vi.clearAllMocks();
glBounds.clear();
resetAllStores();
});
describe('MapViewGL', () => {
function loadOnAttach() {
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)();
return glMap;
});
}
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 = vi.mocked(maplibregl.Map).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(vi.mocked(maplibregl.Map).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.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();
});
});
@@ -47,7 +47,6 @@ 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([]),
@@ -247,17 +246,6 @@ 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)
@@ -943,55 +931,6 @@ 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 = vi.mocked(maplibregl.Map).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(vi.mocked(maplibregl.Map).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(
@@ -1043,126 +982,6 @@ 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 })
+15 -51
View File
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
@@ -569,6 +569,11 @@ export function brandLogoMarkup(_brandWikidata?: string | null): string {
return ''
}
/** The look of a planner control point, drawn by usePlannerWaypointMarkersGL. */
function plannerWaypointPinCss(size: number, selected: boolean): string {
return `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`
}
export function MapViewGL({
places = NO_PLACES,
dayPlaces = NO_PLACES,
@@ -710,11 +715,6 @@ 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 })
useLayoutEffect(() => {
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
}, [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[]>([])
@@ -746,50 +746,14 @@ export function MapViewGL({
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
useEffect(() => {
const map = mapRef.current
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
const size = selected ? 30 : 26
const el = document.createElement('button')
el.type = 'button'
el.textContent = String(index + 1)
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(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
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, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId])
const attachPlannerWaypointPin = useCallback(
(el: HTMLElement, lng: number, lat: number) => attachPin(mapRef.current, gl, pinLayerRef.current, el, lng, lat),
[gl],
)
usePlannerWaypointMarkersGL({
mapRef, mapReady, pinsRef: plannerWaypointPinsRef, attachPin: attachPlannerWaypointPin, pinCss: plannerWaypointPinCss,
plannerWaypoints, selectedPlannerWaypointId, onPlannerWaypointClick, onPlannerWaypointMove,
})
useEffect(() => () => {
profileFocusPinRef.current?.remove()
@@ -0,0 +1,84 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26;
return L.divIcon({
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
});
}
export default function PlannerWaypointMarker({
point,
index,
selected,
onClick,
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number };
index: number;
selected: boolean;
onClick?: (id: string) => void;
onMove?: (id: string, lat: number, lng: number) => boolean;
pinCss: PinCss;
}) {
const map = useMap();
const [marker, setMarker] = useState<L.Marker | null>(null);
const handlersRef = useRef({ onClick, onMove });
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, 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();
if (!element) return;
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>
);
}
@@ -0,0 +1,115 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { MapViewProps } from './mapViewContract';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
interface PlannerWaypointMap {
getContainer: () => HTMLElement;
unproject: (point: [number, number]) => { lat: number; lng: number };
}
/** The slice of a hand-positioned pin the waypoint markers drive. */
interface PlannerWaypointPin {
remove: () => void;
setLngLat: (value: [number, number]) => void;
}
interface PlannerWaypointMarkersOptions<Pin extends PlannerWaypointPin> {
mapRef: { readonly current: PlannerWaypointMap | null };
mapReady: boolean;
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
pinsRef: { current: Pin[] };
/** Puts a waypoint element on the map at the given position. */
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
/** The pin's inline style for a size and selection; the renderer owns the colour. */
pinCss: (size: number, selected: boolean) => string;
plannerWaypoints: NonNullable<MapViewProps['plannerWaypoints']>;
selectedPlannerWaypointId: string | null;
onPlannerWaypointClick: MapViewProps['onPlannerWaypointClick'];
onPlannerWaypointMove: MapViewProps['onPlannerWaypointMove'];
}
/**
* The numbered control points of a list-first route editor, drawn as pins on the GL map.
*
* The handlers are read through a ref so a parent re-rendering with fresh callbacks does
* not tear the pins down mid-drag; only a change in whether they can be dragged does.
*/
export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
mapRef,
mapReady,
pinsRef,
attachPin,
pinCss,
plannerWaypoints,
selectedPlannerWaypointId,
onPlannerWaypointClick,
onPlannerWaypointMove,
}: PlannerWaypointMarkersOptions<Pin>): void {
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove });
useLayoutEffect(() => {
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove };
}, [onPlannerWaypointClick, onPlannerWaypointMove]);
const plannerWaypointsDraggable = !!onPlannerWaypointMove;
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
const dragCleanups: (() => void)[] = [];
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
const size = selected ? 30 : 26;
const el = document.createElement('button');
el.type = 'button';
el.textContent = String(index + 1);
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
const swallow = (event: Event) => event.stopPropagation();
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);
const pin = attachPin(el, point.lng, point.lat);
pinsRef.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());
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
};
}, [
attachPin,
mapReady,
mapRef,
pinCss,
pinsRef,
plannerWaypoints,
plannerWaypointsDraggable,
selectedPlannerWaypointId,
]);
}
@@ -0,0 +1,108 @@
import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import TourDayRowFacts from '../Tours/TourDayRowFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
interface DayPlanPlaceSublineProps {
place: Place;
cat: Category | undefined;
tourTimes: DayRowTourTimes | undefined;
isTourPlace: boolean;
timeFormat: string;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
if (!tourTimes) return false;
return tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null;
}
// Walking, break and planned-total pills of a tour stop that has no tour row of its own.
function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
const { t } = useTranslation();
const manual = tourTimes.manuallyOverridden;
return (
<>
{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>
)}
{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>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span
data-testid="trip-plan-tour-planned-duration"
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
>
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
</>
);
}
// Description line; tour stops get two clamped lines instead of one.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
<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]}>{text}</Markdown>
</div>
);
}
// Second line of a place row in the day plan: time, tour facts or planned
// tour durations, then the clamped description.
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
const description = place.description || place.address || cat?.name;
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
return (
<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} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
}
@@ -1534,58 +1534,6 @@ 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 }] })
@@ -0,0 +1,244 @@
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
// and that it falls back to the plain Place row when it is off. The mocks are the
// ones DayPlanSidebar.test.tsx renders the sidebar with.
import { http, HttpResponse } from 'msw';
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import DayPlanSidebar from './DayPlanSidebar';
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
const mockDayNotesState = vi.hoisted(() => ({
noteUi: {} as Record<string, unknown>,
dayNotes: {} as Record<string, unknown[]>,
setNoteUi: vi.fn(),
noteInputRef: { current: null } as { current: null },
openAddNote: vi.fn(),
openEditNote: vi.fn(),
cancelNote: vi.fn(),
saveNote: vi.fn(),
deleteNote: vi.fn(),
moveNote: vi.fn(),
}));
// ── Module mocks ────────────────────────────────────────────────────────────
vi.mock('../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/client')>();
return {
...actual,
assignmentsApi: {
reorder: vi.fn().mockResolvedValue({}),
remove: vi.fn().mockResolvedValue({}),
updateTime: vi.fn().mockResolvedValue({}),
updateTransport: vi.fn().mockResolvedValue({}),
},
reservationsApi: {
list: vi.fn().mockResolvedValue({ reservations: [] }),
updatePositions: vi.fn().mockResolvedValue({}),
},
daysApi: {
...actual.daysApi,
updateTransport: vi.fn().mockResolvedValue({}),
},
};
});
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../Map/RouteCalculator', () => ({
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
Promise.resolve({
distanceText: '2 km',
durationText: '10 min',
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
distanceText: '2 km',
durationText: '10 min',
drivingText: '10 min',
walkingText: '25 min',
})),
})
),
}));
// PlaceAvatar needs IntersectionObserver
class MockIO {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeAll(() => {
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIO;
});
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
}));
vi.mock('../../hooks/useDayNotes', () => ({
useDayNotes: () => mockDayNotesState,
}));
vi.mock('../Weather/WeatherWidget', () => ({
default: (props: { locationName?: string | null }) => (
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
),
}));
// A stable toast object so tests can assert on the messages the sidebar raises.
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({
useToast: () => mockToast,
}));
vi.mock('../../store/permissionsStore', async (importOriginal) => ({
...(await importOriginal<typeof import('../../store/permissionsStore')>()),
useCanDo: () => () => true,
}));
const trip = buildTrip({ id: 1, currency: 'EUR' });
function makeDefaultProps(overrides = {}) {
return {
tripId: 1,
trip,
days: [],
places: [],
categories: [],
assignments: {},
selectedDayId: null,
selectedPlaceId: null,
selectedAssignmentId: null,
onSelectDay: vi.fn(),
onPlaceClick: vi.fn(),
onDayDetail: vi.fn(),
accommodations: [],
onReorder: vi.fn(),
onUpdateDayTitle: vi.fn(),
onRouteCalculated: vi.fn(),
onAssignToDay: vi.fn(),
onRemoveAssignment: vi.fn(),
onEditPlace: vi.fn(),
onDeletePlace: vi.fn(),
reservations: [],
onAddReservation: vi.fn(),
onNavigateToFiles: vi.fn(),
...overrides,
};
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
sessionStorage.clear();
localStorage.clear();
clearExchangeRateCache();
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])));
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as never);
});
describe('DayPlanSidebar', () => {
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();
});
});
@@ -80,9 +80,10 @@ 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 { TourPlannedTimes } from '../Tours/tourPresentation'
import type { TourListItem } from '@trek/shared'
import TourDayRowFacts, { TourDayRowIcon } from '../Tours/TourDayRowFacts'
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
export interface DayPlanSidebarProps {
tripId: number
@@ -2034,42 +2035,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
{(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 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>
)}
</div>
)}
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
@@ -0,0 +1,250 @@
// The TOUR-PLANNER location search, wired through the planner page.
//
// The same harness as TripPlannerPage.wiring.test.tsx: the hook is replaced by
// the shared fixture (TripPlannerPage.wiring.fixture.ts) and every child the
// page mounts is a stub that records its props, so the map's focus and the
// planner callbacks can be read and driven directly.
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import React from 'react';
import { buildUser } from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server';
import { act, 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 { useTripStore } from '../store/tripStore';
import TripPlannerPage from './TripPlannerPage';
import { baseState, type HookState } from './TripPlannerPage.wiring.fixture';
// ── Component stubs ───────────────────────────────────────────────────────────
// Each stub records the props it was rendered with so the page's inline
// callbacks can be invoked from the test.
type Props = Record<string, unknown>;
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),
saveOutcome: null as unknown,
mapFocusKey: 0,
newDraftGeneration: 0,
mode: { type: 'neutral' } as unknown,
isSaving: false,
waypoints: [] as unknown,
routeProfileFocus: null as unknown,
readOnlyGpxTour: null as unknown,
readOnlyGpxAnalysis: null as unknown,
}));
function stub(name: string, testId?: string) {
return (props: Props) => {
captured[name] = props;
return testId ? React.createElement('div', { 'data-testid': testId }) : null;
};
}
type AnyProp = ((...args: unknown[]) => unknown) & Record<string, unknown>;
/** The last-rendered props of a stub, typed loosely so callbacks can be invoked. */
function props(name: string): Record<string, AnyProp> {
return (captured[name] ?? {}) as Record<string, AnyProp>;
}
vi.mock('../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') }));
vi.mock('../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') }));
const poiCategories = vi.hoisted(() => ({ core: [], plugin: [] }));
vi.mock('../components/Map/PoiCategoryPill', () => ({ default: stub('poiPill', 'poi-pill') }));
vi.mock('../components/Map/usePoiExplore', () => ({
usePoiExplore: () => ({
categories: poiCategories,
active: [],
pois: [],
loadingKeys: [],
errorKeys: [],
moved: false,
toggle: vi.fn(),
searchArea: vi.fn(),
onViewportChange: vi.fn(),
}),
}));
vi.mock('../components/Layout/Navbar', () => ({ default: stub('navbar', 'navbar') }));
vi.mock('../components/shared/SlidingTabs', () => ({ default: stub('tabs', 'sliding-tabs'), TabLabel: () => null }));
vi.mock('../components/shared/TripLoadingSplash', () => ({
default: ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'splash', role: 'status' }, title ?? 'TREK'),
}));
vi.mock('../components/shared/ConfirmDialog', () => ({ default: () => null }));
vi.mock('../components/Planner/DayPlanSidebar', () => ({ default: stub('dayPlan', 'day-plan-sidebar') }));
vi.mock('../components/Planner/PlacesSidebar', () => ({ default: stub('places', 'places-sidebar') }));
vi.mock('../components/Planner/PlaceInspector', () => ({ default: stub('inspector', 'place-inspector') }));
vi.mock('../components/Planner/DayDetailPanel', () => ({ default: stub('dayDetail', 'day-detail-panel') }));
vi.mock('../components/Planner/PlaceFormModal', () => ({ default: stub('placeForm') }));
vi.mock('../components/Planner/ReservationModal', () => ({ ReservationModal: stub('reservationModal') }));
vi.mock('../components/Planner/TransportModal', () => ({ TransportModal: stub('transportModal', 'transport-modal') }));
vi.mock('../components/Planner/TransitJourneyModal', () => ({ default: stub('transitModal', 'transit-modal') }));
vi.mock('../components/Planner/bookings/BookingDetailHost', () => ({
BookingDetailPopup: stub('bookingDetail', 'booking-detail'),
}));
vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('bookingImport') }));
vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('airtrailImport') }));
vi.mock('../components/Planner/ReservationsPanel', () => ({
default: stub('reservationsPanel', 'reservations-panel'),
}));
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }));
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }));
vi.mock('../components/Tours/planner/useTourPlanner', () => ({
useTourPlanner: () => ({
route: null,
waypoints: plannerActions.waypoints,
routeProfileFocus: plannerActions.routeProfileFocus,
name: '',
startNewTour: plannerActions.startNewTour,
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
elevationProfileExpanded: true,
toggleElevationProfile: vi.fn(),
newDraftGeneration: plannerActions.newDraftGeneration,
mapFocusKey: plannerActions.mapFocusKey,
mode: plannerActions.mode,
isSaving: plannerActions.isSaving,
selectedWaypointId: null,
editingPlaceId: null,
saveOutcome: plannerActions.saveOutcome,
hasUnsavedChanges: false,
setSelectedWaypointId: vi.fn(),
mapBaseLayer: 'map',
setMapBaseLayer: vi.fn(),
openTour: plannerActions.openTour,
viewGpxTour: plannerActions.viewGpxTour,
addWaypoint: plannerActions.addWaypoint,
setWaypointPosition: plannerActions.setWaypointPosition,
closeGpxTour: vi.fn(),
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
discard: vi.fn(),
forgetDeletedTour: plannerActions.forgetDeletedTour,
}),
}));
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }));
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }));
vi.mock('../components/Packing/PackingListPanel', () => ({ default: stub('packingPanel', 'packing-list-panel') }));
vi.mock('../components/Packing/ApplyTemplateButton', () => ({ default: stub('applyTemplate', 'apply-template') }));
vi.mock('../components/Packing/PackingExportMenu', () => ({ default: stub('exportMenu', 'export-menu') }));
vi.mock('../components/Todo/TodoListPanel', () => ({ default: stub('todoPanel', 'todo-list-panel') }));
vi.mock('../components/Files/FileManager', () => ({ default: stub('fileManager', 'file-manager') }));
vi.mock('../components/Budget/CostsPanel', () => ({
default: stub('costsPanel', 'costs-panel'),
ExpenseModal: stub('expenseModal', 'expense-modal'),
}));
vi.mock('../components/Collab/CollabPanel', () => ({ default: stub('collabPanel', 'collab-panel') }));
vi.mock('../components/Plugins/PluginFrame', () => ({ default: stub('pluginFrame', 'plugin-frame') }));
// ── useTripPlanner fixture ────────────────────────────────────────────────────
let hookState: HookState;
vi.mock('./tripPlanner/useTripPlanner', () => ({
useTripPlanner: () => hookState,
}));
function renderPage(overrides: HookState = {}) {
hookState = { ...hookState, ...overrides };
return render(<TripPlannerPage />);
}
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
for (const key of Object.keys(captured)) delete captured[key];
plannerActions.viewGpxTour.mockReset();
plannerActions.addWaypoint.mockReset();
plannerActions.setWaypointPosition.mockReset().mockReturnValue(true);
plannerActions.openTour.mockReset();
plannerActions.startNewTour.mockReset();
plannerActions.forgetDeletedTour.mockReset();
plannerActions.saveOutcome = null;
plannerActions.mapFocusKey = 0;
plannerActions.newDraftGeneration = 0;
plannerActions.mode = { type: 'neutral' };
plannerActions.isSaving = false;
plannerActions.waypoints = [];
plannerActions.routeProfileFocus = null;
plannerActions.readOnlyGpxTour = null;
plannerActions.readOnlyGpxAnalysis = null;
hookState = baseState();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) });
useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never);
});
describe('TripPlannerPage — shell', () => {
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();
});
});
+6 -45
View File
@@ -2,15 +2,12 @@ import React, { useState, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useSettingsStore } from '../store/settingsStore'
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'
@@ -57,6 +54,7 @@ import {
import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer'
import { PlanMapStage } from './tripPlanner/PlanMapStage'
import { TourPlannerMapStage } from './tripPlanner/TourPlannerMapStage'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
import PanelResizeHandle from '../components/Planner/PanelResizeHandle'
@@ -149,8 +147,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tourDetails,
tourMap,
locationSearch,
plannedTourDurations,
} = 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.
@@ -640,48 +638,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && (
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={canPlaceEdit && !tourPlanner.isSaving && (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
? ({ latlng }: { latlng: { lat: number; lng: number } }) => tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
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}
<TourPlannerMapStage
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
/>
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
<div style={{ position: 'absolute', top: 12, insetInline: 0, display: 'flex', justifyContent: 'center', pointerEvents: 'none', zIndex: 19 }}>
<div
style={{
transform: `translateX(${((leftHidden ? 56 : leftPanelPx) - (rightHidden ? 56 : rightPanelPx)) / 2}px)`,
width: `min(360px, calc(100vw - ${(leftHidden ? 56 : leftPanelPx) + (rightHidden ? 56 : rightPanelPx) + 32}px))`,
pointerEvents: 'auto',
}}
>
<LocationSelect
value={locationSearch.value}
onChange={locationSearch.onChange}
placeholder={t('reservations.searchLocation')}
ariaLabel={t('reservations.searchLocation')}
showSearchStatus
style={{ width: '100%' }}
/>
</div>
</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={{
@@ -0,0 +1,330 @@
// The useTripPlanner fixture the TripPlannerPage wiring tests render against:
// every field the page reads, set to the plain Plan-tab state, so a test only
// overrides what it is about. Shared by TripPlannerPage.wiring.test.tsx and
// TripPlannerPage.tours.wiring.test.tsx.
import { vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../tests/helpers/factories';
import type { Day, Place } from '../types';
export const trip = buildTrip({ id: 42, title: 'Kyoto', currency: 'JPY' });
export const day: Day = buildDay({ id: 7, trip_id: 42 });
export const place: Place = buildPlace({ id: 1, name: 'Kiyomizu', lat: 34.9, lng: 135.7 });
export type HookState = Record<string, unknown>;
/** The plain Plan-tab state of every field the page reads from useTripPlanner. */
export function baseState(): HookState {
return { ...tripDataState(), ...roadtripState(), ...layoutState(), ...dialogState(), ...mapState() };
}
/** The trip, its data and the rights and addons around it. */
function tripDataState(): HookState {
return {
tripId: 42,
navigate: vi.fn(),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
t: (key: string) => key,
language: 'en',
placesPhotosEnabled: true,
trip,
days: [day],
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 0 })] },
packingItems: [],
todoItems: [],
categories: [],
reservations: [],
budgetItems: [],
files: [],
selectedDayId: 7,
isLoading: false,
tripActions: {
addFile: vi.fn(async () => undefined),
deleteFile: vi.fn(async () => undefined),
loadFiles: vi.fn(async () => undefined),
updatePlace: vi.fn(async () => undefined),
uploadPlaceImage: vi.fn(async () => undefined),
ratePlace: vi.fn(async () => undefined),
updateReservation: vi.fn(async () => undefined),
updateTrip: vi.fn(async () => undefined),
setSelectedDay: vi.fn(() => undefined),
addCategory: vi.fn(async () => undefined),
},
can: vi.fn(() => true),
canUploadFiles: true,
pushUndo: vi.fn(() => undefined),
undo: vi.fn(async () => undefined),
canUndo: false,
lastActionLabel: null,
handleUndo: vi.fn(async () => undefined),
enabledAddons: { packing: true, budget: true, documents: true, collab: true },
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
tripAccommodations: [],
setTripAccommodations: vi.fn(),
};
}
function roadtripState(): HookState {
return {
// Road trip mode off, which is the state every wiring test reads: the page keeps
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
roadtripMode: false,
toggleRoadtripMode: vi.fn(),
roadtripActive: false,
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
roadtripCorridor: {
visible: [],
day: null,
dayId: '',
setDayId: vi.fn(),
categories: [],
toggleCategory: vi.fn(),
widthKm: 5,
setWidthKm: vi.fn(),
search: vi.fn(),
nameFilter: '',
setNameFilter: vi.fn(),
insertIndexFor: vi.fn(),
},
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: {
dayId: null,
open: vi.fn(),
close: vi.fn(),
tracks: [],
busy: false,
round: 0,
error: null,
outcome: null,
apply: vi.fn(),
clear: vi.fn(),
viaCount: 0,
available: false,
},
// The recorded-route overlay (#2279). Off and empty: the page reads
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
// shape the hook returns even when the addon is not in play.
dawarichEnabled: false,
dawarichTrailShown: false,
toggleDawarichTrail: vi.fn(),
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
roadtripViaCounts: {},
stopDraft: null,
setStopDraft: vi.fn(),
saveStopDraft: vi.fn(async () => undefined),
stopDraftToForm: vi.fn(),
stopDraftDuplicate: null,
reorderRoadtripStop: vi.fn(async () => undefined),
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
addRoadtripVia: vi.fn(async () => undefined),
moveRoadtripVia: vi.fn(async () => undefined),
removeRoadtripVia: vi.fn(async () => undefined),
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
askRouteAlternatives: vi.fn(async () => undefined),
chooseRouteAlternative: vi.fn(async () => undefined),
alternativeOverlays: [],
alternativeFocusPoints: [],
stayDraft: null,
setStayDraft: vi.fn(),
setRoadtripStay: vi.fn(async () => undefined),
highlightedAlternative: null,
setHighlightedAlternative: vi.fn(),
moveRoadtripStopToDay: vi.fn(async () => undefined),
dropPoiOnRoute: vi.fn(async () => undefined),
};
}
/** Tabs, side panels and the selection. */
function layoutState(): HookState {
return {
allowedFileTypes: 'pdf',
tripMembers: [],
setTripMembers: vi.fn(),
refreshMembers: vi.fn(),
loadAccommodations: vi.fn(),
TRANSPORT_TYPES: new Set(['flight', 'train']),
TRIP_TABS: [
{ id: 'plan', label: 'Plan' },
{ id: 'transports', label: 'Transports' },
{ id: 'buchungen', label: 'Bookings', shortLabel: 'Book' },
{ id: 'listen', label: 'Lists' },
{ id: 'finanzplan', label: 'Costs' },
{ id: 'dateien', label: 'Files' },
{ id: 'collab', label: 'Collab' },
],
activeTab: 'plan',
setActiveTab: vi.fn(),
handleTabChange: vi.fn(),
leftWidth: 320,
rightWidth: 320,
leftCollapsed: false,
rightCollapsed: false,
setLeftCollapsed: vi.fn(),
setRightCollapsed: vi.fn(),
leftHidden: false,
rightHidden: false,
toggleLeft: vi.fn(),
toggleRight: vi.fn(),
narrowPanels: false,
startResizeLeft: vi.fn(),
startResizeRight: vi.fn(),
nudgeLeft: vi.fn(),
nudgeRight: vi.fn(),
resizeMin: 200,
resizeMax: 520,
selectedPlaceId: null,
selectedAssignmentId: null,
setSelectedPlaceId: vi.fn(),
selectAssignment: vi.fn(),
showDayDetail: null,
setShowDayDetail: vi.fn(),
dayDetailCollapsed: false,
setDayDetailCollapsed: vi.fn(),
showPlaceForm: false,
setShowPlaceForm: vi.fn(),
setPlaceFormDayId: vi.fn(),
editingPlace: null,
setEditingPlace: vi.fn(),
prefillCoords: null,
setPrefillCoords: vi.fn(),
editingAssignmentId: null,
setEditingAssignmentId: vi.fn(),
serviceStopMode: null,
setServiceStopForm: vi.fn(),
openManualRoadtripStop: vi.fn(),
};
}
/** The modals, the booking flows and the delete confirmations. */
function dialogState(): HookState {
return {
showTripForm: false,
setShowTripForm: vi.fn(),
showMembersModal: false,
setShowMembersModal: vi.fn(),
showReservationModal: false,
setShowReservationModal: vi.fn(),
editingReservation: null,
setEditingReservation: vi.fn(),
showBookingImport: false,
setShowBookingImport: vi.fn(),
bookingImportKind: 'bookings' as const,
setBookingImportKind: vi.fn(),
bookingImportAvailable: true,
airTrailAvailable: true,
showAirTrailImport: false,
setShowAirTrailImport: vi.fn(),
bookingForAssignmentId: null,
setBookingForAssignmentId: vi.fn(),
showTransportModal: false,
setShowTransportModal: vi.fn(),
editingTransport: null,
setEditingTransport: vi.fn(),
transportModalDayId: null,
setTransportModalDayId: vi.fn(),
transportModalAutomated: false,
setTransportModalAutomated: vi.fn(),
transitPrefill: null,
setTransitPrefill: vi.fn(),
transitJourney: null,
setTransitJourney: vi.fn(),
openTransportEditor: vi.fn(),
changeTransitRoute: vi.fn(),
bookingDetail: null,
openBookingDetail: vi.fn(),
openBookingFromDayList: vi.fn(),
closeBookingDetail: vi.fn(),
bookingDetailEditor: undefined,
bookingDetailChangeRoute: undefined,
showBookingOnMap: vi.fn(),
isBookingOnMap: vi.fn(() => false),
reservationPrefill: null,
transportPrefill: null,
importReviewActive: false,
advanceImportReview: vi.fn(),
routeShown: false,
setRouteShown: vi.fn(),
transitRoutesShown: false,
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
fitKey: 0,
setFitKey: vi.fn(),
mobileSidebarOpen: null,
setMobileSidebarOpen: vi.fn(),
mobilePlanScrollTopRef: { current: 0 },
mobilePlacesScrollTopRef: { current: 0 },
deletePlaceId: null,
setDeletePlaceId: vi.fn(),
deletePlaceIds: null,
setDeletePlaceIds: vi.fn(),
stayRelease: null,
setStayRelease: vi.fn(),
confirmStayRelease: vi.fn(async () => undefined),
visibleConnections: [],
toggleConnection: vi.fn(),
allConnectionsShown: false,
toggleAllConnections: vi.fn(),
mapTransportDetail: null,
setMapTransportDetail: vi.fn(),
};
}
/** The map, the route and the hook handlers, plus Tours. */
function mapState(): HookState {
return {
isMobile: false,
isTouch: false,
expandedDayIds: null,
setExpandedDayIds: vi.fn(),
mapPlaces: [place],
route: null,
routeSegments: [],
routeInfo: null,
setRoute: vi.fn(),
setRouteInfo: vi.fn(),
updateRouteForDay: vi.fn(),
handleSelectDay: vi.fn(),
handlePlaceClick: vi.fn(),
handleMarkerClick: vi.fn(),
handleMapClick: vi.fn(),
handleMapContextMenu: vi.fn(),
openAddPlaceFromPoi: vi.fn(),
handleSavePlace: vi.fn(async () => undefined),
openPlaceEditor: vi.fn(),
handleDeletePlace: vi.fn(),
handleDeleteTour: vi.fn(),
confirmDeletePlace: vi.fn(async () => undefined),
confirmDeletePlaces: vi.fn(async () => undefined),
confirmChangeCategory: vi.fn(async () => undefined),
handleAssignToDay: vi.fn(async () => undefined),
handleRemoveAssignment: vi.fn(async () => undefined),
handleReorder: vi.fn(),
handleReorderDays: vi.fn(),
handleAddDay: vi.fn(),
handleUpdateDayTitle: vi.fn(async () => undefined),
handleSaveReservation: vi.fn(async () => ({ id: 1 })),
handleSaveTransport: vi.fn(async () => ({ id: 2 })),
handleDeleteReservation: vi.fn(async () => undefined),
selectedPlace: null,
dayOrderMap: {},
dayPlaces: [],
toursEnabled: false,
toursMode: true,
setToursMode: vi.fn(),
tours: [],
toursLoading: false,
tourDataReady: true,
tourPlaceIds: new Set<number>(),
reloadTourPlaceIds: vi.fn(async () => undefined),
upsertTour: vi.fn(),
selectedTour: null,
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
fontStyle: { fontFamily: 'var(--font-system)' },
splashDone: true,
};
}
@@ -5,12 +5,9 @@
// 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 userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import React from 'react';
import {
buildAssignment,
buildDay,
buildPackingItem,
buildPlace,
buildReservation,
@@ -18,16 +15,16 @@ import {
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 type { Reservation, Settings } from '../types';
import { analyzeRouteGeometry } from '../utils/routeGeometry';
import TripPlannerPage from './TripPlannerPage';
import { baseState, day, place, trip, type HookState } from './TripPlannerPage.wiring.fixture';
// ── Component stubs ───────────────────────────────────────────────────────────
// Each stub records the props it was rendered with so the page's inline
@@ -165,277 +162,10 @@ vi.mock('../components/Budget/CostsPanel', () => ({
vi.mock('../components/Collab/CollabPanel', () => ({ default: stub('collabPanel', 'collab-panel') }))
vi.mock('../components/Plugins/PluginFrame', () => ({ default: stub('pluginFrame', 'plugin-frame') }))
// ── useTripPlanner fixture ────────────────────────────────────────────────────
// ── useTripPlanner fixture (TripPlannerPage.wiring.fixture.ts) ───────────────────
const trip = buildTrip({ id: 42, title: 'Kyoto', currency: 'JPY' })
const day: Day = buildDay({ id: 7, trip_id: 42 })
const place: Place = buildPlace({ id: 1, name: 'Kiyomizu', lat: 34.9, lng: 135.7 })
type HookState = Record<string, unknown>
let hookState: HookState
function baseState(): HookState {
return {
tripId: 42,
navigate: vi.fn(),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
t: (key: string) => key,
language: 'en',
placesPhotosEnabled: true,
trip,
days: [day],
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 0 })] },
packingItems: [],
todoItems: [],
categories: [],
reservations: [],
budgetItems: [],
files: [],
selectedDayId: 7,
isLoading: false,
tripActions: {
addFile: vi.fn(async () => undefined),
deleteFile: vi.fn(async () => undefined),
loadFiles: vi.fn(async () => undefined),
updatePlace: vi.fn(async () => undefined),
uploadPlaceImage: vi.fn(async () => undefined),
ratePlace: vi.fn(async () => undefined),
updateReservation: vi.fn(async () => undefined),
updateTrip: vi.fn(async () => undefined),
setSelectedDay: vi.fn(() => undefined),
addCategory: vi.fn(async () => undefined),
},
can: vi.fn(() => true),
canUploadFiles: true,
pushUndo: vi.fn(() => undefined),
undo: vi.fn(async () => undefined),
canUndo: false,
lastActionLabel: null,
handleUndo: vi.fn(async () => undefined),
enabledAddons: { packing: true, budget: true, documents: true, collab: true },
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
tripAccommodations: [],
setTripAccommodations: vi.fn(),
// Road trip mode off, which is the state every assertion below reads: the page keeps
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
roadtripMode: false,
toggleRoadtripMode: vi.fn(),
roadtripActive: false,
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
// The recorded-route overlay (#2279). Off and empty: the page reads
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
// shape the hook returns even when the addon is not in play.
dawarichEnabled: false,
dawarichTrailShown: false,
toggleDawarichTrail: vi.fn(),
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
roadtripViaCounts: {},
stopDraft: null,
setStopDraft: vi.fn(),
saveStopDraft: vi.fn(async () => undefined),
stopDraftToForm: vi.fn(),
stopDraftDuplicate: null,
reorderRoadtripStop: vi.fn(async () => undefined),
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
addRoadtripVia: vi.fn(async () => undefined),
moveRoadtripVia: vi.fn(async () => undefined),
removeRoadtripVia: vi.fn(async () => undefined),
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
askRouteAlternatives: vi.fn(async () => undefined),
chooseRouteAlternative: vi.fn(async () => undefined),
alternativeOverlays: [],
alternativeFocusPoints: [],
stayDraft: null,
setStayDraft: vi.fn(),
setRoadtripStay: vi.fn(async () => undefined),
highlightedAlternative: null,
setHighlightedAlternative: vi.fn(),
moveRoadtripStopToDay: vi.fn(async () => undefined),
dropPoiOnRoute: vi.fn(async () => undefined),
allowedFileTypes: 'pdf',
tripMembers: [],
setTripMembers: vi.fn(),
refreshMembers: vi.fn(),
loadAccommodations: vi.fn(),
TRANSPORT_TYPES: new Set(['flight', 'train']),
TRIP_TABS: [
{ id: 'plan', label: 'Plan' },
{ id: 'transports', label: 'Transports' },
{ id: 'buchungen', label: 'Bookings', shortLabel: 'Book' },
{ id: 'listen', label: 'Lists' },
{ id: 'finanzplan', label: 'Costs' },
{ id: 'dateien', label: 'Files' },
{ id: 'collab', label: 'Collab' },
],
activeTab: 'plan',
setActiveTab: vi.fn(),
handleTabChange: vi.fn(),
leftWidth: 320,
rightWidth: 320,
leftCollapsed: false,
rightCollapsed: false,
setLeftCollapsed: vi.fn(),
setRightCollapsed: vi.fn(),
leftHidden: false,
rightHidden: false,
toggleLeft: vi.fn(),
toggleRight: vi.fn(),
narrowPanels: false,
startResizeLeft: vi.fn(),
startResizeRight: vi.fn(),
nudgeLeft: vi.fn(),
nudgeRight: vi.fn(),
resizeMin: 200,
resizeMax: 520,
selectedPlaceId: null,
selectedAssignmentId: null,
setSelectedPlaceId: vi.fn(),
selectAssignment: vi.fn(),
showDayDetail: null,
setShowDayDetail: vi.fn(),
dayDetailCollapsed: false,
setDayDetailCollapsed: vi.fn(),
showPlaceForm: false,
setShowPlaceForm: vi.fn(),
setPlaceFormDayId: vi.fn(),
editingPlace: null,
setEditingPlace: vi.fn(),
prefillCoords: null,
setPrefillCoords: vi.fn(),
editingAssignmentId: null,
setEditingAssignmentId: vi.fn(),
serviceStopMode: null,
setServiceStopForm: vi.fn(),
openManualRoadtripStop: vi.fn(),
showTripForm: false,
setShowTripForm: vi.fn(),
showMembersModal: false,
setShowMembersModal: vi.fn(),
showReservationModal: false,
setShowReservationModal: vi.fn(),
editingReservation: null,
setEditingReservation: vi.fn(),
showBookingImport: false,
setShowBookingImport: vi.fn(),
bookingImportKind: 'bookings' as const,
setBookingImportKind: vi.fn(),
bookingImportAvailable: true,
airTrailAvailable: true,
showAirTrailImport: false,
setShowAirTrailImport: vi.fn(),
bookingForAssignmentId: null,
setBookingForAssignmentId: vi.fn(),
showTransportModal: false,
setShowTransportModal: vi.fn(),
editingTransport: null,
setEditingTransport: vi.fn(),
transportModalDayId: null,
setTransportModalDayId: vi.fn(),
transportModalAutomated: false,
setTransportModalAutomated: vi.fn(),
transitPrefill: null,
setTransitPrefill: vi.fn(),
transitJourney: null,
setTransitJourney: vi.fn(),
openTransportEditor: vi.fn(),
changeTransitRoute: vi.fn(),
bookingDetail: null,
openBookingDetail: vi.fn(),
openBookingFromDayList: vi.fn(),
closeBookingDetail: vi.fn(),
bookingDetailEditor: undefined,
bookingDetailChangeRoute: undefined,
showBookingOnMap: vi.fn(),
isBookingOnMap: vi.fn(() => false),
reservationPrefill: null,
transportPrefill: null,
importReviewActive: false,
advanceImportReview: vi.fn(),
routeShown: false,
setRouteShown: vi.fn(),
transitRoutesShown: false,
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
fitKey: 0,
setFitKey: vi.fn(),
mobileSidebarOpen: null,
setMobileSidebarOpen: vi.fn(),
mobilePlanScrollTopRef: { current: 0 },
mobilePlacesScrollTopRef: { current: 0 },
deletePlaceId: null,
setDeletePlaceId: vi.fn(),
deletePlaceIds: null,
setDeletePlaceIds: vi.fn(),
stayRelease: null,
setStayRelease: vi.fn(),
confirmStayRelease: vi.fn(async () => undefined),
visibleConnections: [],
toggleConnection: vi.fn(),
allConnectionsShown: false,
toggleAllConnections: vi.fn(),
mapTransportDetail: null,
setMapTransportDetail: vi.fn(),
isMobile: false,
isTouch: false,
expandedDayIds: null,
setExpandedDayIds: vi.fn(),
mapPlaces: [place],
route: null,
routeSegments: [],
routeInfo: null,
setRoute: vi.fn(),
setRouteInfo: vi.fn(),
updateRouteForDay: vi.fn(),
handleSelectDay: vi.fn(),
handlePlaceClick: vi.fn(),
handleMarkerClick: vi.fn(),
handleMapClick: vi.fn(),
handleMapContextMenu: vi.fn(),
openAddPlaceFromPoi: vi.fn(),
handleSavePlace: vi.fn(async () => undefined),
openPlaceEditor: vi.fn(),
handleDeletePlace: vi.fn(),
handleDeleteTour: vi.fn(),
confirmDeletePlace: vi.fn(async () => undefined),
confirmDeletePlaces: vi.fn(async () => undefined),
confirmChangeCategory: vi.fn(async () => undefined),
handleAssignToDay: vi.fn(async () => undefined),
handleRemoveAssignment: vi.fn(async () => undefined),
handleReorder: vi.fn(),
handleReorderDays: vi.fn(),
handleAddDay: vi.fn(),
handleUpdateDayTitle: vi.fn(async () => undefined),
handleSaveReservation: vi.fn(async () => ({ id: 1 })),
handleSaveTransport: vi.fn(async () => ({ id: 2 })),
handleDeleteReservation: vi.fn(async () => undefined),
selectedPlace: null,
dayOrderMap: {},
dayPlaces: [],
toursEnabled: false,
toursMode: true,
setToursMode: vi.fn(),
tours: [],
toursLoading: false,
tourDataReady: true,
tourPlaceIds: new Set<number>(),
reloadTourPlaceIds: vi.fn(async () => undefined),
upsertTour: vi.fn(),
selectedTour: null,
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
fontStyle: { fontFamily: 'var(--font-system)' },
splashDone: true,
}
}
vi.mock('./tripPlanner/useTripPlanner', () => ({
useTripPlanner: () => hookState,
}))
@@ -577,70 +307,6 @@ 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()
@@ -0,0 +1,107 @@
import type { ReactElement } from 'react';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import type { useTripPlanner } from './useTripPlanner';
import type { useTripPlannerPage } from './useTripPlannerPage';
type Planner = ReturnType<typeof useTripPlanner>;
type PageState = ReturnType<typeof useTripPlannerPage>;
export interface TourPlannerMapStageProps
extends
Pick<Planner, 'tripId' | 'mapTileUrl' | 'leftHidden' | 'rightHidden' | 't'>,
Pick<PageState, 'tourPlanner' | 'tourMap' | 'locationSearch'> {
canPlaceEdit: boolean;
leftPanelPx: number;
rightPanelPx: number;
}
/**
* The tour planner tab's map and the location search floating over it. It returns a
* fragment so both sit straight in the tab container, ahead of the side panels, with
* the stacking they had when this lived in the page.
*/
export function TourPlannerMapStage({
tripId,
mapTileUrl,
leftHidden,
rightHidden,
t,
tourPlanner,
tourMap,
locationSearch,
canPlaceEdit,
leftPanelPx,
rightPanelPx,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
return (
<>
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={
canPlaceEdit &&
!tourPlanner.isSaving &&
(tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
? ({ latlng }: { latlng: { lat: number; lng: number } }) => tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined
}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
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}
/>
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
<div
style={{
position: 'absolute',
top: 12,
insetInline: 0,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 19,
}}
>
<div
style={{
transform: `translateX(${((leftHidden ? 56 : leftPanelPx) - (rightHidden ? 56 : rightPanelPx)) / 2}px)`,
width: `min(360px, calc(100vw - ${(leftHidden ? 56 : leftPanelPx) + (rightHidden ? 56 : rightPanelPx) + 32}px))`,
pointerEvents: 'auto',
}}
>
<LocationSelect
value={locationSearch.value}
onChange={locationSearch.onChange}
placeholder={t('reservations.searchLocation')}
ariaLabel={t('reservations.searchLocation')}
showSearchStatus
style={{ width: '100%' }}
/>
</div>
</div>
</>
);
}
@@ -3,6 +3,7 @@ import type { TourListItem } from '@trek/shared'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { useMapLocationSearch } from './useMapLocationSearch'
import { useTripPlanner } from './useTripPlanner'
@@ -19,6 +20,7 @@ export function useTripPlannerPage() {
setSelectedPlaceId,
t,
toast,
tours,
trip,
tripActions,
tripId,
@@ -101,6 +103,9 @@ export function useTripPlannerPage() {
reportViewport(bounds)
}, [reportViewport])
// Each tour's planned times keyed by its place, for the day plan's tour rows.
const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => {
tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null
setSelectedPlaceId(tour?.place_id ?? null)
@@ -116,5 +121,6 @@ export function useTripPlannerPage() {
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
locationSearch,
plannedTourDurations,
}
}