feat(tours): add non-motorized day tour planning

This commit is contained in:
elmocito
2026-10-04 12:59:55 +02:00
parent c93cf2bce9
commit 9e475f447a
202 changed files with 16129 additions and 383 deletions
+21 -2
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -465,7 +465,8 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
delete: (tripId: number | string, id: number | string): Promise<{ success: boolean; tourPlaceIds?: number[] }> =>
apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
uploadImage: (tripId: number | string, id: number | string, file: File) => {
const fd = new FormData()
@@ -513,6 +514,24 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
}
export const toursApi = {
list: (tripId: number | string): Promise<{ tours: TourListItem[] }> =>
apiClient.get(`/trips/${tripId}/tours`).then(r => r.data),
detail: (tripId: number | string, placeId: number | string, signal?: AbortSignal): Promise<TourDetailResponse> =>
(signal
? apiClient.get(`/trips/${tripId}/tours/${placeId}`, { signal })
: apiClient.get(`/trips/${tripId}/tours/${placeId}`)).then(r => r.data),
create: (tripId: number | string, body: TourCreateRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
const fd = new FormData()
fd.append('file', file)
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
},
}
export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
+3
View File
@@ -16,6 +16,9 @@ import type { TrekWsEventName } from '@trek/shared'
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
*/
export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
@@ -2,7 +2,7 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -42,7 +42,23 @@ describe('MapLayerSwitcher', () => {
expect(button.style.background).toBe('transparent')
})
it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => {
it('FE-COMP-MAPLAYER-006: offers all three Tours-only base layers', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Satellite' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-COMP-MAPLAYER-007: reports a per-view Topo selection', () => {
const onChange = vi.fn()
render(<TourMapLayerSwitcher active="default" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Topo' }))
expect(onChange).toHaveBeenCalledWith('topo')
})
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
const shell = button.parentElement as HTMLElement
+55 -8
View File
@@ -1,9 +1,18 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { Map as MapIcon, Mountain, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | 'topo'
const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
@@ -28,13 +37,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
const label = isSatellite ? t('map.baseLayer.switchToDefault') : t('map.baseLayer.switchToSatellite')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<div style={FROSTED_SHELL}>
{/* Same tooltip as its neighbours on the map, for the same reasons — see
TripRouteOverviewPill. To the right, though: this one sits at the
bottom left of the map, where a tooltip to the left would land on the
@@ -61,3 +64,47 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
</div>
)
}
const TOUR_LAYERS = [
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
{ id: 'topo', labelKey: null, Icon: Mountain },
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
/** Per-view Tours control. It never reads or writes the global map setting. */
export function TourMapLayerSwitcher({
active,
onChange,
}: {
active: TourBaseLayer
onChange: (layer: TourBaseLayer) => void
}) {
const { t } = useTranslation()
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={selected}
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: selected ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}
>
<Icon size={15} strokeWidth={2} />
<span>{label}</span>
</button>
)
})}
</div>
)
}
@@ -16,6 +16,8 @@ import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest
import { render, waitFor, act } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { AMAP_ROAD } from '../../constants/mapDefaults'
import { useSettingsStore } from '../../store/settingsStore'
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
@@ -112,6 +114,58 @@ async function renderMap() {
}
describe('MapView on a real Leaflet map', () => {
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
const onMapClick = vi.fn()
const route: [number, number][][] = [[[39.9042, 116.4074], [39.9052, 116.4084]]]
const waypoints = [{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
const renderLayer = (viewBaseLayer: 'default' | 'topo' | 'satellite') => (
<MapView
places={[beijing]}
center={[39.9042, 116.4074]}
zoom={15}
tileUrl={AMAP_ROAD}
viewBaseLayer={viewBaseLayer}
onViewBaseLayerChange={vi.fn()}
route={route}
plannerWaypoints={waypoints}
onMapClick={onMapClick}
/>
)
const view = render(renderLayer('default'))
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).toBeInTheDocument())
let map = maps[maps.length - 1]
expect(map.options.crs).toBeTruthy()
expect(map.options.crs?.code).toBe('TREK:GCJ02')
const retainedCenter = L.latLng(39.91, 116.41)
act(() => map.setView(retainedCenter, 13))
view.rerender(renderLayer('topo'))
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map))
map = maps[maps.length - 1]
expect(map.options.crs).toBe(L.CRS.EPSG3857)
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5)
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5)
const expectedClick = L.latLng(39.9042, 116.4074)
const pixel = map.latLngToContainerPoint(expectedClick)
const roundTrip = map.containerPointToLatLng(pixel)
const pixelTolerance = map.distance(expectedClick, map.containerPointToLatLng(pixel.add([1, 1])))
map.fire('click', { latlng: roundTrip })
expect(onMapClick).toHaveBeenCalledOnce()
expect(map.distance(onMapClick.mock.calls[0][0].latlng, expectedClick)).toBeLessThanOrEqual(pixelTolerance)
view.rerender(renderLayer('satellite'))
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map))
map = maps[maps.length - 1]
expect(map.options.crs?.code).toBe('TREK:GCJ02')
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5)
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5)
expect(waypoints).toEqual([{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' }])
expect(route).toEqual([[[39.9042, 116.4074], [39.9052, 116.4084]]])
})
it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
const { pins, onMarkerClick, onMapClick } = await renderMap()
clickOn(pins[0])
+149 -5
View File
@@ -5,8 +5,9 @@ import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import * as photoService from '../../services/photoService'
import { Coffee, MapPin, Mountain, Signpost, type LucideIcon } from 'lucide-react'
@@ -14,6 +15,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -92,10 +94,10 @@ const clusterMock = vi.hoisted(() => {
vi.mock('react-leaflet', () => ({
// center/zoom are surfaced so tests can assert the camera the map is built
// with; maxZoom because a cluster refuses to attach to a map without one.
MapContainer: ({ children, center, zoom, maxZoom }: any) => (
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>{children}</div>
MapContainer: ({ children, center, zoom, maxZoom, crs }: any) => (
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom} data-crs={crs?.code ?? 'EPSG:3857'}>{children}</div>
),
TileLayer: () => <div data-testid="tile-layer" />,
TileLayer: ({ url }: any) => <div data-testid="tile-layer" data-url={url} />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
<div
ref={node => {
@@ -148,7 +150,14 @@ vi.mock('react-leaflet', () => ({
onClick={() => eventHandlers?.click?.()}
/>
),
CircleMarker: () => <div data-testid="circle-marker" />,
CircleMarker: ({ center, interactive, pathOptions }: any) => (
<div
data-testid={pathOptions?.className === 'tour-profile-focus-marker' ? 'profile-focus-marker' : 'circle-marker'}
data-center={JSON.stringify(center)}
data-interactive={String(interactive)}
data-path-options={JSON.stringify(pathOptions ?? null)}
/>
),
Circle: () => <div data-testid="circle" />,
Tooltip: ({ children }: any) => <>{children}</>,
useMap: () => mapMock,
@@ -195,6 +204,10 @@ vi.mock('../../services/photoService', () => ({
getAllThumbs: vi.fn(() => ({})),
}))
vi.mock('./gcj02Crs', () => ({
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
}))
import { MapView } from './MapView'
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
@@ -231,6 +244,53 @@ describe('MapView', () => {
expect(screen.getByTestId('map-container')).toBeTruthy()
})
it('uses the CRS of the Tours-visible layer and preserves the viewport across datum switches', () => {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
const beijing = buildMapPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 })
const onViewBaseLayerChange = vi.fn()
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
})
const { rerender } = render(<MapView {...props('default')} />)
const defaultMap = screen.getByTestId('map-container')
expect(defaultMap).toHaveAttribute('data-crs', 'TREK:GCJ02')
expect(screen.getAllByTestId('marker').some(marker => marker.getAttribute('data-lat') === '39.9042'
&& marker.getAttribute('data-lng') === '116.4074')).toBe(true)
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD)
const movedCenter = { lat: 39.91, lng: 116.41 }
mapMock.getCenter.mockReturnValue(movedCenter)
mapMock.getZoom.mockReturnValue(13)
const saveCamera = mapMock.on.mock.calls.find(([event]) => event === 'moveend zoomend')?.[1] as (() => void) | undefined
act(() => saveCamera?.())
rerender(<MapView {...props('topo')} />)
const topoMap = screen.getByTestId('map-container')
expect(topoMap).not.toBe(defaultMap)
expect(topoMap).toHaveAttribute('data-crs', 'EPSG:3857')
expect(topoMap).toHaveAttribute('data-center', JSON.stringify([movedCenter.lat, movedCenter.lng]))
expect(topoMap).toHaveAttribute('data-zoom', '13')
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', OPENTOPOMAP_TILE_URL)
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
rerender(<MapView {...props('satellite')} />)
const satelliteMap = screen.getByTestId('map-container')
expect(satelliteMap).not.toBe(topoMap)
expect(satelliteMap).toHaveAttribute('data-crs', 'TREK:GCJ02')
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_SATELLITE)
rerender(<MapView {...props('default')} />)
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'TREK:GCJ02')
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD)
expect(useSettingsStore.getState().settings.map_base_layer).toBe('default')
expect(onViewBaseLayerChange).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
@@ -349,6 +409,90 @@ describe('MapView', () => {
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('moves a passive semantic profile focus marker without changing the map camera', () => {
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 }
const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]]
const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]]
mapMock.fitBounds.mockClear()
mapMock.panTo.mockClear()
mapMock.setView.mockClear()
const { rerender } = render(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={focus} />)
const mapContainer = screen.getByTestId('map-container')
mapMock.fitBounds.mockClear()
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.123,11.456]')
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-interactive', 'false')
expect(JSON.parse(screen.getByTestId('profile-focus-marker').getAttribute('data-path-options')!)).toMatchObject({
color: 'var(--bg-card)', fillColor: 'var(--text-muted)',
})
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 }
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={movedFocus} />)
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.16,11.47]')
expect(screen.getByTestId('map-container')).toBe(mapContainer)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
expect(mapMock.panTo).not.toHaveBeenCalled()
expect(mapMock.setView).not.toHaveBeenCalled()
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={null} />)
expect(screen.queryByTestId('profile-focus-marker')).toBeNull()
expect(mapMock.fitBounds).not.toHaveBeenCalled()
rerender(<MapView places={[]} fitKey={8} focusPoints={[[48, 11], [48.3, 11.8]]} />)
expect(mapMock.fitBounds).toHaveBeenCalled()
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(<MapView places={[]} route={null} fitKey={0} />)
mapMock.fitBounds.mockClear()
rerender(<MapView places={[selectedPlace]} route={null} fitKey={1} />)
expect(mapMock.fitBounds).toHaveBeenCalled()
mapMock.fitBounds.mockClear()
rerender(<MapView places={[selectedPlace]} route={route} fitKey={2} />)
expect(mapMock.fitBounds).toHaveBeenCalled()
})
it.each([
['two-point', [[48, 2], [49, 3]]],
['elevation', [[48, 2, 12.5], [49, 3, 22.75]]],
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
['loop', [[48, 2], [48.1, 2.1], [48, 2]]],
['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]],
])('renders valid %s geometry unchanged', (_label, geometry) => {
render(<MapView places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} />)
const lines = screen.getAllByTestId('polyline')
expect(lines).toHaveLength(3)
const expected = geometry.map(([lat, lng]) => [lat, lng])
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual(expected)
})
it.each([
['malformed JSON', 'not json'],
['non-array JSON', '{"coordinates":[]}'],
['one point', '[[48,2]]'],
['missing coordinate dimension', '[[48],[49,3]]'],
['string coordinates', '[[48,"2"],[49,3]]'],
['NaN token', '[[48,2],[NaN,3]]'],
['Infinity token', '[[48,2],[49,Infinity]]'],
['out-of-range latitude', '[[48,2],[91,3]]'],
['out-of-range longitude', '[[48,2],[49,181]]'],
['all-invalid coordinates', '[[91,2],[49,181]]'],
])('skips %s geometry without passing invalid points to Leaflet', (_label, geometry) => {
expect(() => render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />)).not.toThrow()
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('filters invalid points while preserving the original order of a renderable track', () => {
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]'
render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />)
const lines = screen.getAllByTestId('polyline')
expect(lines).toHaveLength(3)
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual([[48, 2], [48.5, 2.5], [49, 3]])
})
// ── Track colours (#776) ──────────────────────────────────────────────────
// The style has to travel through pathOptions: react-leaflet only calls
// setStyle when that object's reference changes, so bare color/weight props
+93 -19
View File
@@ -59,14 +59,15 @@ import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -356,6 +357,20 @@ function ViewportController({ onViewportChange }: { onViewportChange?: (b: { sou
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const save = () => {
const center = map.getCenter()
onChange([center.lat, center.lng], map.getZoom())
}
map.whenReady(save)
map.on('moveend zoomend', save)
return () => { map.off('moveend zoomend', save) }
}, [map, onChange])
return null
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
@@ -734,6 +749,16 @@ const MemoMarker = memo(function MemoMarker({
)
})
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
const size = selected ? 30 : 26
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer">${index + 1}</span>`,
})
}
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
@@ -799,7 +824,19 @@ export const MapView = memo(function MapView({
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
}: any) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const isSatellite = baseLayer === 'satellite'
const isTopo = baseLayer === 'topo'
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
@@ -811,7 +848,7 @@ export const MapView = memo(function MapView({
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
@@ -870,6 +907,10 @@ export const MapView = memo(function MapView({
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
}, [])
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
@@ -1045,12 +1086,10 @@ export const MapView = memo(function MapView({
// of points), so it hangs off `places` alone — a selection change must not
// re-parse every track.
const gpxTracks = useMemo(() => places.flatMap(place => {
if (!place.route_geometry) return []
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return []
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
} catch { return [] }
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
@@ -1119,9 +1158,6 @@ export const MapView = memo(function MapView({
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
// Store flips state synchronously (instant, works offline); a failed save is logged there.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
@@ -1149,8 +1185,8 @@ export const MapView = memo(function MapView({
// the cluster group away on every template or API-key edit.
key={isGcjBasemap ? 'gcj02' : 'wgs84'}
id="trek-map"
center={initialView.center}
zoom={initialView.zoom}
center={cameraSnapshotRef.current?.center ?? initialView.center}
zoom={cameraSnapshotRef.current?.zoom ?? initialView.zoom}
zoomControl={false}
// On the map itself, not left to the base layer. Leaflet reads its zoom
// ceiling from the map options or, failing that, from a GridLayer that
@@ -1184,6 +1220,18 @@ export const MapView = memo(function MapView({
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : isTopo ? (
<TileLayer
key="topo"
url={OPENTOPOMAP_TILE_URL}
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
maxZoom={MAP_MAX_ZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
) : (
@@ -1200,6 +1248,7 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
@@ -1311,10 +1360,37 @@ export const MapView = memo(function MapView({
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
<Marker
key={`planner-waypoint-${point.id}`}
position={[point.lat, point.lng]}
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
eventHandlers={{
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
onPlannerWaypointClick?.(point.id)
},
}}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
))}
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
bubblingMouseEvents={false}
/>
)}
<ReservationOverlay
reservations={visibleReservations}
showConnections
@@ -1367,13 +1443,11 @@ export const MapView = memo(function MapView({
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
{/* Bottom left, whatever else is on screen. Opening a place used to send it to the
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
@@ -889,6 +889,65 @@ describe('MapViewGL', () => {
return calls[calls.length - 1][0] as { features: GeoFeature[] }
}
it('moves a passive semantic profile focus pin without changing the map camera', async () => {
loadOnAttach()
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 }
const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]]
const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]]
const { rerender } = render(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={focus} />)
await act(async () => {})
const marker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]')
expect(marker).not.toBeNull()
const projected = glMap.project([focus.lng, focus.lat])
expect(marker?.style.transform).toContain(`translate(${projected.x}px, ${projected.y}px)`)
expect(marker?.style.pointerEvents).toBe('none')
expect(marker?.style.background).toBe('var(--text-muted)')
expect(marker?.style.border).toBe('3px solid var(--bg-card)')
expect(glCanvasContainer.contains(marker)).toBe(true)
glMap.fitBounds.mockClear()
glMap.flyTo.mockClear()
glMap.jumpTo.mockClear()
glMap.easeTo.mockClear()
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 }
rerender(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={movedFocus} />)
await act(async () => {})
const movedMarker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]')
expect(movedMarker).toBe(marker)
expect(movedMarker?.style.transform).toContain(`translate(${glMap.project([movedFocus.lng, movedFocus.lat]).x}px, ${glMap.project([movedFocus.lng, movedFocus.lat]).y}px)`)
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
expect(glMap.easeTo).not.toHaveBeenCalled()
rerender(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={null} />)
await act(async () => {})
expect(glCanvasContainer.contains(marker)).toBe(false)
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
expect(glMap.easeTo).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 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(<MapViewGL places={[]} route={null} fitKey={0} glProvider="maplibre-gl" />)
await act(async () => {})
glMap.fitBounds.mockClear()
rerender(<MapViewGL places={[selectedPlace]} route={null} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalled()
glMap.fitBounds.mockClear()
rerender(<MapViewGL places={[selectedPlace]} route={route} fitKey={2} glProvider="maplibre-gl" />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalled()
})
// jsdom drives requestAnimationFrame off a timer, so the marker reconcile and
// the batched photo updates only land after a short tick.
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
@@ -1782,6 +1841,33 @@ describe('MapViewGL', () => {
expect(features[1].properties.color).toBeNull()
})
it('uses view-local Topo tiles and controls without writing settings or moving the camera', async () => {
glMap.on.mockImplementation((event, handlerOrLayer) => {
if (event === 'load' && typeof handlerOrLayer === 'function') handlerOrLayer()
return glMap
})
const onChange = vi.fn()
const settings = useSettingsStore.getState().settings.map_base_layer
const { container, rerender } = render(<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" viewBaseLayer="topo" onViewBaseLayerChange={onChange} />)
await act(async () => {})
expect(glMap.addSource).toHaveBeenCalledWith('trip-topo', expect.objectContaining({
type: 'raster', tiles: ['a', 'b', 'c'].map(subdomain => `https://${subdomain}.tile.opentopomap.org/{z}/{x}/{y}.png`),
}))
const button = Array.from(container.querySelectorAll('button')).find(element => element.textContent === 'Topo')!
expect(button).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(button)
expect(onChange).toHaveBeenCalledWith('topo')
expect(useSettingsStore.getState().settings.map_base_layer).toBe(settings)
glMap.fitBounds.mockClear()
glMap.flyTo.mockClear()
glMap.jumpTo.mockClear()
rerender(<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" viewBaseLayer="default" onViewBaseLayerChange={onChange} />)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
const gpxSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
@@ -1800,6 +1886,47 @@ describe('MapViewGL', () => {
expect(features[0].properties.place_id).toBe(74)
})
it.each([
['two-point', [[48, 2], [49, 3]]],
['elevation', [[48, 2, 12.5], [49, 3, 22.75]]],
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
['loop', [[48, 2], [48.1, 2.1], [48, 2]]],
['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]],
])('renders valid %s geometry with MapLibre coordinate order', async (_label, geometry) => {
const features = gpxFeatures()
render(<MapViewGL places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} fitKey={1} />)
await act(async () => {})
expect(features()).toHaveLength(1)
expect(features()[0].geometry.coordinates).toEqual(geometry.map(([lat, lng]) => [lng, lat]))
})
it.each([
['malformed JSON', 'not json'],
['non-array JSON', '{"coordinates":[]}'],
['one point', '[[48,2]]'],
['missing coordinate dimension', '[[48],[49,3]]'],
['string coordinates', '[[48,"2"],[49,3]]'],
['NaN token', '[[48,2],[NaN,3]]'],
['Infinity token', '[[48,2],[49,Infinity]]'],
['out-of-range latitude', '[[48,2],[91,3]]'],
['out-of-range longitude', '[[48,2],[49,181]]'],
['all-invalid coordinates', '[[91,2],[49,181]]'],
])('omits %s geometry from the MapLibre source', async (_label, geometry) => {
const features = gpxFeatures()
expect(() => render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />)).not.toThrow()
await act(async () => {})
expect(features()).toHaveLength(0)
})
it('filters invalid points and preserves valid order in the MapLibre source', async () => {
const features = gpxFeatures()
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]'
render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />)
await act(async () => {})
expect(features()).toHaveLength(1)
expect(features()[0].geometry.coordinates).toEqual([[2, 48], [2.5, 48.5], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-050: without a Mapbox token it shows the settings hint and touches no camera', async () => {
const mapboxgl = (await import('mapbox-gl')).default
useSettingsStore.setState({
+111 -29
View File
@@ -9,7 +9,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { MapLockPill } from './MapLockPill'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -41,6 +42,7 @@ import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { parseRenderableRouteGeometry, type RouteProfileFocus } from '../../utils/routeGeometry'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
@@ -199,6 +201,13 @@ interface Props {
onChooseAlternative?: (index: number) => void
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void
/** Generic numbered control points for list-first route editors. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>
selectedPlannerWaypointId?: string | null
onPlannerWaypointClick?: (id: string) => void
routeProfileFocus?: RouteProfileFocus | null
viewBaseLayer?: TourBaseLayer
onViewBaseLayerChange?: (layer: TourBaseLayer) => void
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
@@ -330,23 +339,27 @@ function addPlaceClusterLayers(map: any): void {
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
try {
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(SATELLITE_SOURCE_ID)) {
map.addSource(SATELLITE_SOURCE_ID, {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tiles: topo
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
})
}
// Under the first thing TREK draws, over everything the basemap style draws.
@@ -355,9 +368,9 @@ function applySatellite(map: any, on: boolean): void {
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
id: layerId,
type: 'raster',
source: SATELLITE_SOURCE_ID,
source: sourceId,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
@@ -727,6 +740,12 @@ export function MapViewGL({
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -740,7 +759,9 @@ export function MapViewGL({
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
// The same stored choice the Leaflet map reads, so the two renderers agree.
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const baseLayer = viewBaseLayer ?? globalBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
@@ -809,6 +830,8 @@ export function MapViewGL({
const routeViaMarkersRef = useRef<PlacePin[]>([])
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
@@ -819,6 +842,8 @@ export function MapViewGL({
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
viaPinsRef.current.forEach(pin => pin.reposition())
plannerWaypointPinsRef.current.forEach(pin => pin.reposition())
profileFocusPinRef.current?.reposition()
altLabelsRef.current.forEach(pin => pin.reposition())
}, [])
// Single reusable hover popup for POI markers. Planned places use the
@@ -837,6 +862,61 @@ 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 = []
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(); onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
}
return () => {
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
}
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
useEffect(() => () => {
profileFocusPinRef.current?.remove()
profileFocusPinRef.current = null
}, [gl, mapReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!routeProfileFocus) {
profileFocusPinRef.current?.remove()
profileFocusPinRef.current = null
return
}
let pin = profileFocusPinRef.current
if (!pin) {
const element = document.createElement('div')
element.setAttribute('aria-hidden', 'true')
element.dataset.tourProfileFocus = 'true'
element.style.cssText = 'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;'
pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat)
profileFocusPinRef.current = pin
} else {
pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat])
}
pin.el.style.pointerEvents = 'none'
}, [gl, mapReady, routeProfileFocus])
/**
* The list only changes when a via does, and the callbacks are read through a ref.
*
@@ -2188,20 +2268,17 @@ export function MapViewGL({
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = places.flatMap(place => {
if (!place.route_geometry) return []
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return []
return [{
type: 'Feature' as const,
properties: {
color: resolveTrackColor(place),
cased: hasManualTrackColor(place),
place_id: place.id,
},
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
} catch { return [] }
const coords = parseRenderableRouteGeometry(place.route_geometry)
if (!coords) return []
return [{
type: 'Feature' as const,
properties: {
color: resolveTrackColor(place),
cased: hasManualTrackColor(place),
place_id: place.id,
},
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
})
src.setData({ type: 'FeatureCollection', features })
}, [places, mapReady])
@@ -2405,11 +2482,14 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applySatellite(map, isSatellite)
const apply = () => {
applySatellite(map, isSatellite)
applySatellite(map, baseLayer === 'topo', true)
}
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, mapReady, glProvider])
}, [isSatellite, baseLayer, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
@@ -2455,7 +2535,9 @@ export function MapViewGL({
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
+213 -1
View File
@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import { projectDayItinerary } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
@@ -224,3 +227,212 @@ describe('buildDayRouteRuns', () => {
})
})
})
describe('Tour day itinerary segments', () => {
const points = {
A: [48.1, 11.1], B: [48.101, 11.101], C: [48.13, 11.13],
S: [48.103, 11.103], E: [48.12, 11.12],
S2: [48.121, 11.121], E2: [48.125, 11.125],
} as const
type Name = keyof typeof points
const tour = (name: Name, end: Name, order: number, geometry?: string) => {
const assignment = at(points[name][0], points[name][1], order)
return { ...assignment, tour_place_id: assignment.place.id,
tour_route_geometry: geometry ?? JSON.stringify([points[name], points[end]]) }
}
const ordinary = (name: Name, order: number) => at(points[name][0], points[name][1], order)
const inspect = (list: AssignmentsMap['1'], enabled = true, profile: 'walking' | 'driving' = 'walking') => {
const input = { ...inputs({ assignments: { '1': list } }), toursEnabled: enabled }
const runs = buildDayRouteRuns(1, input)
const plan = planTripRoute(input, profile)
const lines = assembleTripRoute(plan, emptyAnswers(plan))[0]?.lines ?? []
return { runs: runs.map(run => run.map(p => [p.lat, p.lng])),
chunks: plan.flatMap(day => day.runs.flatMap(run => run.map(chunk => chunk.mode))), lines }
}
it('keeps ordinary A-B-C unchanged with Tours on or off', () => {
const list = [ordinary('A', 0), ordinary('B', 1), ordinary('C', 2)]
expect(inspect(list).runs).toEqual(inspect(list, false).runs)
expect(inspect(list).runs).toEqual([[points.A, points.B, points.C]])
})
it('skips an excluded ordinary activity while preserving the upstream A-C run', () => {
const list = [ordinary('A', 0), { ...ordinary('B', 1), route_excluded: true }, ordinary('C', 2)]
expect(inspect(list).runs).toEqual([[points.A, points.C]])
expect(inspect(list, false).runs).toEqual([[points.A, points.C]])
})
it('retains an excluded Tour and its line but uses no Tour endpoint in routing', () => {
const excluded = { ...tour('S', 'E', 1), route_excluded: true }
const list = [ordinary('A', 0), excluded, ordinary('C', 2)]
expect(projectDayItinerary(list, true)[1]).toMatchObject({
kind: 'tour', assignment: excluded, geometry: [points.S, points.E], valid: true,
})
expect(inspect(list).runs).toEqual([[points.A, points.C]])
expect(inspect(list).lines).toEqual([[points.A, points.C]])
expect(inspect(list, false).runs).toEqual([[points.A, points.C]])
})
it('frames an excluded Tour-only day without generating any calculated route line', () => {
const excluded = { ...tour('S', 'E', 0), route_excluded: true }
const plan = planTripRoute({ ...inputs({ assignments: { '1': [excluded] } }), toursEnabled: true }, 'walking')
expect(plan).toHaveLength(1)
expect(plan[0].runs).toEqual([])
const overview = summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan)))
expect(overview.lines).toEqual([])
expect(overview.days[0].tourLines).toEqual([[points.S, points.E]])
expect(overview.focusPoints).toEqual([points.S, points.E])
})
it('omits excluded first, last, consecutive and invalid Tours from connector anchors', () => {
const excluded = (start: Name, end: Name, order: number, geometry?: string) => ({
...tour(start, end, order, geometry), route_excluded: true,
})
expect(inspect([excluded('S', 'E', 0), ordinary('A', 1), ordinary('C', 2)]).runs).toEqual([[points.A, points.C]])
expect(inspect([ordinary('A', 0), ordinary('C', 1), excluded('S', 'E', 2)]).runs).toEqual([[points.A, points.C]])
expect(inspect([ordinary('A', 0), excluded('S', 'E', 1), excluded('S2', 'E2', 2), ordinary('C', 3)]).runs).toEqual([[points.A, points.C]])
expect(inspect([ordinary('A', 0), excluded('S', 'E', 1, '{bad'), ordinary('C', 2)]).runs).toEqual([[points.A, points.C]])
})
it.each(['cycling', 'cable_car', 'plugin:custom/walk'])('preserves %s overrides on connectors outside Tours', mode => {
const list = [{ ...ordinary('A', 0), leg_transport_mode: mode }, tour('S', 'E', 1), ordinary('C', 2)]
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.E, points.C]])
expect(inspect(list).chunks).toEqual([mode, 'walking'])
})
it.each(['walking', 'driving'] as const)('uses connectors and authoritative point-to-point geometry in %s', profile => {
const list = [ordinary('A', 0), tour('S', 'E', 1), ordinary('C', 2)]
const projection = projectDayItinerary(list, true)
expect(projection[1]).toMatchObject({ kind: 'tour', valid: true, start: { lat: 48.103, lng: 11.103 }, end: { lat: 48.12, lng: 11.12 }, geometry: [points.S, points.E] })
expect(inspect(list, true, profile).runs).toEqual([[points.A, points.S], [points.E, points.C]])
expect(inspect(list, true, profile).lines).toEqual([[points.A, points.S], [points.E, points.C]])
expect(inspect(list, true, profile).chunks).toEqual([profile, profile])
expect(inspect(list, false, profile).runs).toEqual([[points.A, points.S, points.C]])
})
it('joins a loop at its shared start/end without routing across the Tour', () => {
const list = [ordinary('A', 0), tour('S', 'S', 1, JSON.stringify([points.S, points.E, points.S])), ordinary('C', 2)]
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.S, points.C]])
expect(projectDayItinerary(list, true)[1]).toMatchObject({ kind: 'tour', loop: true })
})
it('handles first, last, single, and consecutive Tours', () => {
expect(inspect([tour('S', 'E', 0), ordinary('C', 1)]).runs).toEqual([[points.E, points.C]])
expect(inspect([ordinary('A', 0), tour('S', 'E', 1)]).runs).toEqual([[points.A, points.S]])
expect(inspect([tour('S', 'E', 0)]).runs).toEqual([])
expect(inspect([ordinary('A', 0), tour('S', 'E', 1), tour('S2', 'E2', 2), ordinary('C', 3)]).runs)
.toEqual([[points.A, points.S], [points.E, points.S2], [points.E2, points.C]])
})
it('bookends a single Tour at start/end and ignores excluded edge Tours for hotel anchors', () => {
const hotel = [48, 11]
const days = [1, 2, 3].map(id => buildDay({ id, day_number: id }))
const accommodations = [{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3,
place_lat: hotel[0], place_lng: hotel[1] }] as Accommodation[]
const withHotels = (list: AssignmentsMap['1']) => buildDayRouteRuns(2, {
...inputs({ days, assignments: { '2': list.map(assignment => ({ ...assignment, day_id: 2 })) },
accommodations, optimizeFromAccommodation: true }), toursEnabled: true,
}).map(run => run.map(point => [point.lat, point.lng]))
expect(withHotels([tour('S', 'E', 0)])).toEqual([[hotel, points.S], [points.E, hotel]])
expect(withHotels([
{ ...tour('S', 'E', 0), route_excluded: true }, ordinary('A', 1), ordinary('C', 2),
{ ...tour('S2', 'E2', 3), route_excluded: true },
])).toEqual([[hotel, points.A], [points.A, points.C], [points.C, hotel]])
expect(withHotels([tour('S', 'E', 0, '{bad')])).toEqual([[hotel, points.S]])
})
it('preserves incoming/outgoing Tour modes on hotel connectors and overview chunks', () => {
const hotel = { id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 48.09, place_lng: 11.09 } as Accommodation
const days = [
buildDay({ id: 1, day_number: 1 }),
buildDay({ id: 2, day_number: 2, default_transport_mode: 'driving' }),
buildDay({ id: 3, day_number: 3 }),
]
const assignedTour = {
...tour('S', 'E', 0),
incoming_leg_transport_mode: 'walking',
leg_transport_mode: 'cycling',
}
const input = {
...inputs({ days, assignments: { '2': [assignedTour] }, accommodations: [hotel], optimizeFromAccommodation: true }),
toursEnabled: true,
}
const runs = buildDayRouteRuns(2, input)
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([
[[hotel.place_lat, hotel.place_lng], points.S],
[points.E, [hotel.place_lat, hotel.place_lng]],
])
expect(resolveLegMode(runs[0][0], runs[0][1], 'driving')).toBe('walking')
expect(resolveLegMode(runs[1][0], runs[1][1], 'driving')).toBe('cycling')
expect(runs[0][1].incoming_leg_transport_mode).toBe('walking')
expect(runs[1][0].leg_transport_mode).toBe('cycling')
const overview = planTripRoute(input, 'driving').find(day => day.day.id === 2)!
expect(overview.runs.map(run => run.map(chunk => chunk.mode))).toEqual([['walking'], ['cycling']])
expect(overview.tourLines).toEqual([[points.S, points.E]])
const loopTour = { ...tour('S', 'S', 0, JSON.stringify([points.S, points.E, points.S])),
incoming_leg_transport_mode: 'walking', leg_transport_mode: 'cycling' }
const loopInput = { ...input, assignments: { '2': [loopTour] } }
const loopRuns = buildDayRouteRuns(2, loopInput)
expect(loopRuns.map(run => resolveLegMode(run[0], run[1], 'driving'))).toEqual(['walking', 'cycling'])
expect(planTripRoute(loopInput, 'driving')[0].tourLines).toEqual([[points.S, points.E, points.S]])
const nextTour = { ...tour('S2', 'E2', 1), incoming_leg_transport_mode: 'walking', leg_transport_mode: 'cycling' }
const consecutiveInput = { ...input, assignments: { '2': [assignedTour, nextTour] } }
const consecutiveRuns = buildDayRouteRuns(2, consecutiveInput)
expect(consecutiveRuns.map(run => resolveLegMode(run[0], run[1], 'driving'))).toEqual(['walking', 'cycling', 'cycling'])
expect(planTripRoute(consecutiveInput, 'driving')[0].tourLines).toEqual([[points.S, points.E], [points.S2, points.E2]])
})
it('does not let an unlocated transport after an invalid Tour create an evening hotel connector', () => {
const hotel = { id: 1, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 48.09, place_lng: 11.09 } as Accommodation
const days = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })]
const invalidTour = tour('S', 'E', 0, '{bad')
const unlocatedTrain = buildReservation({ id: 90, type: 'train', day_id: 2, day_plan_position: 1, endpoints: [] })
const runs = buildDayRouteRuns(2, {
...inputs({ days, assignments: { '2': [invalidTour] }, reservations: [unlocatedTrain], accommodations: [hotel], optimizeFromAccommodation: true }),
toursEnabled: true,
})
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[hotel.place_lat, hotel.place_lng], points.S]])
})
it('marks malformed Tour geometry invalid without inventing an outgoing end', () => {
const list = [ordinary('A', 0), tour('S', 'E', 1, '{bad'), ordinary('C', 2)]
expect(projectDayItinerary(list, true)[1]).toMatchObject({ kind: 'tour', valid: false, start: { lat: 48.103, lng: 11.103 }, end: null })
expect(inspect(list).runs).toEqual([[points.A, points.S]])
expect(inspect(list, false).runs).toEqual([[points.A, points.S, points.C]])
})
it('uses first and last usable coordinates when Tour geometry contains malformed points', () => {
const malformedPointToPoint = JSON.stringify([
[91, 200], points.S, [Number.NaN, points.E[1]], points.E, [48.2],
])
const list = [ordinary('A', 0), tour('S', 'E', 1, malformedPointToPoint), ordinary('C', 2)]
expect(projectDayItinerary(list, true)[1]).toMatchObject({
kind: 'tour', valid: true, start: { lat: points.S[0], lng: points.S[1] },
end: { lat: points.E[0], lng: points.E[1] }, geometry: [points.S, points.E],
})
expect(inspect(list).runs).toEqual([[points.A, points.S], [points.E, points.C]])
})
it('uses the current Place geometry when a saved Tour changes before assignments refresh', () => {
const assignment = tour('S', 'E', 1)
const updatedEnd: [number, number] = [48.127, 11.127]
const places = [buildPlace({ id: assignment.place.id, lat: points.S[0], lng: points.S[1],
route_geometry: JSON.stringify([points.S, updatedEnd]) })]
const list = [ordinary('A', 0), assignment, ordinary('C', 2)]
expect(projectDayItinerary(list, true, places)[1]).toMatchObject({ end: { lat: 48.127, lng: 11.127 } })
expect(buildDayRouteRuns(1, { ...inputs({ assignments: { '1': list } }), toursEnabled: true, places })
.map(run => run.map(point => [point.lat, point.lng])))
.toEqual([[points.A, points.S], [updatedEnd, points.C]])
})
it('preserves the outgoing Tour mode override for connectors only', () => {
const list = [ordinary('A', 0), { ...tour('S', 'E', 1), leg_transport_mode: 'plugin:custom/walk' }, ordinary('C', 2)]
expect(inspect(list).chunks).toEqual(['walking', 'plugin:custom/walk'])
expect(projectDayItinerary(list, true)[1]).toMatchObject({ geometry: [points.S, points.E] })
})
})
+37 -7
View File
@@ -2,7 +2,8 @@ import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import { projectDayItinerary } from './dayTourProjection'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
@@ -36,6 +37,8 @@ export interface DayRouteInputs {
reservations: Reservation[]
accommodations: Accommodation[]
optimizeFromAccommodation: boolean | undefined
toursEnabled?: boolean
places?: Place[]
}
/**
@@ -78,16 +81,23 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'tour'; start: { lat: number; lng: number } | null; end: { lat: number; lng: number } | null; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
if (a.route_excluded) return []
const tour = projected.get(a.id)
if (tour?.kind === 'tour') return [{
kind: 'tour', start: tour.start, end: tour.end, pos: item.sortKey,
time: a.place?.place_time ?? null,
mode: a.leg_transport_mode ?? null, incoming: a.incoming_leg_transport_mode ?? null,
}]
if (!a.place?.lat || !a.place?.lng) return []
// Kept on the day, shown on the map, but not driven to (#2532).
if ((a as { route_excluded?: boolean }).route_excluded) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
@@ -117,7 +127,19 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
if (entry.kind === 'tour') {
if (entry.start) {
const prev = currentRun[currentRun.length - 1]
if (prev && !prev.isPlace && !withinDriveRange(prev, entry.start)) closeRun()
currentRun.push({ ...entry.start, isPlace: true, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
}
closeRun()
if (entry.end) {
currentRun.push({ ...entry.end, isPlace: true, leg_transport_mode: entry.mode })
runHasPlace = true
}
} else if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
@@ -142,6 +164,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const flatPts: DayRoutePoint[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
else if (e.kind === 'tour') {
if (e.start) flatPts.push({ ...e.start, isPlace: true, incoming_leg_transport_mode: e.incoming })
if (e.end) flatPts.push({ ...e.end, isPlace: true, leg_transport_mode: e.mode })
}
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
@@ -153,12 +179,16 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const contributes = (e: Entry) => e.kind === 'place' || e.kind === 'tour' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
if (e.kind === 'tour') {
const point = side === 'first' ? e.start : e.end
return point ? { isPlace: true, time: e.time, ...point } : undefined
}
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
@@ -172,9 +202,9 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning, 'morning')
const eveningHotel = hotelPt(bookends?.evening, 'evening')
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
const drawMorning = !!bookends && !!day && !(firstStop?.kind === 'tour' && !firstStop.start) && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
const drawEvening = !!bookends && !!day && !(lastStop?.kind === 'tour' && !lastStop.end) && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
runs,
@@ -0,0 +1,41 @@
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import type { Assignment, Place } from '../../types'
export type DayItineraryItem =
| { kind: 'stop'; assignment: Assignment }
| {
kind: 'tour'
assignment: Assignment
placeId: number
start: { lat: number; lng: number } | null
end: { lat: number; lng: number } | null
geometry: [number, number][]
loop: boolean
valid: boolean
}
export function projectDayItinerary(
assignments: Assignment[],
toursEnabled: boolean,
places: readonly Place[] = [],
): DayItineraryItem[] {
const placesById = new Map(places.map(place => [place.id, place]))
return assignments.map(assignment => {
const placeId = assignment.place?.id
if (!toursEnabled || placeId == null || assignment.tour_place_id !== placeId) return { kind: 'stop', assignment }
const currentPlace = placesById.get(placeId)
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
const geometry = analysis?.routeCoordinates ?? []
const start = geometry.length >= 2
? { lat: geometry[0][0], lng: geometry[0][1] }
: assignment.place?.lat != null && assignment.place?.lng != null
? { lat: assignment.place.lat, lng: assignment.place.lng }
: null
const end = geometry.length >= 2
? { lat: geometry[geometry.length - 1][0], lng: geometry[geometry.length - 1][1] }
: null
return { kind: 'tour', assignment, placeId, start, end, geometry,
loop: !!start && !!end && start.lat === end.lat && start.lng === end.lng,
valid: !!end } as const
})
}
+19 -6
View File
@@ -1,5 +1,6 @@
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { projectDayItinerary } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { dayColor } from '../Roadtrip/dayColors'
import type { Day, RouteSegment } from '../../types'
@@ -15,6 +16,8 @@ export interface TripOverviewDay {
color: { line: string; casing: string }
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
/** Saved Tours are not routed legs and keep their own map style. */
tourLines?: [number, number][][]
segments: RouteSegment[]
/** Metres and seconds, summed over the day's legs. */
distance: number
@@ -49,7 +52,7 @@ export interface TripRouteSummary {
interface Chunk { points: DayRoutePoint[]; mode: string }
/** A day reduced to the routing requests it needs, in the order they draw. */
export interface TripRoutePlanDay { day: Day; runs: Chunk[][] }
export interface TripRoutePlanDay { day: Day; runs: Chunk[][]; tourLines?: [number, number][][] }
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
/** Every leg's answer so far, indexed like the plan: day, run, chunk. */
@@ -83,11 +86,20 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p =
export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
return [...input.days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({ day, runs: buildDayRouteRuns(day.id, input) }))
.filter(entry => entry.runs.length > 0)
.map(({ day, runs }) => ({
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, input),
tourLines: projectDayItinerary(
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
input.toursEnabled ?? false,
input.places,
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
}))
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
.map(({ day, runs, tourLines }) => ({
day,
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)),
tourLines,
}))
}
@@ -97,7 +109,7 @@ export const emptyAnswers = (plan: TripRoutePlanDay[]): Answer[][][] =>
/** Stitch the routed answers back onto the plan, in the order the days are travelled. */
export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]): TripOverviewDay[] {
return plan.map(({ day, runs }, d) => {
return plan.map(({ day, runs, tourLines }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
let unroutedLegs = 0
@@ -127,6 +139,7 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
title: day.title ?? null,
color: dayRouteColor(day),
lines,
tourLines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
@@ -143,7 +156,7 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
lines,
lineColors: days.flatMap(d => d.lines.map(() => d.color)),
segments: days.flatMap(d => d.segments),
focusPoints: lines.flat(),
focusPoints: [...lines.flat(), ...days.flatMap(day => (day.tourLines ?? []).flat())],
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
unroutedLegs: days.reduce((sum, d) => sum + (d.unroutedLegs ?? 0), 0),
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import { useAddonStore } from '../../store/addonStore'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
@@ -38,6 +39,7 @@ const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days
)
beforeEach(() => {
useAddonStore.setState({ addons: [] })
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
@@ -47,6 +49,38 @@ beforeEach(() => {
})
describe('useTripRouteOverview', () => {
it('updates an excluded Tour-only overview from current Place geometry without routing or reframing edits', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled: true }] })
const geometry = [[48.1, 11.1], [48.2, 11.2], [48.3, 11.3]]
const place = buildPlace({ id: 71, lat: 48.1, lng: 11.1, route_geometry: JSON.stringify(geometry) })
const assignments = { '1': [buildAssignment({ day_id: 1, place, tour_place_id: 71,
tour_route_geometry: '[[49,12],[49.1,12.1]]', route_excluded: true })] }
const days = [DAYS[0]]
const { result, rerender } = renderHook(
({ places, enabled }: { places: typeof place[]; enabled: boolean }) =>
useTripRouteOverview(7, days, assignments, [], [], 'walking', enabled, places),
{ initialProps: { places: [place], enabled: true } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].tourLines).toEqual([geometry])
expect(result.current.focusPoints).toEqual(geometry)
expect(result.current.lines).toEqual([])
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
const frame = result.current.focusPoints
const changed = [[48.1, 11.1], [48.25, 11.28], [48.3, 11.3]]
const places = [{ ...place, route_geometry: JSON.stringify(changed) }]
rerender({ places, enabled: true })
await waitFor(() => expect(result.current.days[0].tourLines).toEqual([changed]))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.focusPoints).toBe(frame)
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
rerender({ places, enabled: false })
rerender({ places, enabled: true })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.focusPoints).toEqual(changed)
expect(result.current.focusPoints).not.toBe(frame)
})
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
const { result } = render(false)
@@ -5,7 +5,8 @@ import {
type TripRouteAnswers, type TripRouteSummary,
} from './tripRouteGeometry'
import { useSettingsStore } from '../../store/settingsStore'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
import { useAddonStore } from '../../store/addonStore'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
@@ -15,6 +16,7 @@ export interface TripRouteOverview extends TripRouteSummary {
}
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
const EMPTY_PLACES: Place[] = []
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
@@ -34,8 +36,10 @@ export function useTripRouteOverview(
accommodations: Accommodation[],
profile: RouteProfileKey,
enabled: boolean,
places: Place[] = EMPTY_PLACES,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
@@ -51,17 +55,17 @@ export function useTripRouteOverview(
const plan = useMemo(
() => (enabled
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation }, profile)
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places }, profile)
: []),
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile],
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places],
)
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
() => plan.map(({ day, runs, tourLines }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
.join('/')}`).join(';'),
.join('/')}:tours=${JSON.stringify(tourLines ?? [])}`).join(';'),
[plan],
)
@@ -264,6 +264,49 @@ describe('valhallaRun over a whole day', () => {
const stops = (n: number) =>
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
it('retains Tours costing options through the upstream spaced retry', async () => {
const bodies: unknown[] = []
const times: number[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
bodies.push(await request.json())
times.push(Date.now())
return bodies.length === 1 ? new HttpResponse(null, { status: 429 }) : HttpResponse.json(chain(1))
}))
const result = await valhallaRun(stops(2), 'walking', [], undefined, { max_hiking_difficulty: 6 })
expect(result).not.toBeNull()
expect(bodies).toHaveLength(2)
expect(bodies[1]).toEqual(bodies[0])
expect(bodies[1]).toMatchObject({ costing_options: { pedestrian: { max_hiking_difficulty: 6 } } })
expect(times[1] - times[0]).toBeGreaterThanOrEqual(1000)
})
it('does not send another Tours request when aborted during the retry pause', async () => {
const controller = new AbortController()
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => {
calls++
setTimeout(() => controller.abort(), 25)
return new HttpResponse(null, { status: 429 })
}))
await expect(valhallaRun(stops(2), 'walking', [], controller.signal, { max_hiking_difficulty: 4 })).resolves.toBeNull()
expect(calls).toBe(1)
})
it.each([1, 2, 3, 4, 5, 6])('keeps Tours T%i options in the exact pedestrian request with avoidance', async difficulty => {
let body: unknown
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
body = await request.json()
return HttpResponse.json(chain(1))
}))
await valhallaRun(stops(2), 'walking', ['ferry'], undefined, { max_hiking_difficulty: difficulty })
expect(body).toEqual({
locations: stops(2).map(point => ({ lat: point.lat, lon: point.lng, radius: 50 })),
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: difficulty, use_ferry: 0 } },
directions_options: { units: 'kilometers' },
})
})
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
@@ -279,6 +322,21 @@ describe('valhallaRun over a whole day', () => {
expect(run!.total.distance).toBe(50000)
})
it('VALHALLA-RUN-008: forwards caller-specific costing options only when supplied', async () => {
let body: Record<string, unknown> | null = null
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json(chain(1))
}))
await valhallaRun(stops(2), 'walking', [], undefined, { max_hiking_difficulty: 2 })
expect(body).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 2 } },
})
})
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
// point, which is exact rather than approximate — measured over a six-stop day,
+6 -3
View File
@@ -191,6 +191,7 @@ export async function valhallaRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number>> = {},
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
@@ -204,7 +205,7 @@ export async function valhallaRun(
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), signal)
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, costingOptions), signal)
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
@@ -285,13 +286,14 @@ function routeBody(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
costingOptions: Readonly<Record<string, number>> = {},
): Record<string, unknown> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = {}
const options: Record<string, number> = { ...costingOptions }
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
@@ -333,8 +335,9 @@ async function requestRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number>> = {},
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid), signal)
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
return data === null ? null : runFrom(data)
}
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import type { RouteSegment } from '../../types'
import { useAddonStore } from '../../store/addonStore'
vi.mock('../Map/RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('../Map/RouteCalculator')>()
@@ -65,6 +66,8 @@ beforeEach(() => {
http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [] })),
)
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(routeTrip).mockClear()
vi.mocked(renderTripMapImage).mockClear()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.88, 2.39], [48.90, 2.42]],
distance: 12000, duration: 900,
@@ -74,9 +77,105 @@ beforeEach(() => {
afterEach(() => {
document.getElementById('pdf-preview-overlay')?.remove()
useAddonStore.setState({ addons: [] })
})
describe('trip route map in the PDF', () => {
it('prints a single Tour track without requesting an ordinary route through it', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const geometry = JSON.stringify([[48.1, 11.1], [48.2, 11.2]])
const assignment = { ...assign(7, 1, 0, 48.1, 11.1), tour_place_id: 7, tour_route_geometry: geometry }
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [assignment] } })
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(srcdoc()).toContain('class="trip-map-svg"')
expect(srcdoc()).toContain('stroke="#14805e"')
})
it('filters a participant-hidden Tour before the shared projection and PDF map', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const geometry = JSON.stringify([[48.1, 11.1], [48.2, 11.2]])
const hiddenTour = { ...assign(7, 1, 0, 48.1, 11.1), tour_place_id: 7,
tour_route_geometry: geometry, participants: [{ user_id: 99, username: 'Other' }] }
const visible = { ...assign(8, 1, 1, 48.3, 11.3), participants: [{ user_id: 42, username: 'Reader' }] }
await downloadTripPDF({ ...args, days: [args.days[0]], onlyUserId: 42,
assignments: { '1': [hiddenTour, visible] } })
const input = vi.mocked(routeTrip).mock.calls[0][0]
expect(input.assignments['1'].map(assignment => assignment.id)).toEqual([visible.id])
expect(input.toursEnabled).toBe(true)
expect(srcdoc()).not.toContain('stroke="#14805e"')
expect(srcdoc()).not.toContain('class="trip-map"')
})
it('renders an excluded Tour once while its connector route bridges A to C', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(11, 1, 0, 48.1, 11.1)
const geometry = JSON.stringify([[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]])
const excludedTour = { ...assign(12, 1, 1, 48.2, 11.2), tour_place_id: 12,
tour_route_geometry: geometry, route_excluded: true }
const c = assign(13, 1, 2, 48.4, 11.4)
vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({
coordinates: [[48.1, 11.1], [48.4, 11.4]], distance: 12000, duration: 900,
legs: [leg(12000)],
})
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, excludedTour, c] } })
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.4, 11.4]]])
const routed = await vi.mocked(routeTrip).mock.results[0].value
expect(routed.days[0].tourLines).toEqual([[[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]]])
const html = srcdoc()
expect(html.match(/stroke="#14805e"/g)).toHaveLength(1)
expect(vi.mocked(renderTripMapImage).mock.calls[0][0]).toMatchObject([
{ lines: [[[48.1, 11.1], [48.4, 11.4]]], tourLines: [[[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]]] },
])
})
it('filters hidden service stops before routing or Tour projection', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(41, 1, 0, 48.1, 11.1)
const service = { ...assign(42, 1, 1, 48.2, 11.2), place: { ...place(42, 48.2, 11.2), stop_type: 'fuel' } }
const c = assign(43, 1, 2, 48.4, 11.4)
await downloadTripPDF({ ...args, days: [args.days[0]], showServiceStops: false,
assignments: { '1': [a, service, c] } })
const input = vi.mocked(routeTrip).mock.calls[0][0]
expect(input.assignments['1'].map(assignment => assignment.id)).toEqual([a.id, c.id])
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.4, 11.4]]])
})
it('keeps the legacy Place anchor and does not emit Tour lines when Tours are off', async () => {
const a = assign(21, 1, 0, 48.1, 11.1)
const geometry = JSON.stringify([[48.2, 11.2], [48.3, 11.3]])
const tourPlace = { ...assign(22, 1, 1, 48.25, 11.25), tour_place_id: 22, tour_route_geometry: geometry }
const c = assign(23, 1, 2, 48.4, 11.4)
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, tourPlace, c] } })
expect(vi.mocked(routeTrip).mock.calls[0][0].toursEnabled).toBe(false)
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.25, 11.25], [48.4, 11.4]]])
expect(srcdoc()).not.toContain('stroke="#14805e"')
})
it('prints one localized warning for an invalid Tour and does not invent its outgoing endpoint', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(31, 1, 0, 48.1, 11.1)
const invalidTour = { ...assign(32, 1, 1, 48.2, 11.2), tour_place_id: 32, tour_route_geometry: 'not valid geometry' }
const c = assign(33, 1, 2, 48.4, 11.4)
await expect(downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, invalidTour, c] } })).resolves.toBeUndefined()
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.2, 11.2]]])
expect(srcdoc().match(/tours\.dayRoute\.endpointUnknown/g)).toHaveLength(1)
expect(srcdoc()).toContain('P32')
expect(srcdoc()).not.toContain('stroke="#14805e"')
})
it('FE-COMP-TRIPPDF-MAP-001: puts the route map and its legend in the document', async () => {
await downloadTripPDF(args)
const html = srcdoc()
+7
View File
@@ -12,6 +12,7 @@ import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime,
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { planCosts } from '../Planner/planCosts'
import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry'
import { buildTripMapSvg } from './tripMapSvg'
@@ -271,6 +272,9 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
const unit: DistanceUnit = (_distanceUnit || useSettingsStore.getState().settings.distance_unit) === 'imperial'
? 'imperial' : 'metric'
let tripRoute: TripRouteSummary | null = null
const toursEnabled = useAddonStore.getState().isEnabled('tours')
const hasInvalidTour = toursEnabled && Object.values(assignments).some(list =>
projectDayItinerary(list || [], true, places).some(item => item.kind === 'tour' && !item.valid))
try {
tripRoute = await routeTrip(
{
@@ -279,6 +283,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
reservations,
accommodations: accommodationList,
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
toursEnabled,
places,
},
{ profile: 'driving', tripId: trip.id, timeoutMs: 8000 },
)
@@ -932,6 +938,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
</div>
<!-- Trip map -->
${hasInvalidTour ? `<p role="status" style="color:#b45309">${escHtml(tr('tours.dayRoute.endpointUnknown'))}</p>` : ''}
${tripMapHtml}
<!-- Days -->
+2 -2
View File
@@ -39,8 +39,8 @@ export async function renderTripMapImage(
opts: TripMapImageOptions,
): Promise<string | null> {
const { width, height } = opts
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const points = drawn.flatMap(d => d.lines.flat())
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const points = drawn.flatMap(d => [...d.lines, ...(d.tourLines ?? [])].flat())
if (points.length < 2 || !hasWebGL()) return null
let container: HTMLDivElement | null = null
+10 -4
View File
@@ -53,8 +53,8 @@ function niceDistanceKm(roughKm: number): number {
*/
export function buildTripMapSvg(days: TripOverviewDay[], opts: TripMapSvgOptions): string | null {
const { width, height } = opts
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const points = drawn.flatMap(d => d.lines.flat()).map(([lat, lng]) => projectMercator(lng, lat))
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const points = drawn.flatMap(d => [...d.lines, ...(d.tourLines ?? [])].flat()).map(([lat, lng]) => projectMercator(lng, lat))
if (points.length < 2) return null
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
@@ -135,7 +135,7 @@ export interface RouteOverlayOptions {
export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOptions): string {
const { project, width, height, metresPerPx } = opts
const round = (n: number) => Math.round(n * 10) / 10
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const routes = drawn.flatMap(day => day.lines.filter(l => l.length > 1).map(line => {
const pts = line.map(([lat, lng]) => project(lat, lng).map(round).join(',')).join(' ')
@@ -146,6 +146,12 @@ export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOpt
}))
// Where each run starts and ends — the stops, rather than every vertex of the road.
const tours = drawn.flatMap(day => (day.tourLines ?? []).filter(line => line.length > 1).map(line => {
const pts = line.map(([lat, lng]) => project(lat, lng).map(round).join(',')).join(' ')
return `<polyline points="${pts}" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`
+ `<polyline points="${pts}" fill="none" stroke="#14805e" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>`
}))
const stops = drawn.flatMap(day => day.lines.filter(l => l.length > 1).flatMap(line => {
const ends = [line[0], line[line.length - 1]]
return ends.map(([lat, lng]) => {
@@ -165,7 +171,7 @@ export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOpt
+ ` font-family="Poppins, system-ui, sans-serif">${escapeText(opts.formatDistance(barKm))}</text>`
: ''
return routes.join('') + stops.join('') + scaleBar
return routes.join('') + tours.join('') + stops.join('') + scaleBar
}
/** The scale label is the only text here, and it is a number and a unit — but it comes
@@ -2857,6 +2857,76 @@ describe('DayPlanSidebar', () => {
expect(pairs).toContainEqual([{ lat: 43.66, lng: 7.21 }, { lat: 43.70, lng: 7.26 }])
})
it.each([
{ enabled: true, excluded: false, pairs: [[48.85, 48.86], [48.88, 48.89]] },
{ enabled: true, excluded: true, pairs: [[48.85, 48.89]] },
{ enabled: false, excluded: false, pairs: [[48.85, 48.86], [48.86, 48.89]] },
])('B5 sidebar Tour connector parity enabled=$enabled excluded=$excluded', async ({ enabled, excluded, pairs }) => {
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled }] })
const day = buildDay({ id: 10, date: '2025-06-01' })
const places = [
buildPlace({ id: 1, name: 'Before Tour', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'Assigned Tour', lat: 48.86, lng: 2.35, route_geometry: '[[48.86,2.35],[48.88,2.35]]' }),
buildPlace({ id: 3, name: 'After Tour', lat: 48.89, lng: 2.35 }),
]
const assignments = { '10': places.map((place, order) => buildAssignment({
id: order + 1, day_id: 10, order_index: order, place,
...(order === 1 ? { tour_place_id: 2, tour_route_geometry: places[1].route_geometry, route_excluded: excluded } : {}),
})) }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places, assignments, selectedDayId: 10, routeShown: true })} />)
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(pairs.length))
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(call => call[0].map(point => point.lat))).toEqual(pairs)
expect(screen.getByText('Assigned Tour')).toBeInTheDocument()
})
it('uses Tour boundary overrides for hotel connectors with a driving day default', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled: true }] })
const days = [
buildDay({ id: 1, day_number: 1 }),
buildDay({ id: 2, day_number: 2, default_transport_mode: 'driving' }),
buildDay({ id: 3, day_number: 3 }),
]
const hotel = { id: 90, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 61.001, place_lng: 10.001 } as unknown as Accommodation
const place = buildPlace({ id: 91, name: 'Mode test Tour', lat: 61.01, lng: 10.01, route_geometry: '[[61.01,10.01],[61.02,10.02]]' })
const assignment = buildAssignment({
id: 92, day_id: 2, order_index: 0, place,
tour_place_id: place.id, tour_route_geometry: place.route_geometry,
incoming_leg_transport_mode: 'walking', leg_transport_mode: 'cycling',
})
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [place], assignments: { '2': [assignment] }, accommodations: [hotel],
selectedDayId: 2, routeShown: true, routeProfile: 'driving', optimizeFromAccommodation: true,
})} />)
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(2))
const calls = vi.mocked(calculateRouteWithLegs).mock.calls
expect(calls.map(call => call[1]?.profile)).toEqual(['walking', 'cycling'])
expect(calls.map(call => call[0].map(point => [point.lat, point.lng]))).toEqual([
[[hotel.place_lat, hotel.place_lng], [61.01, 10.01]],
[[61.02, 10.02], [hotel.place_lat, hotel.place_lng]],
])
})
it('does not connect an invalid Tour start to the evening hotel through an unlocated transport', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled: true }] })
const days = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })]
const hotel = { id: 93, trip_id: 1, start_day_id: 1, end_day_id: 3, place_lat: 61.101, place_lng: 10.101 } as unknown as Accommodation
const place = buildPlace({ id: 94, name: 'Invalid Tour', lat: 61.11, lng: 10.11, route_geometry: '{bad' })
const assignment = buildAssignment({ id: 95, day_id: 2, order_index: 0, place,
tour_place_id: place.id, tour_route_geometry: place.route_geometry })
const unlocatedTrain = buildReservation({ id: 96, type: 'train', day_id: 2, day_plan_position: 1, endpoints: [] })
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [place], assignments: { '2': [assignment] }, reservations: [unlocatedTrain],
accommodations: [hotel], selectedDayId: 2, routeShown: true, optimizeFromAccommodation: true,
})} />)
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(1))
expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0].map(point => [point.lat, point.lng]))
.toEqual([[hotel.place_lat, hotel.place_lng], [place.lat, place.lng]])
})
it('FE-PLANNER-DAYPLAN-122: a failing route lookup leaves the day without connectors', async () => {
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('router down'))
@@ -60,6 +60,7 @@ import { findTodayDayId } from './today'
import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
import { resolveLegMode } from './legMode'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
@@ -130,7 +131,7 @@ interface DayPlanSidebarProps {
onCreatePlaceForDay?: (dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
/** `dayIds`: the days the step acts on, so deleting one of them drops it. */
pushUndo?: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[], placeIds?: number[]) => void
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
@@ -233,6 +234,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
@@ -643,6 +645,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
const planDay = (dayId: number) => {
const merged = mergedItemsMap[dayId] || []
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item]))
// Each run point carries the ORIGIN assignment's per-segment travel mode
// (#1281): the leg from this point to the next is routed with `mode` (null =
// inherit the day default). Transport endpoints carry no mode → day default.
@@ -654,8 +657,24 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// flight, not a drive — and surface it as a bogus connector distance (#1394).
let curHasPlace = false
for (const it of merged) {
// A stop out of the route (#2532) is passed by, like a note.
if (it.type === 'place' && it.data.route_excluded) continue
const tour = it.type === 'place' ? projected.get(it.data.id) : undefined
if (tour?.kind === 'tour') {
if (tour.start) {
const prev = cur[cur.length - 1]
if (prev && !prev.isPlace && !withinDriveRange(prev, tour.start)) {
if (cur.length >= 2 && curHasPlace) runs.push(cur)
cur = []
curHasPlace = false
}
cur.push({ id: it.data.id, ...tour.start, isPlace: true, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null })
curHasPlace = true
}
if (cur.length >= 2 && curHasPlace) runs.push(cur)
cur = tour.end ? [{ id: it.data.id, ...tour.end, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null }] : []
curHasPlace = !!tour.end
continue
}
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
// Mirror of the guard below: the open run may hold nothing but a far-away
// arrival endpoint, which is no more drivable in this direction (#2133).
@@ -714,7 +733,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// is one you flew out of or landed at rather than drove between (#2133).
const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng && !it.data.route_excluded) {
if (it.type === 'place' && it.data.route_excluded) continue
const tour = it.type === 'place' ? projected.get(it.data.id) : undefined
if (tour?.kind === 'tour') {
if (tour.start) wayPts.push({ ...tour.start, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null })
if (tour.end) wayPts.push({ ...tour.end, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, leg_transport_mode: it.data.leg_transport_mode ?? null })
continue
}
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, dayId)
@@ -725,14 +751,31 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
const hasRouteWaypoint = (item: typeof merged[number]) => {
if (item.type === 'place') {
if (item.data.route_excluded) return false
const tour = projected.get(item.data.id)
if (tour?.kind === 'tour') return !!tour.start || !!tour.end
return !!item.data.place?.lat && !!item.data.place?.lng
}
if (item.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return !!from || !!to
}
return false
}
const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) =>
!h || !w || w.isPlace || h.place_lat == null || h.place_lng == null
|| withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w)
// Same carrier evidence the map route uses (#2157): with a located carrier
// endpoint on the day, the no-time default must not open a hotel leg.
const dayHasCarrier = wayPts.some(w => w.carrierEdge != null)
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay)
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay)
const firstItem = merged.find(hasRouteWaypoint)
const firstTour = firstItem?.type === 'place' ? projected.get(firstItem.data.id) : undefined
const wantTop = !(firstTour?.kind === 'tour' && !firstTour.start) && !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay)
const lastItem = [...merged].reverse().find(hasRouteWaypoint)
const lastTour = lastItem?.type === 'place' ? projected.get(lastItem.data.id) : undefined
const wantBottom = !(lastTour?.kind === 'tour' && !lastTour.end) && !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay)
return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom }
}
@@ -822,7 +865,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
// effect on the string is equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit])
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -842,7 +885,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Capture previous place order for undo
const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const previousAssignments = assignments[String(dayId)] || []
const prevAssignmentIds = previousAssignments.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const prevPlaceIds = [...new Set(previousAssignments.map(a => a.place?.id).filter((id): id is number => id != null))]
// …and, per booking, the fields this call is about to overwrite, so a failed write
// can put the visible order back instead of leaving a phantom one behind the error
// toast. Restoring the whole array instead would also drop what a collaborator's
@@ -958,7 +1003,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedPrevIds = prevAssignmentIds
pushUndo?.(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}, [capturedDayId])
}, [capturedDayId], prevPlaceIds)
}
} catch (err: unknown) {
rollBackReservations()
@@ -1090,6 +1135,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (da.length < 3) return
const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const placeIds = [...new Set((assignments[String(dayId)] || []).map(a => a.place?.id).filter((id): id is number => id != null))]
// Separate fixed (stay at their index) and movable assignments. A place is
// fixed if it's locked OR has a set time — timed places are anchored by their
@@ -1133,7 +1179,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedDayId = dayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
}, [capturedDayId])
}, [capturedDayId], placeIds)
}
@@ -1364,6 +1410,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const dateFirst = useSettingsStore(s => s.settings.day_date_first === true)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
// Plugin time contributions in the day plan (dayScheduleProvider hook).
const daySchedule = usePluginDaySchedule(S.tripId)
// Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a
@@ -1625,6 +1672,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Where nothing sits above it (the mobile shell), the height still fills the panel.
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0%', minHeight: 0, height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
{toursEnabled && selectedDayId != null && projectDayItinerary(assignments[String(selectedDayId)] || [], true, places).some(item => item.kind === 'tour' && !item.valid) && (
<div role="status" style={{ color: 'var(--warning)', padding: '8px 12px' }}>
<span aria-hidden="true">!</span> {t('tours.dayRoute.endpointUnknown')}
</div>
)}
<DayPlanSidebarToolbar
tripId={tripId}
trip={trip}
@@ -0,0 +1,169 @@
import { act, fireEvent, render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildAssignment, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { PlacesHeader } from './PlacesSidebarHeader'
import { usePlacesSidebar, type PlacesSidebarProps, type SidebarState } from './usePlacesSidebar'
let sidebar: SidebarState
function Host(props: PlacesSidebarProps) {
sidebar = usePlacesSidebar(props)
return (
<div data-testid="pool" onDragEnter={sidebar.handleSidebarDragEnter}
onDragOver={sidebar.handleSidebarDragOver} onDragLeave={sidebar.handleSidebarDragLeave}
onDrop={sidebar.handleSidebarDrop}>
<PlacesHeader {...sidebar} />
{sidebar.filtered.map(place => <span key={place.id} data-testid={`place-${place.id}`}>{place.name}</span>)}
</div>
)
}
function makeProps(overrides: Partial<PlacesSidebarProps> = {}): PlacesSidebarProps {
return {
tripId: 1,
places: [],
categories: [],
assignments: {},
selectedDayId: null,
selectedPlaceId: null,
onPlaceClick: vi.fn(),
onAddPlace: vi.fn(),
onAssignToDay: vi.fn(),
onEditPlace: vi.fn(),
onDeletePlace: vi.fn(),
days: [],
isMobile: false,
...overrides,
}
}
beforeEach(() => {
resetAllStores()
localStorage.removeItem('trek:places-sort')
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
})
afterEach(() => {
localStorage.removeItem('trek:places-sort')
})
describe('B4A Places pool', () => {
it('excludes only parent-identified Tours from rows and every Show count base', () => {
const tour = buildPlace({ id: 1, name: 'Tour', route_geometry: 'track', rating_avg: 5 })
const legacyTrack = buildPlace({ id: 2, name: 'Zebra track', route_geometry: 'track', rating_avg: 2 })
const planned = buildPlace({ id: 3, name: 'Alpha planned', rating_avg: 5 })
const loose = buildPlace({ id: 4, name: 'Beta loose', rating_avg: 2 })
render(<Host {...makeProps({
places: [tour, legacyTrack, planned, loose],
selectedDayId: 7,
assignments: {
'7': [buildAssignment({ day_id: 7, place: tour }), buildAssignment({ day_id: 7, place: planned })],
'8': [buildAssignment({ day_id: 8, place: legacyTrack })],
},
toursEnabled: true,
excludePlaceIds: new Set([1]),
})} />)
expect(sidebar.filtered.map(place => place.id)).toEqual([2, 3, 4])
expect(sidebar.filterCounts).toEqual({ all: 3, unplanned: 1, planned: 1, tracks: 1 })
expect(sidebar.hasTracks).toBe(false)
act(() => sidebar.setPlacesSort('name'))
expect(sidebar.filtered.map(place => place.id)).toEqual([3, 4, 2])
act(() => sidebar.setLocalityFilter({ country: 'No matching country', region: null }))
expect(sidebar.filtered).toEqual([])
act(() => { sidebar.setLocalityFilter(null); sidebar.setRatingFilter(5) })
expect(sidebar.filtered.map(place => place.id)).toEqual([3])
expect(sidebar.filterCounts.all).toBe(3)
act(() => { sidebar.setRatingFilter('all'); sidebar.pickFilter('planned') })
expect(sidebar.filtered.map(place => place.id)).toEqual([3])
act(() => sidebar.pickFilter('unplanned'))
expect(sidebar.filtered.map(place => place.id)).toEqual([4])
})
it('applies category and search to the excluded count base and reacts to a replacement exclusion set', () => {
const props = makeProps({
toursEnabled: true,
excludePlaceIds: new Set([1]),
places: [
buildPlace({ id: 1, name: 'Matching tour', category_id: 7 }),
buildPlace({ id: 2, name: 'Matching place', category_id: 7 }),
buildPlace({ id: 3, name: 'Matching other category', category_id: 8 }),
buildPlace({ id: 4, name: 'Other place', category_id: 7 }),
],
})
const { rerender } = render(<Host {...props} />)
act(() => { sidebar.setCategoryFilters(new Set(['7'])); sidebar.setSearch('matching') })
expect(sidebar.filtered.map(place => place.id)).toEqual([2])
expect(sidebar.filterCounts).toEqual({ all: 1, unplanned: 1, planned: 0, tracks: 0 })
rerender(<Host {...props} excludePlaceIds={new Set([1, 2])} />)
expect(sidebar.filtered).toEqual([])
expect(sidebar.filterCounts.all).toBe(0)
})
it('restores legacy rows and Tracks while off, even when the parent retains the exclusion set', () => {
const props = makeProps({
places: [buildPlace({ id: 1, name: 'Tour', route_geometry: 'track' }), buildPlace({ id: 2, name: 'Place' })],
toursEnabled: true,
excludePlaceIds: new Set([1]),
})
const { rerender } = render(<Host {...props} />)
expect(sidebar.filtered.map(place => place.id)).toEqual([2])
rerender(<Host {...props} toursEnabled={false} />)
expect(sidebar.filtered.map(place => place.id)).toEqual([1, 2])
expect(sidebar.filterCounts.all).toBe(2)
expect(sidebar.hasTracks).toBe(true)
act(() => sidebar.pickFilter('tracks'))
expect(sidebar.filtered.map(place => place.id)).toEqual([1])
rerender(<Host {...props} />)
expect(sidebar.filter).toBe('all')
expect(sidebar.filtered.map(place => place.id)).toEqual([2])
})
it('blocks Places file drops while Tours is on and restores them while off', () => {
const props = makeProps({ toursEnabled: true })
const { rerender } = render(<Host {...props} />)
const file = new File(['track'], 'route.gpx', { type: 'application/gpx+xml' })
fireEvent.dragEnter(screen.getByTestId('pool'))
expect(sidebar.sidebarDragOver).toBe(false)
fireEvent.drop(screen.getByTestId('pool'), { dataTransfer: { files: [file] } })
expect(sidebar.fileImportOpen).toBe(false)
expect(sidebar.sidebarDropFile).toBeNull()
rerender(<Host {...props} toursEnabled={false} />)
fireEvent.dragEnter(screen.getByTestId('pool'))
expect(sidebar.sidebarDragOver).toBe(true)
fireEvent.drop(screen.getByTestId('pool'), { dataTransfer: { files: [file] } })
expect(sidebar.fileImportOpen).toBe(true)
expect(sidebar.sidebarDropFile).toBe(file)
})
it('keeps list import in Places while Tours is on and restores the file menu while off', async () => {
const user = userEvent.setup()
const props = makeProps({ toursEnabled: true })
const { rerender } = render(<Host {...props} />)
await user.click(screen.getByRole('button', { name: 'Import Places' }))
expect(screen.queryByRole('button', { name: sidebar.t('places.importFile') })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: sidebar.t('places.importList') }))
expect(sidebar.listImportOpen).toBe(true)
expect(sidebar.fileImportOpen).toBe(false)
rerender(<Host {...props} toursEnabled={false} />)
await user.click(screen.getByRole('button', { name: 'Import Places' }))
await user.click(screen.getByRole('button', { name: sidebar.t('places.importFile') }))
expect(sidebar.fileImportOpen).toBe(true)
})
it.each([true, false])('exposes no AddRow actions without place-edit permission (Tours=%s)', async toursEnabled => {
seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } })
const props = makeProps({ toursEnabled, selectedDayId: 7, onAddPlaceToSelectedDay: vi.fn() })
render(<Host {...props} />)
expect(sidebar.canEditPlaces).toBe(false)
expect(screen.queryByRole('button', { name: 'Import Places' })).not.toBeInTheDocument()
expect(screen.queryByTestId('add-place-to-day')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Add Place|New place/i })).not.toBeInTheDocument()
expect(props.onAddPlace).not.toHaveBeenCalled()
expect(props.onAddPlaceToSelectedDay).not.toHaveBeenCalled()
})
})
@@ -409,7 +409,7 @@ function LocalityFilterDropdown(S: SidebarState) {
}
function AddRow(S: SidebarState) {
const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders } = S
const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, toursEnabled } = S
const dayOpen = selectedDayId != null
const split = dayOpen && !!onAddPlaceToSelectedDay
const { ref: rowRef, width: rowWidth } = useElementSize<HTMLDivElement>()
@@ -419,7 +419,7 @@ function AddRow(S: SidebarState) {
const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')
const importMenu = useContextMenu()
const importItems = [
{ label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) },
...(!toursEnabled ? [{ label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) }] : []),
{ label: t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList'), icon: ListPlus, onClick: () => setListImportOpen(true) },
]
return (
@@ -65,6 +65,13 @@ export interface PlacesSidebarProps {
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
/**
* With the Tours addon on, file import belongs to Tours mode, list import
* stays here, and the "Tracks" chip is hidden because tracks are tours.
*/
toursEnabled?: boolean
/** Places that are tours (a `tours` facet row exists) — hidden from the pool entirely while tours is on. */
excludePlaceIds?: Set<number>
}
/**
@@ -77,6 +84,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
pushUndo, initialScrollTop, onScrollTopChange, onEditPlace, onAssignToDay, onDeletePlace,
} = props
const poolPlaces = useMemo(
() => props.toursEnabled && props.excludePlaceIds ? places.filter(p => !props.excludePlaceIds!.has(p.id)) : places,
[places, props.toursEnabled, props.excludePlaceIds],
)
const { t, language } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
@@ -107,14 +118,14 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
}, [])
const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return
if (!canEditPlaces || props.toursEnabled) return
e.preventDefault()
sidebarDragCounter.current++
setSidebarDragOver(true)
}
const handleSidebarDragOver = (e: React.DragEvent) => {
if (!canEditPlaces) return
if (!canEditPlaces || props.toursEnabled) return
e.preventDefault()
}
@@ -127,7 +138,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
e.preventDefault()
sidebarDragCounter.current = 0
setSidebarDragOver(false)
if (!canEditPlaces) return
if (!canEditPlaces || props.toursEnabled) return
const f = e.dataTransfer.files[0]
if (!f) return
setSidebarDropFile(f)
@@ -195,7 +206,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
// like the rating floor.
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
const localities = useMemo(() => localityGroups(places.map(p => localityOf.get(p.id)!)), [places, localityOf])
const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf])
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
@@ -257,7 +268,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) }
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
const hasTracks = useMemo(
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
[poolPlaces, props.toursEnabled],
)
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(
@@ -286,7 +300,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const dayScoped = filter === 'planned' && plannedInDayIds !== null
const filtered = useMemo(() => {
const list = places.filter(p => {
const list = poolPlaces.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
@@ -301,7 +315,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
})
return sortPlaces(list, placesSort, language)
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter, localityFilter, localityOf, placesSort, language])
}, [poolPlaces, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter, localityFilter, localityOf, placesSort, language])
/**
* How many places each "show" choice would leave, under the category and search
@@ -311,7 +325,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
const base = places.filter(p => {
const base = poolPlaces.filter(p => {
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
@@ -325,7 +339,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
planned: base.filter(p => plannedFilterIds.has(p.id)).length,
tracks: base.filter(p => p.route_geometry).length,
} satisfies Record<PlacesFilter, number>
}, [places, categoryFilters, search, plannedIds, plannedFilterIds])
}, [poolPlaces, categoryFilters, search, plannedIds, plannedFilterIds])
/** The filters narrowing the list besides the search box, for the badge on the filter button. */
@@ -0,0 +1,50 @@
import React from 'react'
import { MapPin, Mountain } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { NEUTRAL_TINT } from '../shared/DialogShell'
interface PlacesToursModeSwitchProps {
active: boolean
onChange: (tours: boolean) => void
}
/**
* Switches the right add-panel between the ordinary Places sidebar and the
* Tours selection list. Top level of the panel, mirroring RoadtripModeSwitch
* (Days <-> Roadtrip), using the same placement and addon gating convention.
* Only rendered while the tours addon is on.
*/
export default function PlacesToursModeSwitch({ active, onChange }: PlacesToursModeSwitchProps): React.ReactElement {
const { t } = useTranslation()
const options: [boolean, string, typeof MapPin][] = [
[false, t('tours.mode.places'), MapPin],
[true, t('tours.mode.tours'), Mountain],
]
return (
<div className="flex-none px-3 pb-1 pt-3" style={{ background: NEUTRAL_TINT }}>
<div role="tablist" aria-label={t('tours.mode.label')} className="flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1">
{options.map(([value, label, Icon]) => {
const selected = active === value
return (
<button
key={label}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(value)}
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
selected
? 'bg-surface-card font-semibold text-content shadow-card'
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
}`}
>
<Icon size={13} strokeWidth={1.8} aria-hidden />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TourDetailDialog from './TourDetailDialog'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
const place = buildPlace({
id: 42,
name: 'Ridge walk',
lat: 48,
lng: 11,
notes: 'Bring water',
route_geometry: JSON.stringify([
[48, 11, 500],
[48, 11.01, 550],
[48, 11.03, 520],
]),
})
const tour: TourListItem = {
place_id: 42,
name: 'Ridge walk',
tour_type: 'hike',
distance: 999,
elevation_gain: 999,
elevation_loss: 999,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
const defaultProps = {
canEdit: true,
canAssign: true,
tour,
place,
onClose: vi.fn(),
onUpdatePlace: vi.fn().mockResolvedValue(undefined),
onAssignToDay: vi.fn(),
onRemoveAssignment: vi.fn(),
onDelete: vi.fn(),
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
describe('TourDetailDialog', () => {
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
const opener = document.createElement('button')
opener.textContent = 'Open Tour'
document.body.appendChild(opener)
const onClose = vi.fn()
function DesktopDetail() {
const [open, setOpen] = useState(true)
return open ? <TourDetailDialog {...defaultProps} desktopNonModal desktopFocusReturnTarget={opener}
onClose={() => { onClose(); setOpen(false) }} /> : null
}
const view = render(<DesktopDetail />)
const close = screen.getByRole('button', { name: 'Close' })
expect(close).toHaveClass('focus-visible:outline')
expect(document.querySelector('[aria-modal="true"]')).toBeNull()
expect(document.querySelector('.trek-modal-backdrop')).toBeNull()
expect(document.body.style.overflow).toBe('')
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
close.dispatchEvent(tab)
expect(tab.defaultPrevented).toBe(false)
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByText('Ridge walk')).not.toBeInTheDocument())
expect(onClose).toHaveBeenCalledTimes(1)
expect(document.activeElement).toBe(opener)
view.unmount()
document.body.removeChild(opener)
})
it('shows only curated tour content and uses live geometry instead of cached tour metrics', () => {
render(<TourDetailDialog {...defaultProps} />)
expect(screen.getByText('Ridge walk')).toBeInTheDocument()
expect(screen.getByText('Minimum altitude')).toBeInTheDocument()
expect(screen.getByText('Maximum altitude')).toBeInTheDocument()
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('Descent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
expect(screen.getByText('30 m')).toBeInTheDocument()
expect(screen.queryByText('999 m')).not.toBeInTheDocument()
expect(screen.getByText('Bring water')).toBeInTheDocument()
expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument()
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
expect(defaultProps.onRemoveAssignment).toHaveBeenCalledWith(7, 9)
})
it('requires place-edit permission for deletion while retaining day-edit assignment removal', () => {
render(<TourDetailDialog
{...defaultProps}
canEdit={false}
canAssign={true}
days={[{ id: 7 } as never]}
selectedDayId={7}
assignments={{ '7': [{ id: 9, place }] as never }}
/>)
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
expect(defaultProps.onRemoveAssignment).toHaveBeenCalledWith(7, 9)
expect(defaultProps.onDelete).not.toHaveBeenCalled()
})
it('renames through the supplied place update path', async () => {
render(<TourDetailDialog {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
const input = screen.getByDisplayValue('Ridge walk')
fireEvent.change(input, { target: { value: 'New route name' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' })
})
it('labels GPX imports as view-only planner content', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
expect(screen.getByText('GPX import')).toBeInTheDocument()
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
})
})
@@ -0,0 +1,288 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, ArrowDown, ArrowUp, Check, Clock, File, FileImage, FileText, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Ruler, Trash2, Upload } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import type { TourListItem } from '@trek/shared'
import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types'
import { useTranslation, translateApiError } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
import TrackColorPicker from '../shared/TrackColorPicker'
import DetailShell from '../shared/DetailShell'
import ElevationProfile from '../shared/ElevationProfile'
import { NavigationMenu } from '../shared/NavigationMenu'
import { markdownLinkComponents } from '../shared/markdownLink'
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import { filesForPlace } from '../../utils/placeFiles'
import { openFile } from '../../utils/fileDownload'
import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation'
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
interface TourDetailDialogProps extends TourPermissionProps {
tour: TourListItem
place: Place
days?: Day[]
selectedDayId?: number | null
selectedAssignmentId?: number | null
assignments?: AssignmentsMap
files?: TripFile[]
onClose: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => Promise<void> | void
onFileUpload?: (formData: FormData) => Promise<unknown>
onAssignToDay?: (placeId: number, dayId?: number) => void
onRemoveAssignment?: (dayId: number, assignmentId: number) => void
onDelete?: () => void
leftWidth?: number
rightWidth?: number
desktopNonModal?: boolean
readOnly?: boolean
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
desktopFocusReturnTarget?: HTMLElement | null
}
function formatFileSize(bytes: number | null | undefined): string {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function assignmentForSelectedDay(
assignments: AssignmentsMap,
selectedDayId: number | null,
selectedAssignmentId: number | null,
placeId: number,
): Assignment | null {
if (selectedDayId == null) return null
const dayAssignments = assignments[String(selectedDayId)] || []
return (selectedAssignmentId ? dayAssignments.find(assignment => assignment.id === selectedAssignmentId) : null)
?? dayAssignments.find(assignment => assignment.place?.id === placeId)
?? null
}
export default function TourDetailDialog({
tour,
place,
selectedDayId = null,
selectedAssignmentId = null,
assignments = {},
files = [],
onClose,
onUpdatePlace,
onFileUpload,
onAssignToDay,
onRemoveAssignment,
onDelete,
leftWidth = 0,
rightWidth = 0,
desktopNonModal = false,
readOnly = false,
desktopFocusReturnTarget = null,
canEdit: editPermission,
canAssign: assignPermission,
}: TourDetailDialogProps) {
const { canEdit: hasEditPermission, canAssign } = useTourPermissions({ tripId: place.trip_id, canEdit: editPermission, canAssign: assignPermission })
const canEdit = hasEditPermission && !readOnly
const { t } = useTranslation()
const toast = useToast()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [editingName, setEditingName] = useState(false)
const [nameDraft, setNameDraft] = useState(place.name)
const [savingName, setSavingName] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [uploading, setUploading] = useState(false)
const [navigationOpen, setNavigationOpen] = useState(false)
const navigationButtonRef = useRef<HTMLButtonElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const closeRef = useRef(onClose)
closeRef.current = onClose
const focusReturnRef = useRef(desktopFocusReturnTarget)
focusReturnRef.current = desktopFocusReturnTarget
useEffect(() => {
if (!desktopNonModal) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
closeRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
const opener = focusReturnRef.current
if (opener?.isConnected) opener.focus()
}
}, [desktopNonModal])
const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry])
const trackColor = resolveTrackColor(place)
const inheritedColor = inheritedTrackColor(place)
const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id)
const placeFiles = filesForPlace(files, place.id, [], assignment ? [assignment.id] : [])
const navigationTargets = getNavigationTargets(place)
const source = tourSource(tour)
const saveName = async () => {
if (!canEdit) return
const name = nameDraft.trim()
if (!name || name === place.name || !onUpdatePlace) { setEditingName(false); return }
setSavingName(true)
try {
await onUpdatePlace(place.id, { name })
setEditingName(false)
} catch (error: unknown) {
toast.error(translateApiError(t, error, 'common.error'))
} finally {
setSavingName(false)
}
}
const uploadFiles = async (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
event.target.value = ''
if (!canEdit || !onFileUpload || selectedFiles.length === 0) return
setUploading(true)
try {
for (const file of selectedFiles) {
const formData = new FormData()
formData.append('file', file)
formData.append('place_id', String(place.id))
await onFileUpload(formData)
}
setFilesExpanded(true)
} catch (error: unknown) {
toast.error(translateApiError(t, error, 'files.uploadError'))
} finally {
setUploading(false)
}
}
const header = canEdit && editingName ? (
<div style={{ display: 'flex', gap: 6, flex: 1, minWidth: 0 }}>
<input
autoFocus
value={nameDraft}
onChange={event => setNameDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') void saveName()
if (event.key === 'Escape') { setNameDraft(place.name); setEditingName(false) }
}}
className="border border-edge bg-surface-secondary text-content"
style={{ flex: 1, minWidth: 0, borderRadius: 8, padding: '6px 10px', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}
/>
<button type="button" onClick={() => void saveName()} disabled={savingName} className="text-accent" style={{ padding: 6 }} aria-label={t('common.save')}>
<Check size={18} strokeWidth={2} />
</button>
</div>
) : (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h2 className="text-content" style={{ minWidth: 0, fontSize: 'calc(18px * var(--fs-scale-title, 1))', fontWeight: 700, margin: 0 }}>{place.name}</h2>
{canEdit && onUpdatePlace && (
<button type="button" onClick={() => { setNameDraft(place.name); setEditingName(true) }} className="text-content-faint shrink-0" style={{ padding: 2, background: 'none', border: 'none', cursor: 'pointer' }} aria-label={t('common.edit')}>
<Pencil size={14} strokeWidth={2} />
</button>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-content-muted">
<span className="rounded-md border border-edge bg-surface-tertiary px-1.5 py-0.5">{t(`tourTypes.${tour.tour_type}`)}</span>
{tour.difficulty && <span>{t('tours.detail.difficulty')}: {tour.difficulty}</span>}
{tour.duration != null && <span className="inline-flex items-center gap-1"><Clock size={11} />{t('tours.durationMinutes', { count: tour.duration })}</span>}
{tour.caution && <span className="inline-flex items-center gap-1 text-warning"><AlertTriangle size={11} />{t('tours.caution.badge')}</span>}
<span className="rounded-md border border-edge bg-surface-tertiary px-1.5 py-0.5">{hikeSourceBadgeLabel(tour, t)}</span>
</div>
</div>
)
const footer = <>
{canAssign && selectedDayId != null && (assignment ? onRemoveAssignment && (
<TourActionButton onClick={() => { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={<Minus size={13} />} label={t('inspector.removeFromDay')} />
) : (
onAssignToDay && <TourActionButton onClick={() => { if (canAssign) onAssignToDay(place.id) }} icon={<Plus size={13} />} label={t('inspector.addToDay')} primary />
))}
{navigationTargets.length > 0 && (
<>
<TourActionButton
buttonRef={navigationButtonRef}
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavigationOpen(open => !open)
}}
icon={<Navigation size={13} />}
label={navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
/>
{navigationOpen && <NavigationMenu targets={navigationTargets} anchor={navigationButtonRef.current} onClose={() => setNavigationOpen(false)} />}
</>
)}
<div style={{ flex: 1 }} />
{canEdit && onDelete && <TourActionButton onClick={() => { if (canEdit) onDelete() }} icon={<Trash2 size={13} />} label={t('common.delete')} danger />}
</>
return (
<DetailShell header={header} footer={footer} onClose={onClose} closeLabel={t('common.close')} leftWidth={leftWidth} rightWidth={rightWidth}
closeButtonClassName="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2">
{source === 'gpx' && (
<div className="rounded-lg border border-edge bg-surface-hover px-3 py-2 text-xs text-content-secondary">
{t('tours.detail.gpxReadOnly')}
{/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */}
</div>
)}
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('inspector.trackStats')}</div>
{analysis && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<TourMetric label={t('tours.detail.distance')} value={formatDistance(analysis.distanceKm, distanceUnit)} icon={<Ruler size={13} />} />
{analysis.minEle != null && <TourMetric label={t('tours.detail.minAltitude')} value={formatElevation(analysis.minEle, distanceUnit)} icon={<Mountain size={13} />} />}
{analysis.maxEle != null && <TourMetric label={t('tours.detail.maxAltitude')} value={formatElevation(analysis.maxEle, distanceUnit)} icon={<Mountain size={13} />} />}
{analysis.gain != null && <TourMetric label={t('tours.detail.ascent')} value={formatElevation(analysis.gain, distanceUnit)} icon={<ArrowUp size={13} />} />}
{analysis.loss != null && <TourMetric label={t('tours.detail.descent')} value={formatElevation(analysis.loss, distanceUnit)} icon={<ArrowDown size={13} />} />}
</div>
<ElevationProfile samples={analysis.distanceIndexedProfileSamples} color={trackColor} gradientId={`tour-elevation-${place.id}`} />
</>
)}
</div>
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px' }}>
<p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, margin: '0 0 6px' }}>{t('tours.detail.trackColor')}</p>
{canEdit && onUpdatePlace ? <TrackColorPicker value={place.route_color ?? null} inheritedColor={inheritedColor} onChange={color => { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> : <span aria-label={t('tours.detail.trackColor')} style={{ display: 'block', width: 20, height: 20, borderRadius: 4, background: trackColor }} />}
</div>
{(place.description || place.notes || assignment?.notes) && (
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{place.description && <div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown></div>}
{place.notes && <div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.notes}</Markdown></div>}
{assignment?.notes && <div><div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('places.assignmentNotes')}</div><div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{assignment.notes}</Markdown></div></div>}
</div>
)}
{(placeFiles.length > 0 || (canEdit && onFileUpload)) && (
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button type="button" onClick={() => setFilesExpanded(expanded => !expanded)} style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}>
<FileText size={13} color="var(--text-faint)" />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{placeFiles.length ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}</span>
</button>
{canEdit && onFileUpload && <label className="text-content-muted bg-surface-tertiary" style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', padding: '2px 6px', borderRadius: 6 }}><input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={uploadFiles} />{uploading ? '…' : <><Upload size={11} /> {t('common.upload')}</>}</label>}
</div>
{filesExpanded && placeFiles.length > 0 && <div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>{placeFiles.map(file => <button type="button" key={file.id} onClick={() => void openFile(file.url)} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>{(file.mime_type || '').startsWith('image/') ? <FileImage size={12} /> : <File size={12} />}<span className="text-content-secondary" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.original_name}</span><span className="text-content-faint">{formatFileSize(file.file_size)}</span></button>)}</div>}
</div>
)}
{navigationTargets.length > 0 && <div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}><MapPin size={12} />{t('tours.detail.navigationHint')}</div>}
</DetailShell>
)
}
function TourMetric({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) {
return <div><div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{icon}{label}</div><div className="text-content" style={{ marginTop: 2, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{value}</div></div>
}
function TourActionButton({ buttonRef, onClick, icon, label, primary = false, danger = false }: { buttonRef?: React.Ref<HTMLButtonElement>; onClick: () => void; icon: React.ReactNode; label: string; primary?: boolean; danger?: boolean }) {
const className = primary ? 'bg-accent text-accent-text' : danger ? 'text-danger hover:bg-danger-subtle' : 'text-content-secondary hover:bg-surface-hover'
return <button ref={buttonRef} type="button" onClick={onClick} className={className} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, background: primary ? undefined : 'transparent' }}>{icon}<span className="hidden sm:inline">{label}</span></button>
}
@@ -0,0 +1,81 @@
import type { ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { hikeSourceBadgeLabel } from './tourPresentation'
interface TourListRowProps {
tour: TourListItem
selected?: boolean
disabled?: boolean
onSelect: (tour: TourListItem, opener: HTMLElement) => void
action?: ReactNode
children?: ReactNode
}
/** Shared Tours list row used by both the ADD flow and the planner workspace. */
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
return (
<li
role="option"
tabIndex={disabled ? -1 : 0}
aria-selected={selected}
aria-disabled={disabled || undefined}
className={`rounded-xl border border-edge p-3 ${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}`}
style={{ background: selected ? 'var(--border-faint)' : 'transparent', transition: 'background 0.1s' }}
onClick={event => { if (!disabled) onSelect(tour, event.currentTarget) }}
onKeyDown={event => {
if (disabled) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect(tour, event.currentTarget)
}
}}
onMouseEnter={event => { if (!selected) event.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={event => { if (!selected) event.currentTarget.style.background = 'transparent' }}
>
<div className="min-w-0">
<span className="block min-w-0 line-clamp-2 break-words font-medium text-content" title={tour.name}>{tour.name}</span>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-content-secondary">
<span className="shrink-0 rounded-md border border-edge bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
{hikeSourceBadgeLabel(tour, t)}
</span>
<span
title={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
aria-label={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
className="shrink-0 rounded-md border border-edge bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary"
>T{tour.max_hiking_difficulty}</span>
{tour.caution && (
<span title={t('tours.caution.tooltip')} aria-label={t('tours.caution.badge')} className="shrink-0 inline-flex items-center gap-0.5 rounded-md border border-amber-300/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<AlertTriangle className="h-2.5 w-2.5" strokeWidth={2} /> {t('tours.caution.badge')}
</span>
)}
</div>
<div data-testid="tour-metrics" className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-content-secondary">
<span className="whitespace-nowrap" aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, distanceUnit)}`}>
{formatDistance(tour.distance ?? 0, distanceUnit)}
</span>
{tour.elevation_gain != null && (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, distanceUnit)}`}>
<ArrowUp className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
{formatElevation(tour.elevation_gain, distanceUnit)}
</span>
)}
{tour.elevation_loss != null && (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, distanceUnit)}`}>
<ArrowDown className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
{formatElevation(tour.elevation_loss, distanceUnit)}
</span>
)}
</div>
{action && <div data-testid="tour-action-row" className="mt-2 w-full [&>button]:w-full [&>button]:justify-center">{action}</div>}
</div>
{children}
</li>
)
}
@@ -0,0 +1,202 @@
import { act, fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { toursApi } from '../../api/client'
import ToursSidebar from './ToursSidebar'
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
const tour: TourListItem = {
place_id: 42,
name: 'Selected ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
describe('ToursSidebar', () => {
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'Show' })
expect(trigger).toHaveTextContent('All')
expect(trigger).toHaveTextContent('2')
fireEvent.click(trigger)
const choices = screen.getByRole('group', { name: 'Show' })
expect(within(choices).getByRole('button', { name: /All\s*2/ })).toHaveAttribute('aria-pressed', 'true')
expect(within(choices).getByRole('button', { name: /Unplanned\s*1/ })).toBeInTheDocument()
expect(within(choices).getByRole('button', { name: /Planned\s*1/ })).toBeInTheDocument()
})
it('uses the place selection id for the same selected background as a place row', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={42} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
expect(row).toHaveAttribute('aria-selected', 'true')
expect(row).toHaveStyle({ background: 'var(--border-faint)' })
})
it('selects the tour from keyboard activation', () => {
const onSelectTour = vi.fn()
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={null} onAssignToDay={vi.fn()} onSelectTour={onSelectTour} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
fireEvent.keyDown(row, { key: 'Enter' })
expect(onSelectTour).toHaveBeenCalledWith(tour, row)
})
it.each([
['en', 'A deliberately long English hiking tour name that should wrap without pushing its metrics aside'],
['de', 'Eine außergewöhnlich lange deutsche Wanderung mit ausführlicher Bezeichnung für schmale Seitenleisten'],
])('keeps %s title, badges, atomic metrics and full-width action in separate rows', (language, name) => {
seedStore(useSettingsStore, { settings: { language, distance_unit: 'metric' } })
const onAssignToDay = vi.fn()
const { container } = render(<ToursSidebar
canEdit={true}
canAssign={true}
tripId={1}
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
tours={[{ ...tour, name, distance: 1.9, elevation_gain: 13, elevation_loss: 11, has_waypoints: true }]}
selectedPlaceId={42}
onAssignToDay={onAssignToDay}
onSelectTour={vi.fn()}
/>)
const row = screen.getByRole('option', { name: new RegExp(name) })
expect(row).toHaveAttribute('aria-selected', 'true')
expect(row.querySelector('span[title]')).toHaveAttribute('title', name)
const metrics = row.querySelector('[data-testid="tour-metrics"]')!
expect(metrics).toHaveTextContent('1.9 km')
expect(metrics).toHaveTextContent('13 m')
expect(metrics).toHaveTextContent('11 m')
for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap')
expect(metrics.children[0].getAttribute('aria-label')).toContain('1.9 km')
expect(metrics.children[1].getAttribute('aria-label')).toContain('13 m')
expect(metrics.children[2].getAttribute('aria-label')).toContain('11 m')
expect(screen.getByText('TREK')).toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow).toHaveClass('w-full')
expect(actionRow.querySelector('button')).toHaveClass('w-full')
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()
})
it('leaves the post-write refresh to the shared assignment handler', async () => {
let resolveAssignment!: () => void
const assignment = new Promise<void>(resolve => { resolveAssignment = resolve })
const onAssignToDay = vi.fn(() => assignment)
const onToursChanged = vi.fn()
render(<ToursSidebar
canEdit={true}
canAssign={true}
tripId={1}
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
tours={[tour]}
onAssignToDay={onAssignToDay}
onToursChanged={onToursChanged}
onSelectTour={vi.fn()}
/>)
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
fireEvent.click(screen.getByRole('button', { name: /Day one/ }))
expect(onAssignToDay).toHaveBeenCalledWith(42, 7)
expect(onToursChanged).not.toHaveBeenCalled()
await act(async () => { resolveAssignment(); await assignment })
expect(onToursChanged).not.toHaveBeenCalled()
})
it('keeps the day picker open and does not refresh when assignment fails', async () => {
const onToursChanged = vi.fn()
render(<ToursSidebar
canAssign={true}
tripId={1}
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
tours={[tour]}
onAssignToDay={vi.fn().mockResolvedValue(false)}
onToursChanged={onToursChanged}
onSelectTour={vi.fn()}
/>)
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
fireEvent.click(screen.getByRole('button', { name: /Day one/ }))
await waitFor(() => expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument())
expect(onToursChanged).not.toHaveBeenCalled()
})
it('quarantines returned Tour ids before loading the imported Places', async () => {
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
tours: [tour], caution: false, skipped: 0,
})
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
canEdit={true}
onAssignToDay={vi.fn()}
onToursChanged={onToursChanged}
onSelectTour={vi.fn()}
/>)
const input = container.querySelector('input[type="file"]')!
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
fireEvent.change(input, { target: { files: [file] } })
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
expect(onToursChanged).toHaveBeenCalledWith([42])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0])
expect(importCall).toHaveBeenCalledOnce()
vi.restoreAllMocks()
})
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
canEdit={true}
onAssignToDay={vi.fn()}
onToursChanged={onToursChanged}
onSelectTour={vi.fn()}
/>)
fireEvent.change(container.querySelector('input[type=file]')!, {
target: { files: [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' })] },
})
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('All places were already in the trip.'))
expect(onToursChanged).not.toHaveBeenCalled()
expect(loadTrip).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
})
@@ -0,0 +1,225 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, FileDown, Mountain } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { toursApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import TourListRow from './TourListRow'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
const MAX_FILE_BYTES = 10 * 1024 * 1024
interface ToursSidebarProps extends TourPermissionProps {
tripId: number
days: Day[]
tours: TourListItem[]
loading?: boolean
selectedPlaceId?: number | null
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
pushUndo?: (label: string, undo: () => void | Promise<void>) => void
/** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */
onToursChanged?: (placeIds?: number[]) => void | Promise<void>
/** Tour row was clicked — caller will render the detail modal. */
onSelectTour?: (tour: TourListItem | null, opener?: HTMLElement) => void
}
function dayLabel(day: Day, index: number, t: (key: string, params?: Record<string, unknown>) => string): string {
if (day.title) return day.title
return t('dayplan.dayN', { n: index + 1 })
}
/**
* Tours mode of the right add-panel: a selection list of existing tours
* with GPX import and day assignment.
*
* Reuses the existing day-assignment mechanism as-is: `onAssignToDay` is the
* same `handleAssignToDay` PlacesSidebar calls, so a tour lands on a day
* exactly like a place does, and its route line renders on the day map
* without any Tours-specific rendering code.
*/
export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement {
const { t } = useTranslation()
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const toast = useToast()
const loadTrip = useTripStore(s => s.loadTrip)
const [filter, setFilter] = useState<'all' | 'unplanned' | 'planned'>('all')
const [importing, setImporting] = useState(false)
const [pickerOpenFor, setPickerOpenFor] = useState<number | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!canEdit || !file) return
const ext = file.name.toLowerCase().split('.').pop()
if (ext !== 'gpx') { toast.error(t('places.importFileUnsupported')); return }
if (file.size > MAX_FILE_BYTES) { toast.error(t('places.importFileTooLarge', { maxMb: 10 })); return }
setImporting(true)
try {
const result = await toursApi.importGpx(tripId, file)
if (result.tours.length > 0) {
// The server publishes place:created with sender echo suppression.
// Reload the importing client's trip so new places and route geometry
// enter the shared store used by the day map.
await onToursChanged?.(result.tours.map(tour => tour.place_id))
await loadTrip(tripId)
toast.success(t(result.tours.length === 1 ? 'tours.import.successOne' : 'tours.import.success', { count: result.tours.length }))
if (result.caution) toast.info(t('tours.import.caution'))
} else {
toast.warning(t('places.importAllSkipped'))
}
} catch (err: any) {
const message = err?.response?.data?.error
toast.error(message === 'No track or route found in GPX file' ? t('tours.import.noTrack') : t('tours.import.error'))
} finally {
setImporting(false)
}
}
const handleAssign = async (tour: TourListItem, day: Day, index: number) => {
if (!canAssign) return
const assigned = await onAssignToDay(tour.place_id, day.id)
if (assigned === false) return
setPickerOpenFor(null)
toast.success(t('tours.addedToDay', { n: index + 1 }))
}
const counts = {
all: tours.length,
unplanned: tours.filter(tr => !tr.planned).length,
planned: tours.filter(tr => tr.planned).length,
}
const filtered = tours.filter(tr => {
if (filter === 'unplanned') return !tr.planned
if (filter === 'planned') return tr.planned
return true
})
useEffect(() => {
if (!filterOpen) return
const onDown = (event: MouseEvent) => {
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.stopPropagation()
setFilterOpen(false)
filterButtonRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [filterOpen])
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
const filterCount = counts[filter]
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: NEUTRAL_TINT }}>
{/* GPX import stays — the tour-appropriate import path. List Import
(place/route-of-driving oriented) is deliberately absent here. */}
<div>
<Tooltip label={t('places.importFile')}>
<button type="button" onClick={handleImportClick} disabled={!canEdit || importing}
className="flex h-8 w-full min-w-0 items-center justify-center gap-1.5 overflow-hidden rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12.5, 'body')}>
<FileDown size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('places.importFile')}</span>
</button>
</Tooltip>
<input ref={fileInputRef} type="file" accept=".gpx" onChange={handleFileChange} style={{ display: 'none' }} />
</div>
<div ref={filterRef} className="relative min-w-0">
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}>
<span className="min-w-0 truncate text-left">{filterLabel}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`ml-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
</button>
{filterOpen && (
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute left-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{(['all', 'unplanned', 'planned'] as const).map(id => {
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
const active = filter === id
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-left transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
})}
</div>
)}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 16px 16px' }}>
{!loading && filtered.length === 0 ? (
<div className="rounded-xl border border-edge bg-surface-secondary p-6 text-center">
<Mountain className="mx-auto mb-2 h-6 w-6 text-content-faint" strokeWidth={1.6} />
<p className="font-medium text-content">{t('tours.empty.title')}</p>
<p className="mt-1 text-sm text-content-secondary">{t('tours.empty.body')}</p>
</div>
) : (
<ul className="space-y-2">
{filtered.map((tour) => {
const isSelected = tour.place_id === selectedPlaceId
return (
<TourListRow
key={tour.place_id}
tour={tour}
selected={isSelected}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
action={(
<button
type="button"
onClick={(e) => { e.stopPropagation(); if (canAssign) setPickerOpenFor(v => v === tour.place_id ? null : tour.place_id) }}
disabled={!canAssign || days.length === 0}
className="inline-flex min-h-9 w-full items-center justify-center gap-1 rounded-lg border border-edge px-3 py-1.5 text-sm font-medium text-content disabled:opacity-50"
>
{t('tours.addToDay')}
<ChevronDown className="h-3 w-3" strokeWidth={2} style={{ transform: pickerOpenFor === tour.place_id ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
)}
>
{canAssign && pickerOpenFor === tour.place_id && (
<div className="mt-2 border-t border-edge-faint pt-2 flex flex-col gap-1" onClick={e => e.stopPropagation()}>
{days.map((day, i) => (
<button
type="button"
key={day.id}
onClick={() => { void handleAssign(tour, day, i) }}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-content hover:bg-surface-tertiary"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-surface-tertiary text-[11px] font-semibold">{i + 1}</span>
{dayLabel(day, i, t)}
</button>
))}
</div>
)}
</TourListRow>
)
})}
</ul>
)}
</div>
</div>
)
}
@@ -0,0 +1,571 @@
import { act, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { resetAllStores, seedStore } from '../../../../tests/helpers/store'
import { useSettingsStore } from '../../../store/settingsStore'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import type { TourPlannerController } from './useTourPlanner'
import type { Day } from '../../../types'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('../../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
const tour: TourListItem = {
place_id: 42,
name: 'Saved ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: 60,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
const days = [{ id: 7, title: 'Summit day', date: '2026-05-15' } as Day]
const railProps = {
days,
loading: false,
onAssignToDay: vi.fn(),
onViewGpxTour: vi.fn(),
}
const routeAnalysis = analyzeRouteGeometry(JSON.stringify([[48, 11, 500], [48.036, 11, 550]]))
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
const state = {
canEdit: true,
canAssign: true,
isSaving: false,
maxHikingDifficulty: 2,
setMaxHikingDifficulty: vi.fn(),
name: '',
startNewTour: vi.fn(),
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
elevationProfileExpanded: true,
toggleElevationProfile: vi.fn(),
routeProfileFocus: null,
setRouteProfileFocus: vi.fn(),
draftRestored: false,
returnToNeutral: vi.fn(),
waypoints: [],
route: [[48, 11], [48.01, 11.02]],
routeAnalysis,
distanceMeters: 4000,
durationSeconds: 3600,
editingPlaceId: null,
openingTourId: null,
openTour: vi.fn().mockResolvedValue(true),
saveOutcome: null,
hasUnsavedChanges: false,
discard: vi.fn(),
readOnlyGpxTour: null,
readOnlyGpxAnalysis: null,
closeGpxTour: vi.fn(),
...overrides,
}
const mode = overrides.mode ?? (state.readOnlyGpxTour
? { type: 'view-gpx' as const, placeId: state.readOnlyGpxTour.tour.place_id, tour: state.readOnlyGpxTour.tour }
: state.editingPlaceId != null
? { type: 'edit-saved' as const, placeId: state.editingPlaceId }
: state.hasUnsavedChanges || state.waypoints.length > 0 || state.name !== '' || state.status !== undefined && state.status !== 'empty'
? { type: 'new-draft' as const }
: { type: 'neutral' as const })
return {
...state,
mode,
} as unknown as TourPlannerController
}
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
describe('TourPlannerToursRail', () => {
it('disables saved-tour switching and Plan another while a Save is pending', () => {
const controller = planner({
isSaving: true,
saveOutcome: tour,
hasUnsavedChanges: true,
mode: { type: 'edit-saved', placeId: tour.place_id },
editingPlaceId: tour.place_id,
})
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} />)
const row = screen.getByRole('option', { name: /Saved ridge walk/i })
expect(row).toHaveAttribute('aria-disabled', 'true')
expect(row).toHaveAttribute('tabindex', '-1')
fireEvent.click(row)
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Plan another' })).toBeDisabled()
})
it('TOUR-PLANNER-RAIL-001: shows saved Tours rows and route stats without hiding the safety note in navigation', () => {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('TREK')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
expect(document.querySelector('svg[viewBox="0 0 440 100"]')).toBeInTheDocument()
expect(screen.queryByText('Routing is planning assistance, not a safety guarantee.')).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-002: guards a saved-tour open while the current draft is unsaved', () => {
const controller = planner({ hasUnsavedChanges: true })
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} />)
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Discard unsaved tour changes?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }))
expect(controller.openTour).toHaveBeenCalledWith(tour)
})
it('TOUR-PLANNER-RAIL-003: assigns the saved tour immediately when a day is picked', async () => {
const controller = planner({ saveOutcome: tour, editingPlaceId: tour.place_id })
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }))
expect(controller.startNewTour).toHaveBeenCalledOnce()
expect(controller.discard).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' }))
const dayOption = screen.getByLabelText('Pick a day').querySelector('option[value="7"]')
expect(dayOption).toHaveTextContent(/Day 1/i)
expect(dayOption).toHaveTextContent(/May 15/i)
expect(dayOption).toHaveTextContent(/Fri/i)
fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } })
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
})
it('keeps the saved Tour day picker open when assignment fails', async () => {
const controller = planner({ saveOutcome: tour, editingPlaceId: tour.place_id })
const onAssignToDay = vi.fn().mockResolvedValue(false)
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' }))
fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } })
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
expect(screen.getByLabelText('Pick a day')).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-004: protects a dirty draft before selecting a GPX Tour', () => {
const gpxTour = { ...tour, has_waypoints: false }
const controller = planner({ hasUnsavedChanges: true, mode: { type: 'new-draft' } })
const onViewGpxTour = vi.fn()
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX import')
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
expect(onViewGpxTour).not.toHaveBeenCalled()
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Discard unsaved tour changes?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onViewGpxTour).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).not.toHaveAttribute('aria-selected', 'true')
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }))
expect(onViewGpxTour).toHaveBeenCalledWith(gpxTour)
})
it('TOUR-PLANNER-RAIL-020: cancelling a dirty saved-tour switch preserves the selected row', () => {
const otherTour = { ...tour, place_id: 43, name: 'Other ridge walk' }
const controller = planner({
mode: { type: 'edit-saved', placeId: tour.place_id },
editingPlaceId: tour.place_id,
hasUnsavedChanges: true,
})
render(<TourPlannerToursRail planner={controller} tours={[tour, otherTour]} {...railProps} />)
fireEvent.click(screen.getByRole('option', { name: /Other ridge walk/i }))
expect(screen.getByRole('heading', { name: 'Discard unsaved tour changes?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('option', { name: /Other ridge walk/i })).not.toHaveAttribute('aria-selected', 'true')
expect(controller.openTour).not.toHaveBeenCalled()
})
it('TOUR-PLANNER-RAIL-005: shows analyzed GPX metrics and profile without editable controls', () => {
const geometry = JSON.stringify([[48, 11, 500], [48, 11.01, 550], [48, 11.02, 520]])
const controller = planner({
readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})
const { container } = render(<TourPlannerRail planner={controller} />)
expect(screen.getByRole('heading', { name: 'GPX tour' })).toBeInTheDocument()
expect(screen.getByText('Saved ridge walk')).toBeInTheDocument()
expect(screen.getByText('Walking time')).toBeInTheDocument()
expect(screen.getByText('60 min')).toBeInTheDocument()
expect(screen.getByText('Minimum altitude')).toBeInTheDocument()
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
expect(container.querySelector('svg[viewBox="0 0 440 100"]')).toBeInTheDocument()
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
expect(controller.returnToNeutral).toHaveBeenCalledOnce()
})
it.each(['editable', 'GPX'] as const)('uses the same interactive profile focus contract for %s Tours', kind => {
let scheduledFrame: FrameRequestCallback | null = null
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
scheduledFrame = callback
return 1
})
const geometry = JSON.stringify([[48, 11, 500], [48.01, 11.02, 550]])
const focus = { distanceMeters: 500, elevationMeters: 525, lat: 48.005, lng: 11.01, sampleIndex: 1 }
const setRouteProfileFocus = vi.fn()
const controller = kind === 'GPX'
? planner({ routeProfileFocus: focus, setRouteProfileFocus, readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: geometry }, readOnlyGpxAnalysis: analyzeRouteGeometry(geometry) })
: planner({ mode: { type: 'new-draft' }, routeProfileFocus: focus, setRouteProfileFocus })
expect(controller.routeAnalysis?.distanceIndexedProfileSamples[0]).toMatchObject({ lat: 48, lng: 11 })
const { container } = kind === 'GPX'
? render(<TourPlannerRail planner={controller} />)
: render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} />)
const profile = container.querySelector('svg[viewBox="0 0 440 100"]') as SVGSVGElement | null
expect(profile).not.toBeNull()
expect(profile).toHaveAttribute('tabindex', '0')
expect(profile).toHaveAttribute('aria-label', 'Elevation profile')
const line = container.querySelector('[data-profile-focus-line]')
expect(line).toBeInTheDocument()
expect(container.querySelector('[data-profile-focus-readout]')?.textContent).toContain('At ')
vi.spyOn(profile!, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 440, bottom: 100, width: 440, height: 100,
toJSON: () => ({}),
} as DOMRect)
fireEvent.pointerMove(profile!, { clientX: 220, pointerType: 'mouse' })
expect(scheduledFrame).not.toBeNull()
act(() => scheduledFrame?.(16))
const focused = setRouteProfileFocus.mock.calls[0][0]!
expect(focused.lat).toBeCloseTo(kind === 'GPX' ? 48.005 : 48.018, 6)
expect(focused.lng).toBe(kind === 'GPX' ? 11.01 : 11)
})
it.each([
['en', 'metric', 'At 500 m: 525 m'],
['de', 'imperial', 'Bei 0.3 mi: 1722 ft'],
] as const)('formats focused profile distance/elevation for %s %s units', async (language, distance_unit, expected) => {
seedStore(useSettingsStore, { settings: { language, distance_unit } })
const controller = planner({
mode: { type: 'new-draft' },
routeProfileFocus: { distanceMeters: 500, elevationMeters: 525, lat: 48.005, lng: 11, sampleIndex: 1 },
setRouteProfileFocus: vi.fn(),
})
const { container } = render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} />)
await waitFor(() => expect(container.querySelector('[data-profile-focus-readout]')?.textContent).toBe(expected))
})
})
describe('TourPlannerRail', () => {
it('disables draft mutation and replacement controls while saving', () => {
const controller = planner({
mode: { type: 'new-draft' },
status: 'saving',
isSaving: true,
hasUnsavedChanges: true,
canSave: true,
canUndo: true,
canRedo: true,
waypoints: [
{ id: 'a', lat: 48, lng: 11, role: 'start' },
{ id: 'b', lat: 48.1, lng: 11.1, role: 'end' },
],
name: 'Ridge walk',
selectedWaypointId: 'a',
setSelectedWaypointId: vi.fn(),
moveWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
save: vi.fn(),
setName: vi.fn(),
})
render(<TourPlannerRail planner={controller} />)
expect(screen.getByLabelText('Tour name')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Maximum trail difficulty' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save tour' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Back to Tours' })).toBeDisabled()
expect(screen.getAllByLabelText('Move waypoint up')).toHaveLength(2)
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent('Saving…')
})
it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => {
const controller = planner({
status: 'ready',
waypoints: [
{ id: 'a', lat: 48, lng: 11, role: 'start' },
{ id: 'b', lat: 48.1, lng: 11.1, role: 'end' },
],
selectedWaypointId: 'a',
canSave: true,
canUndo: false,
canRedo: false,
setSelectedWaypointId: vi.fn(),
moveWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
save: vi.fn(),
setName: vi.fn(),
name: 'Ridge walk',
})
render(<TourPlannerRail planner={controller} />)
const save = screen.getByRole('button', { name: 'Save tour' })
expect(save.parentElement?.parentElement).toHaveTextContent('Routing is planning assistance, not a safety guarantee.')
expect(save).toHaveClass('bg-accent', 'text-accent-text')
expect(save).toHaveClass('hover:opacity-90')
const firstNumber = screen.getByLabelText('Waypoint 1')
const secondNumber = screen.getByLabelText('Waypoint 2')
expect(firstNumber).toHaveTextContent('1')
expect(secondNumber).toHaveTextContent('2')
expect(firstNumber).toHaveAttribute('data-waypoint-number', '1')
expect(secondNumber).toHaveAttribute('data-waypoint-number', '2')
expect(firstNumber).toHaveClass('bg-surface-tertiary', 'text-content-secondary', 'font-geist', 'tabular-nums')
expect(firstNumber).not.toHaveClass('text-white')
expect(firstNumber).toHaveStyle({ display: 'flex', width: '28px', minWidth: '28px', height: '28px', lineHeight: '1' })
})
it('TOUR-PLANNER-RAIL-007: acknowledges alpine routing once per planner session', () => {
const setDifficulty = vi.fn()
const controller = planner({ mode: { type: 'new-draft' }, setMaxHikingDifficulty: setDifficulty })
render(<TourPlannerRail planner={controller} />)
fireEvent.click(screen.getByRole('button', { name: 'Maximum trail difficulty' }))
fireEvent.click(screen.getByRole('button', { name: /T4 — Alpine hiking/ }))
expect(screen.getByRole('heading', { name: 'Enable alpine routing?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Enable alpine routing' }))
expect(setDifficulty).toHaveBeenCalledWith(4)
fireEvent.click(screen.getByRole('button', { name: 'Maximum trail difficulty' }))
fireEvent.click(screen.getByRole('button', { name: /T5 — Demanding alpine hiking/ }))
expect(screen.queryByRole('heading', { name: 'Enable alpine routing?' })).not.toBeInTheDocument()
expect(setDifficulty).toHaveBeenLastCalledWith(5)
})
it('TOUR-PLANNER-RAIL-008: cancelling alpine acknowledgement leaves the setting unchanged', () => {
const setDifficulty = vi.fn()
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, setMaxHikingDifficulty: setDifficulty })} />)
fireEvent.click(screen.getByRole('button', { name: 'Maximum trail difficulty' }))
fireEvent.click(screen.getByRole('button', { name: /T4 — Alpine hiking/ }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(setDifficulty).not.toHaveBeenCalled()
})
it('TOUR-PLANNER-RAIL-014: exposes every T1-T6 choice and the T3 warning', () => {
const setDifficulty = vi.fn()
function StatefulDifficultyRail() {
const [difficulty, setDifficultyState] = useState<1 | 2 | 3 | 4 | 5 | 6>(2)
return <TourPlannerRail planner={planner({
mode: { type: 'new-draft' },
maxHikingDifficulty: difficulty,
setMaxHikingDifficulty: value => { setDifficulty(value); setDifficultyState(value as 1 | 2 | 3 | 4 | 5 | 6) },
})} />
}
render(<StatefulDifficultyRail />)
fireEvent.click(screen.getByRole('button', { name: 'Maximum trail difficulty' }))
for (const label of [
'T1 — Hiking',
'T2 — Mountain hiking',
'T3 — Demanding mountain hiking',
'T4 — Alpine hiking',
'T5 — Demanding alpine hiking',
'T6 — Difficult alpine hiking',
]) expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'T3 — Demanding mountain hiking' }))
expect(setDifficulty).toHaveBeenCalledWith(3)
expect(screen.getByRole('status')).toHaveTextContent(/T3 routes may include exposed/)
})
it('TOUR-PLANNER-RAIL-009: neutral shows only the Plan new entry and new-draft guidance hides after the first waypoint', () => {
const controller = planner({ mode: { type: 'neutral' } })
const { rerender } = render(<TourPlannerRail planner={controller} />)
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
expect(screen.getByText('Create a route with waypoints and save it to this trip.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Plan new tour' }))
expect(controller.startNewTour).toHaveBeenCalledOnce()
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
expect(screen.queryByText('Maximum trail difficulty')).not.toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Click the map to add your starting point.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Add another point to calculate a route.')
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, waypoints: [{ id: 'first', lat: 48, lng: 11, role: 'start' }] })} />)
expect(screen.queryByTestId('tour-planner-empty-state')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-010: elevation disclosure is focusable and toggles with Enter and Space', async () => {
function StatefulToursRail() {
const [expanded, setExpanded] = useState(true)
return (
<TourPlannerToursRail
planner={planner({
mode: { type: 'new-draft' },
elevationProfileExpanded: expanded,
toggleElevationProfile: () => setExpanded(value => !value),
})}
tours={[tour]}
{...railProps}
/>
)
}
const user = userEvent.setup()
const { container } = render(<StatefulToursRail />)
const toggle = screen.getByRole('button', { name: 'Collapse elevation profile' })
toggle.focus()
expect(document.activeElement).toBe(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(document.getElementById(toggle.getAttribute('aria-controls')!)).not.toBeNull()
await user.keyboard('{Enter}')
const expand = screen.getByRole('button', { name: 'Expand elevation profile' })
expect(expand).toHaveAttribute('aria-expanded', 'false')
expect(container.querySelector('svg[viewBox="0 0 440 100"]')).not.toBeInTheDocument()
expand.focus()
await user.keyboard(' ')
expect(screen.getByRole('button', { name: 'Collapse elevation profile' })).toHaveAttribute('aria-expanded', 'true')
expect(container.querySelector('svg[viewBox="0 0 440 100"]')).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-011: routes Plan new confirmation cancel and confirm through the hook action', () => {
const controller = planner({ newTourConfirmationOpen: true, mode: { type: 'new-draft' } })
const { rerender } = render(<TourPlannerRail planner={controller} />)
expect(screen.getByRole('heading', { name: 'Start a new tour?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(controller.cancelNewTour).toHaveBeenCalledOnce()
rerender(<TourPlannerRail planner={controller} />)
fireEvent.click(screen.getByRole('button', { name: 'Discard changes and start new tour' }))
expect(controller.startNewTour).toHaveBeenCalledWith(true)
})
it('TOUR-PLANNER-RAIL-012: exposes the same Plan new action while editing a saved Tour', () => {
const controller = planner({
editingPlaceId: tour.place_id,
name: tour.name,
waypoints: [{ id: 'saved-start', lat: 48, lng: 11, role: 'start' }],
})
render(<TourPlannerRail planner={controller} />)
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-013: does not compete with the GPX read-only presentation', () => {
const geometry = JSON.stringify([[48, 11, 500], [48, 11.01, 550]])
const controller = planner({
readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})
render(<TourPlannerRail planner={controller} />)
expect(screen.getByRole('heading', { name: 'GPX tour' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-015: returns from GPX view to the blank editable Planner', () => {
const controller = planner({
readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: null },
readOnlyGpxAnalysis: null,
})
const { rerender } = render(<TourPlannerRail planner={controller} />)
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'neutral' } })} />)
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
expect(screen.queryByText('GPX tours are view-only and cannot be edited in the planner yet.')).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-016: the master list reflects neutral, saved-edit, and GPX selection modes', () => {
const gpxTour = { ...tour, place_id: 43, name: 'Imported GPX walk', has_waypoints: false }
const geometry = JSON.stringify([[48, 11], [48.01, 11.02]])
const { rerender } = render(<TourPlannerToursRail planner={planner({ mode: { type: 'neutral' } })} tours={[tour, gpxTour]} {...railProps} />)
expect(screen.getAllByRole('option').every(row => row.getAttribute('aria-selected') !== 'true')).toBe(true)
rerender(<TourPlannerToursRail planner={planner({ editingPlaceId: tour.place_id })} tours={[tour, gpxTour]} {...railProps} />)
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveAttribute('aria-selected', 'true')
rerender(<TourPlannerToursRail planner={planner({
readOnlyGpxTour: { tour: gpxTour, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})} tours={[tour, gpxTour]} {...railProps} />)
expect(screen.getByRole('option', { name: /Imported GPX walk/i })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).not.toHaveAttribute('aria-selected', 'true')
})
it('TOUR-PLANNER-RAIL-018: clean saved editor closes directly; dirty editor close is cancellable', () => {
const clean = planner({ mode: { type: 'edit-saved', placeId: tour.place_id }, editingPlaceId: tour.place_id, hasUnsavedChanges: false })
const { rerender } = render(<TourPlannerRail planner={clean} />)
const back = screen.getByRole('button', { name: 'Back to Tours' })
expect(back).toHaveAttribute('aria-label', 'Back to Tours')
expect(back).toHaveClass('focus-visible:outline')
fireEvent.click(back)
expect(clean.returnToNeutral).toHaveBeenCalledOnce()
expect(screen.queryByRole('heading', { name: 'Discard unsaved tour changes?' })).not.toBeInTheDocument()
const dirty = planner({ mode: { type: 'edit-saved', placeId: tour.place_id }, editingPlaceId: tour.place_id, hasUnsavedChanges: true })
rerender(<TourPlannerRail planner={dirty} />)
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
expect(screen.getByRole('heading', { name: 'Discard unsaved tour changes?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(dirty.returnToNeutral).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
fireEvent.click(screen.getByRole('button', { name: 'Discard unsaved changes' }))
expect(dirty.returnToNeutral).toHaveBeenCalledOnce()
})
it('TOUR-PLANNER-RAIL-021: dirty new-draft close requires confirmation', () => {
const controller = planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true })
render(<TourPlannerRail planner={controller} />)
fireEvent.click(screen.getByRole('button', { name: 'Back to Tours' }))
expect(screen.getByRole('heading', { name: 'Discard unsaved tour changes?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(controller.returnToNeutral).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
@@ -0,0 +1,533 @@
import { useRef, useState } from 'react'
import type { TourListItem } from '@trek/shared'
import { ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, ChevronDown, Clock3, Mountain, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { formatDate } from '../../../utils/formatters'
import { formatDistance, formatElevation } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { DialogButton } from '../../shared/DialogShell'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { BarButton } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import type { RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
routing: 'tours.planner.status.routing',
'routing-failed': 'tours.planner.status.routingFailed',
'enriching-elevation': 'tours.planner.status.elevation',
'elevation-failed': 'tours.planner.status.elevationFailed',
ready: 'tours.planner.status.ready',
saving: 'tours.planner.status.saving',
saved: 'tours.planner.saved',
}
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
return t(`tours.planner.${role}`)
}
function profileFocusLabel(focus: RouteProfileFocus, t: (key: string, params?: Record<string, string | number | null>) => string, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
elevation: focus.elevationMeters == null ? '—' : formatElevation(focus.elevationMeters, unit),
})
}
export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) {
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
const requestClose = () => {
if (isSaving) return
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
else planner.returnToNeutral()
}
if (planner.mode.type === 'neutral') {
return (
<section className="flex h-full min-h-0 flex-col bg-transparent" aria-label={t('tours.planner.title')}>
<EmptyState
scene="idle"
title={t('tours.planner.neutralTitle')}
size={48}
compact
className="min-h-0 flex-1 gap-2 px-4 py-4"
action={(
<div className="max-w-full">
<p className="text-xs leading-5 text-content-secondary">{t('tours.planner.neutralBody')}</p>
<div className="mt-3 [&>button]:w-full [&>button]:justify-center">
<DialogButton variant="primary" icon={<Plus size={14} />} disabled={!canEdit} onClick={() => { if (canEdit) planner.startNewTour() }}>
{t('tours.planner.newTour')}
</DialogButton>
</div>
<p className="mt-2 text-[11px] leading-4 text-content-tertiary">{t('tours.planner.neutralHint')}</p>
</div>
)}
/>
</section>
)
}
if (planner.mode.type === 'view-gpx') {
const { tour } = planner.readOnlyGpxTour
const analysis = planner.readOnlyGpxAnalysis
return (
<>
<section className="flex h-full min-h-0 flex-col bg-transparent" aria-label={tour.name}>
<div className="flex items-start gap-2 border-b border-edge px-4 py-3">
<BarButton label={t('tours.planner.backToTours')} onClick={() => planner.returnToNeutral()} className="mt-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} />
</BarButton>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-content">{t('tours.planner.gpxTitle')}</h2>
<p className="mt-0.5 truncate text-xs text-content">{tour.name}</p>
<p className="mt-0.5 text-[11px] text-content-secondary">{hikeSourceBadgeLabel(tour, t)}</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<p className="text-xs leading-5 text-content-secondary">{t('tours.detail.gpxReadOnly')}</p>
{analysis ? (
<section className="mt-5" aria-label={t('tours.planner.inspector.title')}>
<h3 className="text-xs font-semibold text-content-secondary">{t('tours.planner.inspector.title')}</h3>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-4">
<TourPlannerMetric label={t('tours.detail.distance')} value={formatDistance(analysis.distanceKm, distanceUnit)} />
{tour.duration != null && <TourPlannerMetric label={t('tours.planner.inspector.duration')} value={t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) })} />}
{analysis.minEle != null && <TourPlannerMetric label={t('tours.detail.minAltitude')} value={formatElevation(analysis.minEle, distanceUnit)} />}
{analysis.maxEle != null && <TourPlannerMetric label={t('tours.detail.maxAltitude')} value={formatElevation(analysis.maxEle, distanceUnit)} />}
{analysis.gain != null && <TourPlannerMetric label={t('tours.detail.ascent')} value={formatElevation(analysis.gain, distanceUnit)} />}
{analysis.loss != null && <TourPlannerMetric label={t('tours.detail.descent')} value={formatElevation(analysis.loss, distanceUnit)} />}
</dl>
{analysis.distanceIndexedProfileSamples.length >= 2 ? (
<div className="mt-5">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold text-content-secondary">{t('tours.planner.inspector.elevation')}</h3>
<ElevationProfileToggle
id="tour-planner-gpx-elevation-profile"
expanded={planner.elevationProfileExpanded}
onToggle={planner.toggleElevationProfile}
t={t}
/>
</div>
<div id="tour-planner-gpx-elevation-profile" hidden={!planner.elevationProfileExpanded}>
{planner.elevationProfileExpanded && <ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color="var(--text-secondary)"
gradientId={`planner-gpx-elevation-${tour.place_id}`}
ariaLabel={t('tours.planner.inspector.elevation')}
focus={planner.routeProfileFocus}
onFocusChange={planner.setRouteProfileFocus}
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
/>}
</div>
</div>
) : (
<p className="mt-4 text-xs text-content-secondary">{t('tours.planner.inspector.elevationPlaceholder')}</p>
)}
</section>
) : (
<p className="mt-5 text-xs text-content-secondary">{t('tours.planner.gpxNoGeometry')}</p>
)}
</div>
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
)
}
const isSavedEdit = planner.mode.type === 'edit-saved'
return (
<>
<section className="flex h-full min-h-0 flex-col bg-transparent" aria-label={t('tours.planner.title')}>
<div className="border-b border-edge px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} />
</BarButton>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-content">{t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')}</h2>
<p className="mt-0.5 text-xs text-content-secondary">{t('tours.planner.mapHint')}</p>
</div>
</div>
<div className="flex shrink-0 gap-1">
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
<Undo2 size={16} />
</BarButton>
<BarButton label={t('tours.planner.redo')} onClick={() => { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}>
<Redo2 size={16} />
</BarButton>
</div>
</div>
{planner.draftRestored && planner.hasUnsavedChanges && <p className="mt-2 rounded-md bg-surface-hover px-2 py-1 text-[11px] text-content-secondary" role="status">{t('tours.planner.restored')}</p>}
<label className="mt-3 block text-xs font-medium text-content-secondary" htmlFor="tour-planner-name">
{t('tours.planner.name')}
</label>
<input
id="tour-planner-name"
disabled={!canEdit || isSaving}
value={planner.name}
onChange={event => { if (canEdit && !isSaving) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content outline-none focus:border-accent"
/>
<label className="mt-3 block text-xs font-medium text-content-secondary" htmlFor="tour-planner-difficulty">
{t('tours.planner.maxDifficulty')}
</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={!canEdit || isSaving}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
if (!canEdit || isSaving) return
const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
else planner.setMaxHikingDifficulty(value)
}}
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
size="sm"
style={{ marginTop: 4 }}
/>
{planner.maxHikingDifficulty === 3 && <p className="mt-2 rounded-lg border border-warning bg-warning-soft px-3 py-2 text-xs leading-4 text-warning" role="status">{t('tours.planner.difficulty.t3Warning')}</p>}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
<h3 className="px-1 text-xs font-semibold uppercase tracking-wide text-content-secondary">{t('tours.planner.waypoints')}</h3>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="mt-3 rounded-lg border border-dashed border-edge bg-surface-hover px-3 py-4 text-center">
<EmptyState scene="idle" title={t('tours.planner.startingPoint')} size={30} layout="row" compact className="gap-2 px-1 py-1" />
<p className="mt-1 text-[11px] leading-4 text-content-secondary">{t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol className="mt-2 space-y-1.5">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id
return (
<li key={point.id}>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className={`flex w-full items-center gap-2 rounded-xl border px-2 py-2 text-left ${selected ? 'border-accent bg-accent-subtle' : 'border-edge bg-transparent hover:bg-surface-hover'}`}
aria-current={selected ? 'true' : undefined}
>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className="shrink-0 rounded-full bg-surface-tertiary font-geist text-xs font-semibold tabular-nums text-content-secondary"
style={{ width: 28, minWidth: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}
>
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-semibold text-content">{roleLabel(point.role, t)}</span>
<span className="block truncate font-mono text-[10px] text-content-tertiary">{point.lat.toFixed(5)}, {point.lng.toFixed(5)}</span>
</span>
{canEdit && <span className="flex items-center">
<span role="button" tabIndex={isSaving || index === 0 ? -1 : 0} aria-label={t('tours.planner.moveUp')} aria-disabled={isSaving || index === 0} onClick={event => { event.stopPropagation(); if (!isSaving) planner.moveWaypoint(point.id, -1) }} onKeyDown={event => { if (!isSaving && event.key === 'Enter' && index > 0) planner.moveWaypoint(point.id, -1) }} className={`rounded p-1 ${isSaving || index === 0 ? 'pointer-events-none opacity-25' : 'hover:bg-surface-card'}`}><ArrowUp size={13} /></span>
<span role="button" tabIndex={isSaving || index === planner.waypoints.length - 1 ? -1 : 0} aria-label={t('tours.planner.moveDown')} aria-disabled={isSaving || index === planner.waypoints.length - 1} onClick={event => { event.stopPropagation(); if (!isSaving) planner.moveWaypoint(point.id, 1) }} onKeyDown={event => { if (!isSaving && event.key === 'Enter' && index < planner.waypoints.length - 1) planner.moveWaypoint(point.id, 1) }} className={`rounded p-1 ${isSaving || index === planner.waypoints.length - 1 ? 'pointer-events-none opacity-25' : 'hover:bg-surface-card'}`}><ArrowDown size={13} /></span>
<span role="button" tabIndex={isSaving ? -1 : 0} aria-label={t('tours.planner.remove')} aria-disabled={isSaving} onClick={event => { event.stopPropagation(); if (!isSaving) planner.removeWaypoint(point.id) }} onKeyDown={event => { if (!isSaving && event.key === 'Enter') planner.removeWaypoint(point.id) }} className={`rounded p-1 text-red-500 hover:bg-red-50 ${isSaving ? 'pointer-events-none opacity-25' : ''}`}><Trash2 size={13} /></span>
</span>}
</button>
</li>
)
})}
</ol>
)}
{(statusKey || planner.error) && (
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${planner.error ? 'border border-warning bg-warning-soft text-warning' : 'bg-surface-hover text-content-secondary'}`} role="status">
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
{canRetry && <button type="button" onClick={planner.retry} className="ml-2 font-semibold underline">{t('tours.planner.retry')}</button>}
</div>
)}
</div>
<div className="border-t border-edge p-3">
<p className="mb-2 flex items-start gap-1.5 text-[11px] leading-4 text-content-secondary">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}</span>
</p>
<div className="grid grid-cols-[auto_1fr] gap-2">
<DialogButton icon={<RotateCcw size={14} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')}
</DialogButton>
<DialogButton variant="primary" icon={<Save size={14} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}>
{t(isSavedEdit ? 'tours.planner.saveChanges' : 'tours.planner.save')}
</DialogButton>
</div>
</div>
<ConfirmDialog
isOpen={confirmDiscard && !isSaving}
onClose={() => setConfirmDiscard(false)}
onConfirm={planner.returnToNeutral}
title={t('tours.planner.discardTitle')}
message={t('tours.planner.discardBody')}
confirmLabel={t('tours.planner.discardUnsaved')}
/>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
<ConfirmDialog
isOpen={pendingDifficulty !== null && !isSaving}
onClose={() => setPendingDifficulty(null)}
onConfirm={() => {
if (canEdit && !isSaving && pendingDifficulty !== null) {
alpineAcknowledged.current = true
planner.setMaxHikingDifficulty(pendingDifficulty)
}
setPendingDifficulty(null)
}}
title={t('tours.planner.difficulty.alpineTitle')}
message={t('tours.planner.difficulty.alpineBody')}
confirmLabel={t('tours.planner.difficulty.enable')}
/>
</section>
</>
)
}
function TourPlannerMetric({ label, value }: { label: string; value: string }) {
return <div><dt className="text-[11px] text-content-secondary">{label}</dt><dd className="mt-1 text-sm font-semibold text-content">{value}</dd></div>
}
function ElevationProfileToggle({ id, expanded, onToggle, t }: { id: string; expanded: boolean; onToggle: () => void; t: (key: string) => string }) {
return (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={id}
aria-label={t(expanded ? 'tours.planner.collapseElevation' : 'tours.planner.expandElevation')}
className="rounded p-1 text-content-secondary hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronDown size={15} className={expanded ? '' : '-rotate-90'} />
</button>
)
}
function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) {
const { t } = useTranslation()
return (
<ConfirmDialog
isOpen={canEdit && planner.newTourConfirmationOpen}
onClose={planner.cancelNewTour}
onConfirm={() => { if (canEdit) planner.startNewTour(true) }}
title={t('tours.planner.newTourConfirmTitle')}
message={t('tours.planner.newTourConfirmBody')}
confirmLabel={t('tours.planner.newTourConfirm')}
/>
)
}
interface TourPlannerToursRailProps extends TourPermissionProps {
planner: TourPlannerController
tours: TourListItem[]
days: Day[]
loading: boolean
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onViewGpxTour: (tour: TourListItem) => void
}
function plannerDayLabel(day: Day, index: number, locale: string, t: (key: string, params?: Record<string, unknown>) => string): string {
const dayNumber = t('dayplan.dayN', { n: index + 1 })
const date = formatDate(day.date, locale)
return [dayNumber, date, day.title].filter(Boolean).join(' · ')
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t, locale } = useTranslation()
const toast = useToast()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null)
const [dayPickerOpen, setDayPickerOpen] = useState(false)
const [assigning, setAssigning] = useState(false)
const isSaving = planner.isSaving
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
const openTour = async (tour: TourListItem) => {
const opened = await planner.openTour(tour)
if (!opened) toast.error(t('tours.planner.openFailed'))
}
const selectTour = (tour: TourListItem) => {
if (tourSource(tour) === 'gpx') onViewGpxTour(tour)
else void openTour(tour)
}
const requestOpenTour = (tour: TourListItem) => {
if (isSaving) return
if (planner.hasUnsavedChanges) {
setPendingTour(tour)
return
}
selectTour(tour)
}
const assignSavedTour = async (dayId: number) => {
const savedTour = planner.saveOutcome
if (!canAssign || !savedTour) return
setAssigning(true)
try {
const assigned = await onAssignToDay(savedTour.place_id, dayId)
if (assigned === false) return
setDayPickerOpen(false)
} finally {
setAssigning(false)
}
}
return (
<aside className="flex h-full min-h-0 flex-col bg-transparent" aria-label={t('tours.planner.tripTours')}>
<div className="shrink-0 border-b border-edge px-4 py-3">
<h2 className="text-sm font-semibold text-content">{t('tours.planner.tripTours')}</h2>
<p className="mt-0.5 text-xs text-content-secondary">{t('tours.subtitle')}</p>
{hasRoute && (
<dl className="mt-4 grid grid-cols-2 gap-2">
<div className="rounded-xl border border-edge bg-surface p-3">
<dt className="text-[11px] text-content-secondary">{t('tours.planner.inspector.distance')}</dt>
<dd className="mt-1 text-sm font-semibold text-content">{routeAnalysis ? formatDistance(routeAnalysis.distanceKm, distanceUnit) : '—'}</dd>
</div>
<div className="rounded-xl border border-edge bg-surface p-3">
<dt className="flex items-center gap-1 text-[11px] text-content-secondary"><Clock3 size={12} />{t('tours.planner.inspector.duration')}</dt>
<dd className="mt-1 text-sm font-semibold text-content">{t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) })}</dd>
</div>
</dl>
)}
{hasRoute && routeAnalysis && (
<>
<dl className="mt-3 grid grid-cols-2 gap-2">
{routeAnalysis.minEle != null && <TourPlannerMetric label={t('tours.detail.minAltitude')} value={formatElevation(routeAnalysis.minEle, distanceUnit)} />}
{routeAnalysis.maxEle != null && <TourPlannerMetric label={t('tours.detail.maxAltitude')} value={formatElevation(routeAnalysis.maxEle, distanceUnit)} />}
{routeAnalysis.gain != null && <TourPlannerMetric label={t('tours.detail.ascent')} value={formatElevation(routeAnalysis.gain, distanceUnit)} />}
{routeAnalysis.loss != null && <TourPlannerMetric label={t('tours.detail.descent')} value={formatElevation(routeAnalysis.loss, distanceUnit)} />}
</dl>
{routeAnalysis.distanceIndexedProfileSamples.length >= 2 && (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold text-content-secondary">{t('tours.planner.inspector.elevation')}</h3>
<ElevationProfileToggle
id="tour-planner-editable-elevation-profile"
expanded={planner.elevationProfileExpanded}
onToggle={planner.toggleElevationProfile}
t={t}
/>
</div>
<div id="tour-planner-editable-elevation-profile" hidden={!planner.elevationProfileExpanded}>
{planner.elevationProfileExpanded && <ElevationProfile
samples={routeAnalysis.distanceIndexedProfileSamples}
color="var(--text-secondary)"
gradientId={`planner-editable-elevation-${planner.editingPlaceId ?? 'draft'}`}
ariaLabel={t('tours.planner.inspector.elevation')}
focus={planner.routeProfileFocus}
onFocusChange={planner.setRouteProfileFocus}
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
/>}
</div>
</div>
) || <p className="mt-2 text-xs text-content-secondary">{t('tours.planner.inspector.elevationPlaceholder')}</p>}
</>
)}
</div>
{planner.saveOutcome && (
<div className="m-3 mb-0 rounded-xl border border-edge bg-surface p-3" role="status">
<div className="flex items-center gap-2 text-sm font-semibold text-content">
<CheckCircle2 className="h-4 w-4 text-success" />
{t('tours.planner.saved')}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<DialogButton onClick={() => { if (canEdit && !isSaving) planner.startNewTour() }} disabled={!canEdit || isSaving}>
{t('tours.planner.planAnother')}
</DialogButton>
<DialogButton variant="primary" icon={<ChevronDown size={13} />} onClick={() => { if (canAssign) setDayPickerOpen(open => !open) }} aria-expanded={dayPickerOpen} disabled={!canAssign || days.length === 0}>
{t('tours.planner.assignToDay')}
</DialogButton>
</div>
{canAssign && dayPickerOpen && (
<label className="mt-2 block text-xs font-medium text-content-secondary" htmlFor="tour-planner-assign-day">
{t('tours.addToDay.pickDay')}
<select
id="tour-planner-assign-day"
defaultValue=""
disabled={assigning}
onChange={event => { if (event.target.value) void assignSavedTour(Number(event.target.value)) }}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-xs text-content"
>
<option value="" disabled>{t('tours.addToDay.pickDay')}</option>
{days.map((day, index) => <option key={day.id} value={day.id}>{plannerDayLabel(day, index, locale, t)}</option>)}
</select>
</label>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{loading && tours.length === 0 ? (
<p className="py-6 text-center text-xs text-content-secondary">{t('common.loading')}</p>
) : tours.length === 0 ? (
<div className="rounded-xl border border-edge bg-surface-secondary p-6 text-center">
<Mountain className="mx-auto mb-2 h-6 w-6 text-content-faint" strokeWidth={1.6} />
<p className="font-medium text-content">{t('tours.empty.title')}</p>
<p className="mt-1 text-sm text-content-secondary">{t('tours.empty.body')}</p>
</div>
) : (
<ul className="space-y-2">
{tours.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
disabled={isSaving}
selected={(planner.mode.type === 'edit-saved' || planner.mode.type === 'view-gpx') && planner.mode.placeId === tour.place_id}
onSelect={requestOpenTour}
action={canAssign && days.length > 0 ? (
<select
aria-label={`${t('tours.addToDay')}: ${tour.name}`}
value=""
onChange={event => {
const dayId = Number(event.target.value)
if (canAssign && dayId) void onAssignToDay(tour.place_id, dayId)
}}
className="w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-xs text-content"
>
<option value="" disabled>{t('tours.addToDay.pickDay')}</option>
{days.map((day, index) => <option key={day.id} value={day.id}>{plannerDayLabel(day, index, locale, t)}</option>)}
</select>
) : undefined}
/>
))}
</ul>
)}
</div>
<ConfirmDialog
isOpen={pendingTour !== null}
onClose={() => setPendingTour(null)}
onConfirm={() => {
const tour = pendingTour
setPendingTour(null)
if (tour) selectTour(tour)
}}
title={t('tours.planner.discardTitle')}
message={t('tours.planner.discardBody')}
confirmLabel={t('tours.planner.discardChanges')}
/>
</aside>
)
}
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { valhallaBase, valhallaRun } = vi.hoisted(() => ({
valhallaBase: vi.fn(),
valhallaRun: vi.fn(),
}))
vi.mock('../../Map/valhallaRoute', () => ({
valhallaBase,
valhallaRun,
}))
import { enrichTourElevations, parseValhallaElevation, routeWalkingTour } from './tourRouting'
const waypoints = [
{ lat: 47.3822621, lng: 10.9888935 },
{ lat: 47.3896073, lng: 11.0089761 },
]
describe('routeWalkingTour', () => {
beforeEach(() => {
valhallaRun.mockReset().mockResolvedValue(null)
valhallaBase.mockReset().mockReturnValue('https://valhalla.test')
})
afterEach(() => vi.unstubAllGlobals())
it('TOUR-ROUTING-001: explicitly admits T2 mountain_hiking trails for Tours only', async () => {
await routeWalkingTour(waypoints)
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
max_hiking_difficulty: 2,
})
})
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
await routeWalkingTour(waypoints, undefined, difficulty)
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
max_hiking_difficulty: difficulty,
})
})
it.each([
[0, 0],
[123.5, 123.5],
[-42.25, -42.25],
])('accepts finite numeric Valhalla elevation %s', (input, expected) => {
expect(parseValhallaElevation(input)).toBe(expected)
})
it.each([null, undefined, '', ' ', '\t', '123', true, false, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '12m', {}, []])(
'preserves invalid Valhalla elevation as missing for %s', value => {
expect(parseValhallaElevation(value)).toBeNull()
},
)
it('keeps valid route coordinates and explicit missing samples when height data is partial', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ range_height: [[0, 0], [1, null], [2, ' ']] }),
}))
const coordinates: [number, number][] = [[48, 11], [48.01, 11.01], [48.02, 11.02]]
await expect(enrichTourElevations(coordinates)).resolves.toEqual([
[48, 11, 0], [48.01, 11.01], [48.02, 11.02],
])
})
it('keeps the full route as 2D geometry when height enrichment is unavailable', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }))
const coordinates: [number, number][] = [[48, 11], [48.01, 11.01]]
await expect(enrichTourElevations(coordinates)).resolves.toEqual(coordinates)
})
})
@@ -0,0 +1,98 @@
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun } from '../../Map/valhallaRoute'
export interface TourRouteResult {
coordinates: [number, number][]
distanceMeters: number
durationSeconds: number
}
/**
* Defensive fallback only. Normal planner calls pass the persisted/draft value.
* TREK exposes 1..6 and never exposes Valhalla's internal 0.
*/
const TOURS_MAX_HIKING_DIFFICULTY = 2
/** Tours deliberately bypasses the OSRM-first RouteCalculator. */
export async function routeWalkingTour(
waypoints: Pick<Waypoint, 'lat' | 'lng'>[],
signal?: AbortSignal,
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
): Promise<TourRouteResult | null> {
const run = await valhallaRun(waypoints, 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
})
if (!run) return null
return {
coordinates: run.total.coordinates,
distanceMeters: run.total.distance,
durationSeconds: run.total.duration,
}
}
const HEIGHT_CHUNK_SIZE = 500
export type TourElevationPoint = [number, number] | [number, number, number]
type HeightResponse = {
range_height?: unknown
}
export function parseValhallaElevation(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseElevations(data: HeightResponse, expected: number): (number | null)[] | null {
if (!Array.isArray(data.range_height) || data.range_height.length !== expected) return null
return data.range_height.map(entry => {
if (!Array.isArray(entry) || entry.length < 2) return null
return parseValhallaElevation(entry[1])
})
}
/**
* Ask Valhalla's independent /height stage for every routed vertex. Chunks
* overlap by one point so full route resolution is retained without inventing
* elevations when a host rejects a large request.
*/
export async function enrichTourElevations(
coordinates: [number, number][],
signal?: AbortSignal,
): Promise<TourElevationPoint[] | null> {
const base = valhallaBase()
if (!base || coordinates.length < 2) return null
const elevations: (number | null)[] = Array(coordinates.length).fill(null)
for (let offset = 0; offset < coordinates.length; offset += HEIGHT_CHUNK_SIZE - 1) {
if (signal?.aborted) return null
const chunk = coordinates.slice(offset, offset + HEIGHT_CHUNK_SIZE)
if (chunk.length === 1 && offset > 0) break
try {
const response = await fetch(`${base}/height`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape: chunk.map(([lat, lng]) => ({ lat, lon: lng })),
range: true,
}),
signal,
})
if (!response.ok) break
const chunkElevations = parseElevations(await response.json() as HeightResponse, chunk.length)
if (!chunkElevations) break
chunkElevations.forEach((elevation, index) => {
const routeIndex = offset + index
if (offset > 0 && index === 0 && elevations[routeIndex] !== null) return
elevations[routeIndex] = elevation
})
} catch {
if (signal?.aborted) return null
break
}
}
return coordinates.map(([lat, lng], index) => {
const elevation = elevations[index]
return elevation === null ? [lat, lng] : [lat, lng, elevation]
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,622 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourCreateResponse, TourListItem, TourWaypointRole, TourMaxHikingDifficulty } from '@trek/shared'
import { toursApi } from '../../../api/client'
import { randomId } from '../../../utils/randomId'
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
export interface TourPlannerWaypoint {
id: string
lat: number
lng: number
role: TourWaypointRole
}
export type TourPlannerStatus =
| 'empty'
| 'dirty'
| 'routing'
| 'routing-failed'
| 'enriching-elevation'
| 'elevation-failed'
| 'ready'
| 'saving'
| 'saved'
interface DraftPayload {
version: 1 | 2
name: string
waypoints: TourPlannerWaypoint[]
maxHikingDifficulty?: TourMaxHikingDifficulty
editingPlaceId?: number | null
}
interface HistoryState {
past: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }>
future: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }>
}
const ROUTE_DEBOUNCE_MS = 450
const HISTORY_LIMIT = 30
function hasCompleteElevation(points: TourElevationPoint[] | null): points is [number, number, number][] {
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
}
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}))
}
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}`).join('|')
}
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
return tourWaypointKey(a) === tourWaypointKey(b)
}
function validDraft(value: unknown): value is DraftPayload {
if (!value || typeof value !== 'object') return false
const draft = value as Partial<DraftPayload>
return (draft.version === 1 || draft.version === 2) && typeof draft.name === 'string'
&& (draft.editingPlaceId == null || (Number.isInteger(draft.editingPlaceId) && draft.editingPlaceId > 0))
&& (draft.maxHikingDifficulty == null || (Number.isInteger(draft.maxHikingDifficulty) && draft.maxHikingDifficulty >= 1 && draft.maxHikingDifficulty <= 6))
&& Array.isArray(draft.waypoints)
&& draft.waypoints.every(point => point && typeof point.id === 'string'
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180)
}
export interface UseTourPlannerOptions extends TourPermissionProps {
tripId: number | string
active?: boolean
onSaved?: (result: TourCreateResponse) => void | Promise<void>
}
export type TourPlannerMode =
| { type: 'neutral' }
| { type: 'new-draft' }
| { type: 'edit-saved'; placeId: number }
| { type: 'view-gpx'; placeId: number; tour: TourListItem }
export function selectTourPlannerMode(state: {
readOnlyGpxTour: { tour: TourListItem } | null
editingPlaceId: number | null
hasUnsavedChanges: boolean
}): TourPlannerMode {
if (state.readOnlyGpxTour) {
return { type: 'view-gpx', placeId: state.readOnlyGpxTour.tour.place_id, tour: state.readOnlyGpxTour.tour }
}
if (state.editingPlaceId !== null) return { type: 'edit-saved', placeId: state.editingPlaceId }
if (state.hasUnsavedChanges) return { type: 'new-draft' }
return { type: 'neutral' }
}
export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPermission, canAssign: assignPermission }: UseTourPlannerOptions) {
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const editPermissionRef = useRef(canEdit)
editPermissionRef.current = canEdit
const storageKey = `tour-draft-${tripId}`
const restored = useMemo(() => {
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) || 'null') as unknown
return validDraft(parsed) ? { ...parsed, waypoints: normalizePlannerWaypoints(parsed.waypoints) } : null
} catch {
return null
}
}, [storageKey])
const [name, setNameState] = useState(restored?.name ?? '')
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const [history, setHistory] = useState<HistoryState>({ past: [], future: [] })
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
const [route, setRoute] = useState<[number, number][] | null>(null)
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
const [routeForKey, setRouteForKey] = useState<string | null>(null)
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
const [durationSeconds, setDurationSeconds] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(Boolean(restored))
const [isSaving, setIsSaving] = useState(false)
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
const [retryToken, setRetryToken] = useState(0)
const [mapBaseLayer, setMapBaseLayer] = useState<TourBaseLayer>('default')
const [readOnlyGpxTour, setReadOnlyGpxTour] = useState<{ tour: TourListItem; routeGeometry: string | null } | null>(null)
const [newTourConfirmationOpen, setNewTourConfirmationOpen] = useState(false)
const [elevationProfileExpanded, setElevationProfileExpanded] = useState(true)
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const generationRef = useRef(0)
const draftRevisionRef = useRef(0)
const saveRequestIdRef = useRef(0)
const saveInFlightRef = useRef(false)
const mountedRef = useRef(false)
const contextGenerationRef = useRef(0)
const detailRequestIdRef = useRef(0)
const detailAbortRef = useRef<AbortController | null>(null)
const detailPlaceIdRef = useRef<number | null>(null)
const activeRef = useRef(active)
const readOnlyGpxTourRef = useRef(readOnlyGpxTour)
const tripIdRef = useRef(tripId)
const editingPlaceIdRef = useRef(editingPlaceId)
activeRef.current = active
readOnlyGpxTourRef.current = readOnlyGpxTour
tripIdRef.current = tripId
editingPlaceIdRef.current = editingPlaceId
const waypointsRef = useRef(waypoints)
const difficultyRef = useRef(maxHikingDifficulty)
waypointsRef.current = waypoints
difficultyRef.current = maxHikingDifficulty
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
saveRequestIdRef.current += 1
saveInFlightRef.current = false
detailRequestIdRef.current += 1
detailPlaceIdRef.current = null
detailAbortRef.current?.abort()
detailAbortRef.current = null
}
}, [])
const invalidateDetailRequest = useCallback(() => {
detailRequestIdRef.current += 1
detailPlaceIdRef.current = null
detailAbortRef.current?.abort()
detailAbortRef.current = null
if (mountedRef.current) setOpeningTourId(null)
}, [])
useEffect(() => {
if (!active) invalidateDetailRequest()
}, [active, invalidateDetailRequest])
useEffect(() => {
invalidateDetailRequest()
}, [tripId, invalidateDetailRequest])
const interruptRouting = useCallback(() => {
generationRef.current += 1
abortRef.current?.abort()
abortRef.current = null
}, [])
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
if (!editPermissionRef.current) return
const current = waypointsRef.current
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue)
if (sameWaypointSnapshot(current, next)) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
setHistory(previous => ({ past: [...previous.past, { waypoints: current, difficulty: difficultyRef.current }].slice(-HISTORY_LIMIT), future: [] }))
waypointsRef.current = next
setWaypoints(next)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [invalidateDetailRequest, interruptRouting])
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
if (!editPermissionRef.current) return
if (value === difficultyRef.current) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
setHistory(current => ({
past: [...current.past, { waypoints: waypointsRef.current, difficulty: difficultyRef.current }].slice(-HISTORY_LIMIT),
future: [],
}))
difficultyRef.current = value
setMaxHikingDifficultyState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [invalidateDetailRequest, interruptRouting])
const addWaypoint = useCallback((lat: number, lng: number) => {
if (!editPermissionRef.current) return
const id = randomId()
commitWaypoints(current => [...current, { id, lat, lng, role: 'end' }])
setSelectedWaypointId(id)
}, [commitWaypoints])
const removeWaypoint = useCallback((id: string) => {
if (!editPermissionRef.current) return
commitWaypoints(current => current.filter(point => point.id !== id))
setSelectedWaypointId(current => current === id ? null : current)
}, [commitWaypoints])
const moveWaypoint = useCallback((id: string, direction: -1 | 1) => {
commitWaypoints(current => {
const index = current.findIndex(point => point.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= current.length) return current
const next = [...current]
;[next[index], next[target]] = [next[target], next[index]]
return next
})
}, [commitWaypoints])
const undo = useCallback(() => {
if (!editPermissionRef.current) return
const previous = history.past[history.past.length - 1]
if (!previous) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
const present = { waypoints: waypointsRef.current, difficulty: difficultyRef.current }
waypointsRef.current = previous.waypoints
difficultyRef.current = previous.difficulty
setWaypoints(previous.waypoints)
setMaxHikingDifficultyState(previous.difficulty)
setSelectedWaypointId(id => previous.waypoints.some(point => point.id === id) ? id : previous.waypoints[0]?.id ?? null)
setHistory({ past: history.past.slice(0, -1), future: [present, ...history.future].slice(0, HISTORY_LIMIT) })
setHasUnsavedChanges(true)
setStatus('dirty')
setError(null)
}, [history, invalidateDetailRequest, interruptRouting])
const redo = useCallback(() => {
if (!editPermissionRef.current) return
const next = history.future[0]
if (!next) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
const present = { waypoints: waypointsRef.current, difficulty: difficultyRef.current }
waypointsRef.current = next.waypoints
difficultyRef.current = next.difficulty
setWaypoints(next.waypoints)
setMaxHikingDifficultyState(next.difficulty)
setSelectedWaypointId(id => next.waypoints.some(point => point.id === id) ? id : next.waypoints[0]?.id ?? null)
setHistory({ past: [...history.past, present].slice(-HISTORY_LIMIT), future: history.future.slice(1) })
setHasUnsavedChanges(true)
setStatus('dirty')
setError(null)
}, [history, invalidateDetailRequest, interruptRouting])
const setName = useCallback((value: string) => {
if (!editPermissionRef.current) return
if (value === name) return
invalidateDetailRequest()
draftRevisionRef.current += 1
setNameState(value)
setHasUnsavedChanges(true)
setSaveOutcome(null)
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
}, [invalidateDetailRequest, name, status])
const returnToNeutral = useCallback(() => {
if (saveInFlightRef.current) return
invalidateDetailRequest()
interruptRouting()
contextGenerationRef.current += 1
draftRevisionRef.current += 1
setRouteProfileFocus(null)
localStorage.removeItem(storageKey)
setNameState('')
setWaypoints([])
setMaxHikingDifficultyState(2)
waypointsRef.current = []
difficultyRef.current = 2
setSelectedWaypointId(null)
setHistory({ past: [], future: [] })
setStatus('empty')
setRoute(null)
setEnrichedGeometry(null)
setRouteForKey(null)
setDistanceMeters(null)
setDurationSeconds(null)
setError(null)
setHasUnsavedChanges(false)
setDraftRestored(false)
setEditingPlaceId(null)
editingPlaceIdRef.current = null
setOpeningTourId(null)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
setNewTourConfirmationOpen(false)
setElevationProfileExpanded(true)
setNewDraftGeneration(generation => generation + 1)
}, [invalidateDetailRequest, interruptRouting, storageKey])
const startNewTour = useCallback((confirmed = false) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
const currentDraftIsEmpty = editingPlaceId === null && waypoints.length === 0 && name.trim().length === 0
if (!confirmed && hasUnsavedChanges && !currentDraftIsEmpty) {
setNewTourConfirmationOpen(true)
return
}
returnToNeutral()
setHasUnsavedChanges(true)
}, [editingPlaceId, hasUnsavedChanges, name, returnToNeutral, waypoints.length])
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
const toggleElevationProfile = useCallback(() => {
if (elevationProfileExpanded) setRouteProfileFocus(null)
setElevationProfileExpanded(expanded => !expanded)
}, [elevationProfileExpanded])
const discard = useCallback(() => returnToNeutral(), [returnToNeutral])
const viewGpxTour = useCallback((tour: TourListItem, routeGeometry: string | null) => {
if (saveInFlightRef.current) return
invalidateDetailRequest()
contextGenerationRef.current += 1
returnToNeutral()
setReadOnlyGpxTour({ tour, routeGeometry })
}, [invalidateDetailRequest, returnToNeutral])
const closeGpxTour = useCallback(() => {
invalidateDetailRequest()
contextGenerationRef.current += 1
setRouteProfileFocus(null)
setReadOnlyGpxTour(null)
}, [invalidateDetailRequest])
const openTour = useCallback(async (tour: TourListItem) => {
if (!mountedRef.current || !activeRef.current || saveInFlightRef.current) return false
detailAbortRef.current?.abort()
const controller = new AbortController()
detailAbortRef.current = controller
const requestId = ++detailRequestIdRef.current
const submittedTripId = String(tripId)
const submittedPlaceId = tour.place_id
const submittedContextGeneration = contextGenerationRef.current
const submittedRevision = draftRevisionRef.current
const submittedEditingPlaceId = editingPlaceIdRef.current
const submittedGpxPlaceId = readOnlyGpxTourRef.current?.tour.place_id ?? null
detailPlaceIdRef.current = submittedPlaceId
const requestIsCurrent = () => mountedRef.current
&& activeRef.current
&& requestId === detailRequestIdRef.current
&& detailPlaceIdRef.current === submittedPlaceId
&& String(tripIdRef.current) === submittedTripId
&& contextGenerationRef.current === submittedContextGeneration
&& draftRevisionRef.current === submittedRevision
&& editingPlaceIdRef.current === submittedEditingPlaceId
&& (readOnlyGpxTourRef.current?.tour.place_id ?? null) === submittedGpxPlaceId
setRouteProfileFocus(null)
setOpeningTourId(tour.place_id)
setError(null)
try {
const result = await toursApi.detail(tripId, tour.place_id, controller.signal)
if (!requestIsCurrent() || saveInFlightRef.current) return false
if (result.waypoints.length < 2) {
setError('open')
return false
}
interruptRouting()
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({
id: randomId(),
lat: point.lat,
lng: point.lng,
role: point.role,
})))
localStorage.removeItem(storageKey)
contextGenerationRef.current += 1
draftRevisionRef.current += 1
setNameState(result.tour.name)
setWaypoints(nextWaypoints)
setMaxHikingDifficultyState(result.tour.max_hiking_difficulty ?? 2)
waypointsRef.current = nextWaypoints
difficultyRef.current = result.tour.max_hiking_difficulty ?? 2
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
setHistory({ past: [], future: [] })
setStatus('dirty')
setRoute(null)
setEnrichedGeometry(null)
setRouteForKey(null)
setDistanceMeters(null)
setDurationSeconds(null)
setHasUnsavedChanges(false)
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setSaveOutcome(null)
setReadOnlyGpxTour(null)
return true
} catch {
if (requestIsCurrent() && !saveInFlightRef.current) setError('open')
return false
} finally {
if (mountedRef.current && requestId === detailRequestIdRef.current) {
detailPlaceIdRef.current = null
if (detailAbortRef.current === controller) detailAbortRef.current = null
setOpeningTourId(null)
}
}
}, [interruptRouting, storageKey, tripId])
const retry = useCallback(() => {
if (waypointsRef.current.length < 2) return
interruptRouting()
setError(null)
setStatus('dirty')
setRetryToken(value => value + 1)
}, [interruptRouting])
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
const routeAnalysis = useMemo(
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
[enrichedGeometry, route],
)
const readOnlyGpxAnalysis = useMemo(
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
[readOnlyGpxTour?.routeGeometry],
)
const mode = useMemo(() => selectTourPlannerMode({ readOnlyGpxTour, editingPlaceId, hasUnsavedChanges }), [editingPlaceId, hasUnsavedChanges, readOnlyGpxTour])
const focusOwner = mode.type === 'view-gpx' || mode.type === 'edit-saved' ? `${mode.type}:${mode.placeId}` : mode.type
useEffect(() => { setRouteProfileFocus(null) }, [focusOwner, routeAnalysis, readOnlyGpxAnalysis, readOnlyGpxTour])
useEffect(() => {
if (waypoints.length < 2) {
interruptRouting()
setStatus(waypoints.length === 0 ? 'empty' : 'dirty')
setRoute(null)
setRouteForKey(null)
setEnrichedGeometry(null)
setDistanceMeters(null)
setDurationSeconds(null)
setRouteProfileFocus(null)
setError(null)
return
}
const generation = ++generationRef.current
const controller = new AbortController()
abortRef.current?.abort()
abortRef.current = controller
const timer = window.setTimeout(async () => {
setStatus('routing')
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
if (controller.signal.aborted || generation !== generationRef.current) return
if (!routed) {
setStatus('routing-failed')
setError('routing')
return
}
setRoute(routed.coordinates)
setDistanceMeters(routed.distanceMeters)
setDurationSeconds(routed.durationSeconds)
setRouteForKey(waypointKey)
setEnrichedGeometry(null)
setStatus('enriching-elevation')
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
if (controller.signal.aborted || generation !== generationRef.current) return
if (!enriched) {
setStatus('elevation-failed')
setError('elevation')
return
}
setEnrichedGeometry(enriched)
if (!hasCompleteElevation(enriched)) {
setStatus('elevation-failed')
setError('elevation')
return
}
setStatus('ready')
setError(null)
}, ROUTE_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints])
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 2, name, waypoints, maxHikingDifficulty, editingPlaceId }
localStorage.setItem(storageKey, JSON.stringify(draft))
}, [editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, storageKey, waypoints])
useEffect(() => {
if (!hasUnsavedChanges) return
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [hasUnsavedChanges])
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeForKey === waypointKey && hasCompleteElevation(enrichedGeometry)
const save = useCallback(async () => {
if (!mountedRef.current || saveInFlightRef.current || !editPermissionRef.current || !canSave || !hasCompleteElevation(enrichedGeometry)) return null
invalidateDetailRequest()
saveInFlightRef.current = true
const requestId = ++saveRequestIdRef.current
const submittedRevision = draftRevisionRef.current
const submittedContextGeneration = contextGenerationRef.current
const submittedTripId = String(tripId)
const submittedPlaceId = editingPlaceId
const payload = {
name: name.trim(),
tour_type: 'hike',
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: waypoints.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
role: point.role,
sequence,
})),
max_hiking_difficulty: maxHikingDifficulty,
duration_seconds: durationSeconds,
} as const
setStatus('saving')
setIsSaving(true)
setError(null)
const stillCurrent = () => mountedRef.current
&& requestId === saveRequestIdRef.current
&& String(tripIdRef.current) === submittedTripId
&& editingPlaceIdRef.current === submittedPlaceId
&& contextGenerationRef.current === submittedContextGeneration
&& draftRevisionRef.current === submittedRevision
try {
const result = submittedPlaceId === null
? await toursApi.create(tripId, payload)
: await toursApi.update(tripId, submittedPlaceId, payload)
if (stillCurrent()) {
localStorage.removeItem(storageKey)
setHasUnsavedChanges(false)
setStatus('saved')
setHistory({ past: [], future: [] })
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setSaveOutcome(result.tour)
}
if (mountedRef.current && String(tripIdRef.current) === submittedTripId) {
try { await onSaved?.(result) } catch { return result }
}
return result
} catch {
if (stillCurrent()) {
setStatus('ready')
setError('save')
}
return null
} finally {
if (mountedRef.current && requestId === saveRequestIdRef.current) {
saveInFlightRef.current = false
setIsSaving(false)
}
}
}, [canSave, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, storageKey, tripId, waypoints])
return {
canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, removeWaypoint, moveWaypoint,
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
status, error, retry, isSaving,
route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
routeProfileFocus, setRouteProfileFocus,
mapBaseLayer, setMapBaseLayer,
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
startNewTour, newTourConfirmationOpen, cancelNewTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored,
editingPlaceId, openingTourId, openTour,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral,
}
}
export type TourPlannerController = ReturnType<typeof useTourPlanner>
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
describe('tourPresentation', () => {
it('uses the source abstraction for TREK, GPX import, and wanderer badges', () => {
const t = vi.fn((key: string) => key)
expect(tourSource({ wanderer_ref: null })).toBe('trek')
expect(hikeSourceBadgeLabel({ wanderer_ref: null }, t)).toBe('tours.badge.trek')
expect(tourSource({ wanderer_ref: null, has_waypoints: false })).toBe('gpx')
expect(hikeSourceBadgeLabel({ wanderer_ref: null, has_waypoints: false }, t)).toBe('tours.badge.gpx')
expect(tourSource({ wanderer_ref: 'wanderer:123' })).toBe('wanderer')
expect(hikeSourceBadgeLabel({ wanderer_ref: 'wanderer:123' }, t)).toBe('tours.badge.wanderer')
})
})
@@ -0,0 +1,22 @@
import type { TourListItem } from '@trek/shared'
export type TourSource = 'trek' | 'gpx' | 'wanderer'
type Translate = (key: string, params?: Record<string, unknown>) => string
/**
* Presentation-only source abstraction until provenance receives its own column.
* A wanderer reference is authoritative. Planner-authored TREK tours persist
* routing controls, while GPX imports intentionally remain geometry-only.
*/
export function tourSource(tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>): TourSource {
if (tour.wanderer_ref) return 'wanderer'
return tour.has_waypoints === false ? 'gpx' : 'trek'
}
export function hikeSourceBadgeLabel(
tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>,
t: Translate,
): string {
return t(`tours.badge.${tourSource(tour)}`)
}
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import en from '@trek/shared/i18n/en'
import de from '@trek/shared/i18n/de'
import fr from '@trek/shared/i18n/fr'
import type { TourListItem } from '@trek/shared'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import TourListRow from './TourListRow'
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
import type { TourPlannerController } from './planner/useTourPlanner'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
const tour: TourListItem = {
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4,
elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null,
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
max_hiking_difficulty: 2, planned: false, caution: false,
}
function toursText(strings: typeof en, key: string): string {
const value = strings[key]
if (typeof value !== 'string') throw new Error(`Missing Tours text: ${key}`)
return value
}
function planner(overrides: Partial<TourPlannerController>): TourPlannerController {
return {
canEdit: true, canAssign: true,
mode: { type: 'neutral' }, status: 'empty', error: null, name: '',
maxHikingDifficulty: 2, waypoints: [], hasUnsavedChanges: false,
draftRestored: false, newTourConfirmationOpen: false,
readOnlyGpxTour: null, readOnlyGpxAnalysis: null,
route: null, routeAnalysis: null, elevationProfileExpanded: true,
...overrides,
} as TourPlannerController
}
function assertNoRawToursKeys() {
const visible = [document.body.textContent, ...Array.from(document.querySelectorAll('[aria-label], [title], [placeholder]'))
.flatMap(element => ['aria-label', 'title', 'placeholder'].map(attribute => element.getAttribute(attribute)))]
.filter(Boolean).join(' ')
expect(visible).not.toMatch(/\b(?:tours|tourTypes)\.[a-zA-Z]+/)
}
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { language: 'en', distance_unit: 'metric' } })
})
describe('Tours English and German copy', () => {
it('has exact merged Tours keys and matching interpolation parameters in both languages', () => {
const keys = Object.keys(en).filter(key => key.startsWith('tours.') || key.startsWith('tourTypes.'))
expect(keys.length).toBeGreaterThan(100)
expect(toursText(de, 'trip.tabs.tourPlanner')).toBe('Touren')
expect(Object.keys(de).filter(key => key.startsWith('tours.') || key.startsWith('tourTypes.')).sort()).toEqual(keys.sort())
for (const key of keys) {
const params = (text: string) => [...text.matchAll(/\{([^{}]+)\}/g)].map(match => match[1]).sort()
expect(params(toursText(de, key)), key).toEqual(params(toursText(en, key)))
}
for (let level = 1; level <= 6; level++) {
expect(en[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
expect(de[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
}
expect(de['tours.planner.safetyNoteDetails']).toMatch(/OSM.*Wetter.*Sperrungen/)
expect(de['tours.planner.difficulty.t3Warning']).toMatch(/Trittsicherheit/)
expect(de['tours.planner.difficulty.alpineBody']).toMatch(/OSM/)
})
for (const [language, neutral, newTitle, editTitle, gpxTitle, back] of [
['en', 'Plan a tour', 'New tour', 'Edit tour', 'GPX tour', 'Back to Tours'],
['de', 'Tour planen', 'Neue Tour', 'Tour bearbeiten', 'GPX-Tour', 'Zurück zu den Touren'],
]) {
it(`${language}: renders the four modes, status, safety and accessibility without raw keys`, async () => {
seedStore(useSettingsStore, { settings: { language } })
const { rerender } = render(<TourPlannerRail planner={planner({})} />)
expect(await screen.findByText(neutral)).toBeInTheDocument()
assertNoRawToursKeys()
const waypoint = { id: 'first', lat: 48, lng: 11, role: 'start' as const }
rerender(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, status: 'routing', hasUnsavedChanges: true,
draftRestored: true, waypoints: [waypoint], maxHikingDifficulty: 3,
})} />)
expect(screen.getByRole('heading', { name: newTitle })).toBeInTheDocument()
const strings = language === 'de' ? de : en
expect(screen.getByText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNoteDetails')) === true)).toBeInTheDocument()
expect(screen.getByLabelText(language === 'de' ? 'Wegpunkt 1' : 'Waypoint 1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: language === 'de' ? 'Maximale Wegschwierigkeit' : 'Maximum trail difficulty' })).toBeInTheDocument()
assertNoRawToursKeys()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'edit-saved', placeId: 42 }, editingPlaceId: 42 })} />)
expect(screen.getByRole('heading', { name: editTitle })).toBeInTheDocument()
expect(screen.getByRole('button', { name: back })).toHaveAttribute('aria-label', back)
assertNoRawToursKeys()
const geometry = JSON.stringify([[48, 11, 500], [48.01, 11.02, 550]])
rerender(<TourPlannerRail planner={planner({
mode: { type: 'view-gpx', placeId: 42, tour },
readOnlyGpxTour: { tour, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})} />)
expect(screen.getByRole('heading', { name: gpxTitle })).toBeInTheDocument()
expect(screen.getByRole('button', { name: language === 'de' ? 'Höhenprofil einklappen' : 'Collapse elevation profile' })).toHaveAttribute('aria-expanded', 'true')
assertNoRawToursKeys()
})
}
it('German ADD row and save outcome have localized badges, labels and day picker', async () => {
seedStore(useSettingsStore, { settings: { language: 'de' } })
const { rerender } = render(<TourListRow tour={tour} onSelect={() => {}} />)
await waitFor(() => expect(screen.getByTitle(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument())
expect(screen.getByTitle(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument()
rerender(<TourPlannerToursRail planner={planner({ saveOutcome: tour, mode: { type: 'edit-saved', placeId: 42 } })} tours={[tour]} days={[{ id: 7, title: 'Gipfeltag', date: '2026-05-15' } as never]} loading={false} onAssignToDay={vi.fn()} onViewGpxTour={vi.fn()} />)
expect(screen.getByText(toursText(de, 'tours.planner.saved'))).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.assignToDay') }))
expect(screen.getByLabelText(toursText(de, 'tours.addToDay.pickDay'))).toBeInTheDocument()
assertNoRawToursKeys()
})
it('German routing, elevation, save errors and alpine acknowledgement use translated copy', async () => {
seedStore(useSettingsStore, { settings: { language: 'de' } })
const { rerender } = render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
await screen.findByRole('heading', { name: toursText(de, 'tours.planner.newTitle') })
for (const [status, key] of [
['routing-failed', 'tours.planner.status.routingFailed'],
['enriching-elevation', 'tours.planner.status.elevation'],
['elevation-failed', 'tours.planner.status.elevationFailed'],
['saving', 'tours.planner.status.saving'],
] as const) {
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status, error: null })} />)
expect(screen.getByText(toursText(de, key))).toBeInTheDocument()
assertNoRawToursKeys()
}
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
expect(screen.getByText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.maxDifficulty') }))
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.difficulty.t4') }))
expect(screen.getByRole('heading', { name: toursText(de, 'tours.planner.difficulty.alpineTitle') })).toBeInTheDocument()
expect(screen.getByText(toursText(de, 'tours.planner.difficulty.alpineBody'))).toBeInTheDocument()
assertNoRawToursKeys()
})
it('German dirty-draft confirmation and GPX missing-elevation text are localized', async () => {
seedStore(useSettingsStore, { settings: { language: 'de' } })
const { rerender } = render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, hasUnsavedChanges: true })} />)
await screen.findByRole('heading', { name: toursText(de, 'tours.planner.newTitle') })
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.backToTours') }))
expect(screen.getByRole('heading', { name: toursText(de, 'tours.planner.discardTitle') })).toBeInTheDocument()
expect(screen.getByText(toursText(de, 'tours.planner.discardBody'))).toBeInTheDocument()
expect(screen.getByRole('button', { name: toursText(de, 'tours.planner.discardUnsaved') })).toBeInTheDocument()
assertNoRawToursKeys()
rerender(<TourPlannerRail planner={planner({
mode: { type: 'view-gpx', placeId: tour.place_id, tour },
readOnlyGpxTour: { tour, routeGeometry: null },
})} />)
expect(screen.getByText(toursText(de, 'tours.planner.gpxNoGeometry'))).toBeInTheDocument()
expect(screen.getByText(toursText(de, 'tours.detail.gpxReadOnly'))).toBeInTheDocument()
assertNoRawToursKeys()
})
it('French Tours copy loads from the registered locale domain', async () => {
seedStore(useSettingsStore, { settings: { language: 'fr' } })
function Sample() {
const { t } = useTranslation()
return <span>{t('tours.planner.neutralTitle')}</span>
}
render(<Sample />)
await waitFor(() => expect(document.documentElement.lang).toBe('fr'))
expect(await screen.findByText(toursText(fr, 'tours.planner.neutralTitle'))).toBeInTheDocument()
assertNoRawToursKeys()
})
})
@@ -0,0 +1,208 @@
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
const state = vi.hoisted(() => ({
trip: { id: 7, user_id: 1 } as { id: number; user_id: number } | null,
user: { id: 2, role: 'user' } as { id: number; role: string } | null,
importGpx: vi.fn(),
loadTrip: vi.fn(),
create: vi.fn(),
update: vi.fn(),
detail: vi.fn(),
route: vi.fn(),
elevation: vi.fn(),
}))
vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip }) }))
vi.mock('../../store/authStore', () => ({ useAuthStore: (selector: (store: unknown) => unknown) => selector({ user: state.user }) }))
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (selector: (store: unknown) => unknown) => selector({ settings: { distance_unit: 'metric' } }) }))
vi.mock('../../api/client', () => ({ toursApi: { importGpx: state.importGpx, create: state.create, update: state.update, detail: state.detail } }))
vi.mock('./planner/tourRouting', () => ({ routeWalkingTour: state.route, enrichTourElevations: state.elevation }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key, locale: 'en' }), translateApiError: () => 'error' }))
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }) }))
vi.mock('../shared/ConfirmDialog', () => ({ default: () => null }))
vi.mock('../shared/EmptyState', () => ({ default: ({ action }: { action?: React.ReactNode }) => <div>{action}</div> }))
vi.mock('../shared/CustomSelect', () => ({ default: ({ id, ariaLabel, disabled, value }: { id: string; ariaLabel: string; disabled: boolean; value: number }) => <select id={id} aria-label={ariaLabel} disabled={disabled} value={value} onChange={() => {}}><option value={value}>{value}</option></select> }))
vi.mock('../shared/ElevationProfile', () => ({ default: () => null }))
vi.mock('../shared/DetailShell', () => ({ default: ({ header, footer, children }: { header: React.ReactNode; footer: React.ReactNode; children: React.ReactNode }) => <div>{header}{children}{footer}</div> }))
vi.mock('../shared/TrackColorPicker', () => ({ default: ({ onChange }: { onChange: (color: string) => void }) => <button onClick={() => onChange('#ff0000')}>color</button> }))
vi.mock('../shared/NavigationMenu', () => ({ NavigationMenu: () => null }))
vi.mock('../shared/markdownLink', () => ({ markdownLinkComponents: {} }))
vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn() }))
vi.mock('../Planner/placeNavigation', () => ({ getNavigationTargets: () => [], openNavigationTarget: vi.fn() }))
import { usePermissionsStore } from '../../store/permissionsStore'
import { useTourPermissions } from './useTourPermissions'
import ToursSidebar from './ToursSidebar'
import { useTourPlanner } from './planner/useTourPlanner'
import type { TourPlannerController } from './planner/useTourPlanner'
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
import TourDetailDialog from './TourDetailDialog'
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem
const days = [{ id: 3, title: 'Summit' }] as never
beforeEach(() => {
vi.clearAllMocks()
state.trip = { id: 7, user_id: 1 }
state.user = { id: 2, role: 'user' }
state.importGpx.mockResolvedValue({ tours: [tour] })
usePermissionsStore.setState({ permissions: { place_edit: 'admin', day_edit: 'admin' } })
})
afterEach(() => { cleanup(); vi.useRealTimers(); localStorage.clear() })
describe('Tours permissions without database setup', () => {
it('revoking sidebar rights closes assignment choices and blocks file-input invocation', async () => {
const assign = vi.fn()
const props = { tripId: 7, days, tours: [tour], onAssignToDay: assign }
const { container, rerender } = render(<ToursSidebar {...props} canEdit canAssign />)
fireEvent.click(screen.getByRole('button', { name: 'tours.addToDay' }))
expect(screen.getByRole('button', { name: /Summit/ })).toBeTruthy()
rerender(<ToursSidebar {...props} canEdit={false} canAssign={false} />)
expect(screen.queryByRole('button', { name: /Summit/ })).toBeNull()
await act(async () => { fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['gpx'], 'ridge.gpx')] } }) })
expect(state.importGpx).not.toHaveBeenCalled()
expect(assign).not.toHaveBeenCalled()
})
it.each([[false, false], [true, false], [false, true], [true, true]])('update edit=%s assign=%s writes only with place rights and never assigns', async (canEdit, canAssign) => {
vi.useFakeTimers()
state.detail.mockResolvedValue({ tour, waypoints: [{ lat: 48, lng: 11, role: 'start' }, { lat: 49, lng: 12, role: 'end' }] })
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
state.update.mockResolvedValue({ tour, waypoints: [] })
const saved = vi.fn()
const { result } = renderHook(() => useTourPlanner({ tripId: 7, canEdit, canAssign, onSaved: saved }))
await act(async () => { await result.current.openTour(tour) })
expect(state.detail).toHaveBeenCalledWith(7, 42, expect.any(AbortSignal))
act(() => result.current.setName('Updated ridge'))
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.canSave).toBe(canEdit)
await act(async () => { await result.current.save() })
expect(state.update).toHaveBeenCalledTimes(canEdit ? 1 : 0)
expect(saved).toHaveBeenCalledTimes(canEdit ? 1 : 0)
expect(state.create).not.toHaveBeenCalled()
})
it.each([[false, false], [true, false], [false, true], [true, true]])('detail/mobile edit=%s assign=%s gate independent actions and preserve viewing', async (canEdit, canAssign) => {
const update = vi.fn()
const remove = vi.fn()
const assign = vi.fn()
const del = vi.fn()
const upload = vi.fn().mockResolvedValue(undefined)
const place = { id: 42, trip_id: 7, name: 'Ridge', lat: 48, lng: 11, route_geometry: null } as never
const props = { tour, place, selectedDayId: 3, canEdit, canAssign, onClose: vi.fn(), onUpdatePlace: update, onAssignToDay: assign, onRemoveAssignment: remove, onDelete: del, onFileUpload: upload }
const { container, rerender } = render(<TourDetailDialog {...props} />)
const uploadInput = container.querySelector('input[type=file]')
expect(Boolean(uploadInput)).toBe(canEdit)
if (uploadInput) await act(async () => { fireEvent.change(uploadInput, { target: { files: [new File(['file'], 'ridge.txt')] } }) })
expect(upload).toHaveBeenCalledTimes(canEdit ? 1 : 0)
expect(Boolean(screen.queryByRole('button', { name: 'common.edit' }))).toBe(canEdit)
expect(Boolean(screen.queryByRole('button', { name: 'common.delete' }))).toBe(canEdit)
expect(Boolean(screen.queryByRole('button', { name: 'inspector.addToDay' }))).toBe(canAssign)
if (canEdit) {
fireEvent.click(screen.getByRole('button', { name: 'color' }))
expect(update).toHaveBeenCalledWith(42, { route_color: '#ff0000' })
fireEvent.click(screen.getByRole('button', { name: 'common.edit' }))
fireEvent.change(screen.getByDisplayValue('Ridge'), { target: { value: 'Renamed' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.save' })) })
expect(update).toHaveBeenCalledWith(42, { name: 'Renamed' })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(del).toHaveBeenCalledOnce()
}
if (canAssign) fireEvent.click(screen.getByRole('button', { name: 'inspector.addToDay' }))
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0)
rerender(<TourDetailDialog {...props} assignments={{ '3': [{ id: 9, place }] } as never} />)
expect(Boolean(screen.queryByRole('button', { name: 'inspector.removeFromDay' }))).toBe(canAssign)
if (canAssign) fireEvent.click(screen.getByRole('button', { name: 'inspector.removeFromDay' }))
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
const view = vi.fn()
const openSheet = vi.fn()
rerender(<MToursSelectionList planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('option'))
expect(view).toHaveBeenCalledWith(42)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
expect(attach.disabled).toBe(!canAssign)
fireEvent.click(attach)
expect(openSheet).toHaveBeenCalledTimes(canAssign ? 1 : 0)
})
it.each([[false, false], [true, false], [false, true], [true, true]])('rails edit=%s assign=%s separate save and existing-tour attach', (canEdit, canAssign) => {
const planner = {
mode: { type: 'new-draft' }, name: 'Ridge', status: 'ready', maxHikingDifficulty: 2,
waypoints: [], canSave: true, save: vi.fn(), route: null, saveOutcome: tour,
startNewTour: vi.fn(), openTour: vi.fn().mockResolvedValue(true),
} as unknown as TourPlannerController
const assign = vi.fn()
render(<><TourPlannerRail planner={planner} canEdit={canEdit} canAssign={canAssign} /><TourPlannerToursRail planner={planner} tours={[tour]} days={days} loading={false} onAssignToDay={assign} onViewGpxTour={vi.fn()} canEdit={canEdit} canAssign={canAssign} /></>)
const save = screen.getByRole('button', { name: 'tours.planner.save' }) as HTMLButtonElement
expect(save.disabled).toBe(!canEdit)
fireEvent.click(save)
expect(planner.save).toHaveBeenCalledTimes(canEdit ? 1 : 0)
expect((screen.getByRole('button', { name: 'tours.planner.assignToDay' }) as HTMLButtonElement).disabled).toBe(!canAssign)
const picker = screen.queryByRole('combobox', { name: 'tours.addToDay: Ridge' })
expect(Boolean(picker)).toBe(canAssign)
if (picker) fireEvent.change(picker, { target: { value: '3' } })
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0)
fireEvent.click(screen.getByRole('option', { name: /Ridge/ }))
expect(planner.openTour).toHaveBeenCalledWith(tour)
})
it.each([[false, false], [true, false], [false, true], [true, true]])('planner edit=%s assign=%s gates direct create and retained save after revocation', async (canEdit, canAssign) => {
vi.useFakeTimers()
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
state.create.mockResolvedValue({ tour, waypoints: [] })
const { result, rerender } = renderHook(({ edit }) => useTourPlanner({ tripId: 7, canEdit: edit, canAssign }), { initialProps: { edit: canEdit } })
act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(49, 12) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.canSave).toBe(canEdit)
expect(result.current.waypoints.length).toBe(canEdit ? 2 : 0)
await act(async () => { await result.current.save() })
expect(state.create).toHaveBeenCalledTimes(canEdit ? 1 : 0)
expect(state.update).not.toHaveBeenCalled()
if (canEdit) {
act(() => result.current.setName('Updated'))
const retainedSave = result.current.save
rerender({ edit: false })
await act(async () => { await retainedSave(); await result.current.save() })
expect(state.update).not.toHaveBeenCalled()
}
})
it.each([[false, false], [true, false], [false, true], [true, true]])('sidebar edit=%s assign=%s keeps reads available and gates writes', async (canEdit, canAssign) => {
const assign = vi.fn()
const select = vi.fn()
const { container } = render(<ToursSidebar tripId={7} days={days} tours={[tour]} canEdit={canEdit} canAssign={canAssign} onAssignToDay={assign} onSelectTour={select} />)
const row = screen.getByRole('option')
fireEvent.click(row)
expect(select).toHaveBeenCalledWith(tour, row)
expect((screen.getByText('places.importFile').closest('button') as HTMLButtonElement).disabled).toBe(!canEdit)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
expect(attach.disabled).toBe(!canAssign)
fireEvent.click(attach)
if (canAssign) fireEvent.click(screen.getByRole('button', { name: /Summit/ }))
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0)
await act(async () => { fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['gpx'], 'ridge.gpx')] } }) })
expect(state.importGpx).toHaveBeenCalledTimes(canEdit ? 1 : 0)
})
it('omitted props use actual configured rights and reject missing or mismatched trip context', () => {
usePermissionsStore.setState({ permissions: { place_edit: 'trip_member', day_edit: 'trip_owner' } })
const { result, rerender } = renderHook(() => useTourPermissions({ tripId: 7 }))
expect(result.current).toEqual({ canEdit: true, canAssign: false })
state.trip = { id: 8, user_id: 1 }
rerender()
expect(result.current).toEqual({ canEdit: false, canAssign: false })
state.trip = null
rerender()
expect(result.current).toEqual({ canEdit: false, canAssign: false })
state.trip = { id: 7, user_id: 1 }
state.user = null
rerender()
expect(result.current).toEqual({ canEdit: false, canAssign: false })
})
})
@@ -0,0 +1,17 @@
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
export interface TourPermissionProps {
canEdit?: boolean
canAssign?: boolean
}
export function useTourPermissions({ canEdit, canAssign, tripId }: TourPermissionProps & { tripId?: number | string } = {}) {
const can = useCanDo()
const trip = useTripStore(state => state.trip)
const hasTrip = trip != null && (tripId == null || String(trip.id) === String(tripId))
return {
canEdit: canEdit ?? (hasTrip && can('place_edit', trip)),
canAssign: canAssign ?? (hasTrip && can('day_edit', trip)),
}
}
@@ -0,0 +1,134 @@
import { useState } from 'react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { TourListItem } from '@trek/shared'
import { TranslationProvider } from '../../i18n/TranslationContext'
import { TourPlannerRail, TourPlannerToursRail } from '../Tours/planner/TourPlannerPanels'
import type { TourPlannerController } from '../Tours/planner/useTourPlanner'
import ToursSidebar from '../Tours/ToursSidebar'
import type { Day } from '../../types'
import CustomSelect from './CustomSelect'
vi.mock('../../api/client', () => ({ toursApi: {} }))
vi.mock('../../store/settingsStore', () => ({
useSettingsStore: (selector: (state: { settings: { language: string; distance_unit: string } }) => unknown) =>
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
}))
vi.mock('../../store/tripStore', () => ({
useTripStore: (selector: (state: { loadTrip: () => Promise<void> }) => unknown) =>
selector({ loadTrip: vi.fn() }),
}))
const tour: TourListItem = {
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4,
elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null,
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
max_hiking_difficulty: 2, planned: false, caution: false,
}
const days = [{ id: 7, trip_id: 1, day_number: 1, title: 'Summit day', date: '2026-05-15' } as Day]
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
return {
mode: { type: 'new-draft' }, maxHikingDifficulty: 2,
setMaxHikingDifficulty: vi.fn(), name: '', setName: vi.fn(),
status: 'empty', waypoints: [], route: [], routeAnalysis: null,
distanceMeters: 0, durationSeconds: 0, hasUnsavedChanges: false,
elevationProfileExpanded: false, readOnlyGpxTour: null,
saveOutcome: null, returnToNeutral: vi.fn(), startNewTour: vi.fn(),
...overrides,
} as TourPlannerController
}
describe('CustomSelect consumer contracts', () => {
it('renders the real labelled T1-T6 selector and updates difficulty through the merged select', async () => {
const user = userEvent.setup()
const setDifficulty = vi.fn()
function DifficultyRail() {
const [difficulty, setDifficultyState] = useState<number>(2)
return <TourPlannerRail planner={planner({
maxHikingDifficulty: difficulty,
setMaxHikingDifficulty: next => { setDifficulty(next); setDifficultyState(next) },
})} canEdit />
}
render(<TranslationProvider><DifficultyRail /></TranslationProvider>)
const trigger = screen.getByRole('button', { name: 'Maximum trail difficulty' })
expect(screen.getByLabelText('Maximum trail difficulty')).toBe(trigger)
expect(trigger).toHaveAttribute('id', 'tour-planner-difficulty')
expect(document.querySelectorAll('[id="tour-planner-difficulty"]')).toHaveLength(1)
await user.click(trigger)
const menu = within(document.getElementById(trigger.getAttribute('aria-controls')!)!)
for (const difficulty of [1, 2, 3, 4, 5, 6]) {
expect(menu.getByRole('button', { name: new RegExp(`^T${difficulty}`) })).toBeInTheDocument()
}
await user.click(menu.getByRole('button', { name: /^T3/ }))
expect(setDifficulty).toHaveBeenCalledWith(3)
expect(trigger).toHaveTextContent('T3')
expect(trigger).toHaveFocus()
await user.click(trigger)
await user.click(screen.getByRole('button', { name: /^T4/ }))
expect(screen.getByRole('heading', { name: 'Enable alpine routing?' })).toBeInTheDocument()
expect(setDifficulty).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'Enable alpine routing' }))
expect(setDifficulty).toHaveBeenLastCalledWith(4)
})
it('keeps the labelled native post-save day picker resolving numeric day ids', async () => {
const user = userEvent.setup()
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
render(<TranslationProvider><TourPlannerToursRail
canAssign
planner={planner({ saveOutcome: tour, editingPlaceId: 42 })}
tours={[tour]} days={days} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()}
/></TranslationProvider>)
await user.click(screen.getByRole('button', { name: 'Assign to a day' }))
const picker = screen.getByRole('combobox', { name: 'Pick a day' })
expect(picker).toHaveAttribute('id', 'tour-planner-assign-day')
await user.selectOptions(picker, '7')
expect(onAssignToDay).toHaveBeenCalledWith(42, 7)
})
it('keeps the real ADD Tours All/Unplanned/Planned filters working', async () => {
const user = userEvent.setup()
render(<TranslationProvider><ToursSidebar tripId={1} days={days}
tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]}
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
/></TranslationProvider>)
expect(screen.getAllByRole('option')).toHaveLength(2)
const filter = screen.getByRole('button', { name: 'Show' })
await user.click(filter)
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /Planned\s*1/ }))
expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
expect(screen.getAllByRole('option')).toHaveLength(2)
})
it('preserves the Places filter select contract with labels, badges and compact sizing', async () => {
const user = userEvent.setup()
const setFilter = vi.fn()
function PlacesFilter() {
const [filter, updateFilter] = useState<string | number>('all')
return <CustomSelect size="sm" value={filter}
onChange={next => { setFilter(next); updateFilter(next) }}
options={[
{ value: 'all', label: 'All', badge: '3' },
{ value: 'unplanned', label: 'Unplanned', badge: '2' },
{ value: 'planned', label: 'Planned', badge: '1' },
]}
/>
}
render(<PlacesFilter />)
await user.click(screen.getByRole('button', { name: /^All\s*3$/ }))
await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ }))
expect(setFilter).toHaveBeenCalledWith('unplanned')
expect(screen.getByRole('button', { name: /^Unplanned\s*2$/ })).toHaveFocus()
await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ }))
await user.click(screen.getByRole('button', { name: /^Planned\s*1$/ }))
expect(setFilter).toHaveBeenLastCalledWith('planned')
})
})
@@ -1,4 +1,4 @@
import { render, screen, within } from '../../../tests/helpers/render';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from './CustomSelect';
@@ -193,4 +193,101 @@ describe('CustomSelect', () => {
// Dropdown should not be in the DOM — options remain hidden
expect(screen.queryByText('Apple')).toBeNull();
});
it('applies the external label id only to the trigger', () => {
render(<><label htmlFor="fruit">Fruit</label><CustomSelect id="fruit" value="apple" onChange={onChange} options={OPTIONS} /></>);
expect(screen.getByRole('button', { name: 'Fruit' })).toHaveAttribute('id', 'fruit');
expect(document.querySelectorAll('[id="fruit"]')).toHaveLength(1);
});
it.each([undefined, 'fruit'])('keeps the explicit accessible name with id %s', id => {
render(<CustomSelect id={id} ariaLabel="Choose fruit" value="apple" onChange={onChange} options={OPTIONS} />);
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
expect(trigger).toHaveAttribute('aria-label', 'Choose fruit');
expect(Array.from(trigger.attributes).filter(attribute => attribute.name === 'aria-label')).toHaveLength(1);
if (id) expect(trigger).toHaveAttribute('id', id);
});
it('associates two independent portals without duplicate ids', async () => {
const user = userEvent.setup();
const { container } = render(<>
<CustomSelect id="first-fruit" ariaLabel="First fruit" value="apple" onChange={onChange} options={OPTIONS} />
<CustomSelect id="second-fruit" ariaLabel="Second fruit" value="banana" onChange={onChange} options={OPTIONS} />
</>);
const first = screen.getByRole('button', { name: 'First fruit' });
const second = screen.getByRole('button', { name: 'Second fruit' });
await user.click(first);
const firstMenuId = first.getAttribute('aria-controls')!;
expect(container.contains(document.getElementById(firstMenuId))).toBe(false);
await user.click(second);
const secondMenuId = second.getAttribute('aria-controls')!;
expect(firstMenuId).not.toBe(secondMenuId);
expect(document.getElementById(secondMenuId)).toHaveAttribute('role', 'group');
const ids = Array.from(document.querySelectorAll('[id]'), element => element.id);
expect(new Set(ids).size).toBe(ids.length);
await user.click(within(document.getElementById(secondMenuId)!).getByRole('button', { name: 'Cherry' }));
expect(onChange).toHaveBeenCalledWith('cherry');
expect(second).toHaveFocus();
expect(second).toHaveAttribute('aria-expanded', 'false');
});
it.each(['{Enter}', ' '])('supports Tab, %s, arrows, selection and Escape', async openingKey => {
const user = userEvent.setup();
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} />);
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
await user.tab();
expect(trigger).toHaveFocus();
await user.keyboard(openingKey);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowDown}{ArrowDown}');
expect(screen.getByRole('button', { name: 'Banana' })).toHaveFocus();
await user.keyboard('{ArrowUp}{Enter}');
expect(onChange).toHaveBeenCalledWith('apple');
expect(trigger).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('button', { name: 'Cherry' })).toHaveFocus();
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('group')).toBeNull();
});
it('keeps searchable focus and supports arrow navigation from the search field', async () => {
const user = userEvent.setup();
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
await user.click(trigger);
expect(screen.getByPlaceholderText('...')).toHaveFocus();
await user.type(screen.getByPlaceholderText('...'), 'ban');
await user.keyboard('{ArrowDown}{Enter}');
expect(onChange).toHaveBeenCalledWith('banana');
expect(trigger).toHaveFocus();
});
it('keeps compact rendering, icon, badge and disabled keyboard behavior', async () => {
const user = userEvent.setup();
render(<CustomSelect ariaLabel="Disabled fruit" value="apple" onChange={onChange} size="sm" disabled options={[{ value: 'apple', label: 'Apple', badge: '3', icon: <span data-testid="fruit-icon" /> }]} />);
const trigger = screen.getByRole('button', { name: 'Disabled fruit' });
expect(trigger).toBeDisabled();
expect(trigger.style.padding).toBe('8px 12px');
expect(within(trigger).getByText('3')).toBeInTheDocument();
expect(within(trigger).getByTestId('fruit-icon')).toBeInTheDocument();
await user.tab();
await user.keyboard('{Enter}{ArrowDown}');
expect(screen.queryByRole('group')).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
it.each([
['{ArrowDown}', 'Apple'],
['{ArrowUp}', 'Cherry'],
])('keyboard opening a searchable menu with %s retains option focus', async (key, option) => {
const user = userEvent.setup();
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
await user.tab();
await user.keyboard(key);
expect(screen.getByRole('button', { name: option })).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.getByRole('button', { name: 'Choose fruit' })).toHaveFocus();
});
});
+50 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'
import React, { useState, useRef, useEffect, useId } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
@@ -16,6 +16,7 @@ interface SelectOption {
}
interface CustomSelectProps {
ariaLabel?: string
value: string | number
onChange: (value: string | number) => void
options?: SelectOption[]
@@ -70,6 +71,7 @@ function menuRoom(anchor: HTMLElement | null, left: number): number {
}
export default function CustomSelect({
ariaLabel,
value,
onChange,
options = [],
@@ -84,8 +86,11 @@ export default function CustomSelect({
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const keyboardDirection = useRef<'first' | 'last' | null>(null)
const menuId = useId()
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
@@ -93,12 +98,20 @@ export default function CustomSelect({
useEffect(() => {
if (!open || !searchable || !searchRef.current) return
searchRef.current.focus()
if (!keyboardDirection.current) searchRef.current.focus()
// Focusing raises the keyboard on a phone; scroll the trigger up so the list
// it just opened is not left underneath it (#2000).
scrollAnchorIntoView(ref.current)
}, [open, searchable])
useEffect(() => {
if (!open || !keyboardDirection.current) return
const buttons = dropRef.current?.querySelectorAll<HTMLButtonElement>('button')
const direction = keyboardDirection.current
keyboardDirection.current = null
if (buttons?.length) buttons[direction === 'last' ? buttons.length - 1 : 0].focus()
}, [open])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
@@ -137,13 +150,46 @@ export default function CustomSelect({
const sm = size === 'sm'
const closeMenu = () => {
setOpen(false)
setSearch('')
triggerRef.current?.focus()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return
if (event.key === 'Escape' && open) {
event.preventDefault()
event.stopPropagation()
closeMenu()
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const backwards = event.key === 'ArrowUp'
if (!open) {
keyboardDirection.current = backwards ? 'last' : 'first'
setSearch('')
setOpen(true)
} else {
const buttons = Array.from(dropRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const current = buttons.findIndex(button => button === document.activeElement)
const next = current < 0 ? (backwards ? buttons.length - 1 : 0) : (current + (backwards ? -1 : 1) + buttons.length) % buttons.length
buttons[next]?.focus()
}
}
}
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{/* Trigger */}
<button
ref={triggerRef}
id={id}
type="button"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={open ? menuId : undefined}
disabled={disabled}
onKeyDown={handleKeyDown}
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
@@ -174,7 +220,7 @@ export default function CustomSelect({
{/* Dropdown */}
{open && createPortal(
<div ref={dropRef} style={{
<div ref={dropRef} id={menuId} role="group" aria-label={ariaLabel || selected?.label || placeholder || undefined} onKeyDown={handleKeyDown} style={{
position: 'fixed',
...(anchored
? {
@@ -255,7 +301,7 @@ export default function CustomSelect({
<button
key={option.value}
type="button"
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
onClick={() => { onChange(option.value); closeMenu() }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 6,
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DetailShell from './DetailShell'
describe('DetailShell', () => {
it('centers between planner panels and owns header, scrolling, footer and close behavior', () => {
const onClose = vi.fn()
const { container } = render(
<DetailShell
header={<h2>Detail title</h2>}
footer={<button type="button">Action</button>}
onClose={onClose}
closeLabel="Close"
leftWidth={240}
rightWidth={320}
testId="detail-scroll"
>
<p>Detail content</p>
</DetailShell>,
)
const shell = container.firstElementChild as HTMLElement
expect(shell.style.left).toBe('calc(240px + 0.5 * (100% - 240px - 320px))')
expect(screen.getByTestId('detail-scroll')).toHaveTextContent('Detail content')
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,85 @@
import type { CSSProperties, ReactNode } from 'react'
import { X } from 'lucide-react'
interface DetailShellProps {
header: ReactNode
children: ReactNode
footer?: ReactNode
onClose: () => void
closeLabel: string
leftWidth?: number
rightWidth?: number
headerStyle?: CSSProperties
contentStyle?: CSSProperties
closeButtonClassName?: string
testId?: string
}
/** Shared map-detail surface: panel-aware positioning, chrome and scrolling. */
export default function DetailShell({
header,
children,
footer,
onClose,
closeLabel,
leftWidth = 0,
rightWidth = 0,
headerStyle,
contentStyle,
closeButtonClassName = '',
testId,
}: DetailShellProps) {
return (
<div
style={{
position: 'absolute',
bottom: 20,
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
transform: 'translateX(-50%)',
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
zIndex: 50,
fontFamily: 'var(--font-system)',
}}
>
<div
className="bg-surface-elevated text-content"
style={{
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRadius: 20,
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
maxHeight: '60vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className="border-b border-edge-faint" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '18px 16px 14px', flexShrink: 0, ...headerStyle }}>
{header}
<button
type="button"
onClick={onClose}
aria-label={closeLabel}
className={`bg-surface-hover ${closeButtonClassName}`}
style={{ width: 28, height: 28, borderRadius: '50%', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={event => { event.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={event => { event.currentTarget.style.background = 'var(--bg-hover)' }}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
<div
data-testid={testId}
style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10, ...contentStyle }}
>
{children}
</div>
{footer && (
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
{footer}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,157 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ElevationProfile from './ElevationProfile'
import type { RouteProfileFocus } from '../../utils/routeGeometry'
import * as elevationProfileModel from './elevationProfileModel'
const samples = [
{ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 },
{ distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01 },
{ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03 },
]
function InteractiveProfile({ onFocus, samples: profileSamples = samples, externalFocus = null, height = 100 }: {
onFocus?: (focus: RouteProfileFocus | null) => void
samples?: typeof samples
externalFocus?: RouteProfileFocus | null
height?: number
}) {
const [focus, setFocus] = useState<RouteProfileFocus | null>(null)
return <ElevationProfile samples={profileSamples} height={height} color="currentColor" gradientId="interactive" ariaLabel="Elevation profile" focus={externalFocus ?? focus} onFocusChange={next => { setFocus(next); onFocus?.(next) }} formatFocus={point => `${point.distanceMeters} m · ${point.elevationMeters} m`} />
}
describe('ElevationProfile', () => {
it('positions samples by travelled distance rather than array index', () => {
const { container } = render(
<ElevationProfile
samples={[
{ distanceMeters: 0, elevationMeters: 100 },
{ distanceMeters: 100, elevationMeters: 150 },
{ distanceMeters: 1000, elevationMeters: 120 },
]}
color="currentColor"
gradientId="test-profile"
/>,
)
const path = container.querySelector('path[stroke]')?.getAttribute('d')
expect(path).toBe('M0.0,96.0 L44.0,4.0 L440.0,59.2')
})
it('renders nothing without at least two samples', () => {
const { container } = render(<ElevationProfile samples={[]} color="currentColor" gradientId="empty-profile" />)
expect(container).toBeEmptyDOMElement()
})
it('maps pointer position to distance and shows the corresponding focus readout', () => {
const onFocus = vi.fn()
const frames: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length })
render(<InteractiveProfile onFocus={onFocus} />)
const profile = screen.getByRole('group', { name: 'Elevation profile' })
vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({
x: 10, y: 0, left: 10, top: 0, right: 450, bottom: 100, width: 440, height: 100,
toJSON: () => ({}),
} as DOMRect)
fireEvent.pointerMove(profile, { clientX: 230, pointerType: 'mouse' })
act(() => frames.shift()?.(16))
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 150, elevationMeters: 187.5, lat: 48.015, lng: 11.015, sampleIndex: 1 })
expect(profile.querySelector('[data-profile-focus-line]')).toBeInTheDocument()
expect(profile.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
const readout = profile.parentElement?.querySelector('[data-profile-focus-readout]')
expect(readout?.textContent).toBe('150 m · 187.5 m')
expect(readout).toHaveClass('rounded-lg', 'border-edge-faint', 'bg-surface-card', 'text-content', 'shadow-elevated')
expect(readout).toHaveStyle({ fontFamily: 'var(--font-system)', fontWeight: '500', pointerEvents: 'none' })
expect(readout).toHaveStyle({ maxWidth: 'min(164px, calc(100% - 8px))' })
expect(profile).toHaveStyle({ fontFamily: 'var(--font-system)' })
expect(profile.querySelector('[data-profile-focus-line]')).toHaveAttribute('stroke', 'var(--text-muted)')
expect(profile.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
fireEvent.pointerMove(profile, { clientX: -50, pointerType: 'mouse' })
act(() => frames.shift()?.(32))
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
fireEvent.pointerMove(profile, { clientX: 900, pointerType: 'mouse' })
act(() => frames.shift()?.(48))
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 })
fireEvent.pointerLeave(profile)
expect(onFocus).toHaveBeenLastCalledWith(null)
vi.restoreAllMocks()
})
it('supports focus, arrow keys, Home, End and Escape without pointer input', () => {
const onFocus = vi.fn()
render(<InteractiveProfile onFocus={onFocus} />)
const profile = screen.getByRole('group', { name: 'Elevation profile' })
profile.focus()
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
fireEvent.keyDown(profile, { key: 'ArrowRight' })
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 })
fireEvent.keyDown(profile, { key: 'End' })
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 })
fireEvent.keyDown(profile, { key: 'Home' })
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
fireEvent.keyDown(profile, { key: 'Escape' })
expect(onFocus).toHaveBeenLastCalledWith(null)
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
})
it('is not keyboard-focusable or interactive without geographic sample coordinates', () => {
render(<ElevationProfile samples={[{ distanceMeters: 0, elevationMeters: 10 }, { distanceMeters: 100, elevationMeters: 20 }]} color="currentColor" gradientId="no-coordinates" ariaLabel="Elevation profile" onFocusChange={vi.fn()} />)
expect(screen.queryByRole('group', { name: 'Elevation profile' })).toBeNull()
expect(document.querySelector('svg[tabindex="0"]')).toBeNull()
})
it('reuses static chart geometry for pointer, keyboard and external focus updates', () => {
const prepared = vi.spyOn(elevationProfileModel, 'prepareElevationProfileChart')
const callbacks = new Map<number, FrameRequestCallback>()
let nextFrameId = 0
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
const id = ++nextFrameId
callbacks.set(id, callback)
return id
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { callbacks.delete(id) })
const onFocus = vi.fn()
const { rerender, unmount } = render(<InteractiveProfile onFocus={onFocus} />)
const profile = screen.getByRole('group', { name: 'Elevation profile' })
expect(prepared).toHaveBeenCalledTimes(1)
vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 440, bottom: 100, width: 440, height: 100,
toJSON: () => ({}),
} as DOMRect)
fireEvent.pointerMove(profile, { clientX: 90, pointerType: 'mouse' })
fireEvent.pointerMove(profile, { clientX: 180, pointerType: 'mouse' })
fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' })
expect(raf).toHaveBeenCalledTimes(1)
expect(prepared).toHaveBeenCalledTimes(1)
const pendingFrameId = [...callbacks.keys()][0]
fireEvent.pointerLeave(profile)
expect(cancel).toHaveBeenCalledWith(pendingFrameId)
expect(callbacks.size).toBe(0)
fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' })
expect(raf).toHaveBeenCalledTimes(2)
const [frameId, callback] = [...callbacks.entries()][0]
act(() => callback(16))
callbacks.delete(frameId)
expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({ sampleIndex: 1 }))
expect(onFocus).toHaveBeenCalledTimes(2)
expect(prepared).toHaveBeenCalledTimes(1)
fireEvent.keyDown(profile, { key: 'ArrowRight' })
fireEvent.keyDown(profile, { key: 'ArrowLeft' })
expect(prepared).toHaveBeenCalledTimes(1)
const externalFocus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }
rerender(<InteractiveProfile samples={samples} externalFocus={externalFocus} onFocus={onFocus} />)
expect(prepared).toHaveBeenCalledTimes(1)
rerender(<InteractiveProfile samples={samples} externalFocus={externalFocus} height={120} onFocus={onFocus} />)
expect(prepared).toHaveBeenCalledTimes(2)
unmount()
})
})
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { focusRouteProfileAtDistance, type DistanceIndexedProfileSample, type RouteProfileFocus } from '../../utils/routeGeometry'
import * as elevationProfileModel from './elevationProfileModel'
interface ElevationProfileProps {
samples: DistanceIndexedProfileSample[]
color: string
height?: number
gradientId: string
ariaLabel?: string
focus?: RouteProfileFocus | null
onFocusChange?: (focus: RouteProfileFocus | null) => void
formatFocus?: (focus: RouteProfileFocus) => string
}
/** A route profile whose horizontal axis represents travelled distance. */
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus }: ElevationProfileProps) {
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
const pointerFrameRef = useRef<number | null>(null)
const pendingPointerDistanceRef = useRef<number | null>(null)
const width = 440
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height), [samples, height])
const interactive = samples.length >= 2 && !!onFocusChange && chart.hasGeographicCoordinates
const focusAtDistance = (distanceMeters: number): RouteProfileFocus | null =>
focusRouteProfileAtDistance(samples, distanceMeters)
const cancelPendingPointerFocus = (): void => {
if (pointerFrameRef.current != null) window.cancelAnimationFrame(pointerFrameRef.current)
pointerFrameRef.current = null
pendingPointerDistanceRef.current = null
}
useEffect(() => () => cancelPendingPointerFocus(), [samples])
if (samples.length < 2) return null
const focusAtPointer = (clientX: number, element: SVGSVGElement): void => {
const bounds = element.getBoundingClientRect()
if (bounds.width <= 0) return
const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width))
pendingPointerDistanceRef.current = ratio * chart.maxDistance
if (pointerFrameRef.current != null) return
pointerFrameRef.current = window.requestAnimationFrame(() => {
pointerFrameRef.current = null
const distance = pendingPointerDistanceRef.current
pendingPointerDistanceRef.current = null
if (distance != null) onFocusChange?.(focusAtDistance(distance))
})
}
const focusText = focus
? formatFocus?.(focus) ?? (focus.elevationMeters == null
? `${Math.round(focus.distanceMeters)} m`
: `${Math.round(focus.distanceMeters)} m · ${Math.round(focus.elevationMeters)} m`)
: ''
const focusX = focus ? (focus.distanceMeters / chart.maxDistance) * width : null
const focusY = focus?.elevationMeters == null
? null
: height - ((focus.elevationMeters - chart.minElevation) / chart.elevationSpan) * (height - 8) - 4
const tooltipX = focusX == null ? 0 : Math.max(4, Math.min(width - 164, focusX + 8))
const tooltipAtRight = focusX != null && focusX > width * 0.62
const updateKeyboardFocus = (next: RouteProfileFocus | null): void => {
if (next == null) cancelPendingPointerFocus()
onFocusChange?.(next)
if (next) setKeyboardAnnouncement(formatFocus?.(next) ?? (next.elevationMeters == null
? `${Math.round(next.distanceMeters)} m`
: `${Math.round(next.distanceMeters)} m · ${Math.round(next.elevationMeters)} m`))
else setKeyboardAnnouncement('')
}
const clearPointerFocus = (): void => {
cancelPendingPointerFocus()
onFocusChange?.(null)
setKeyboardAnnouncement('')
}
const handleKeyDown = (event: React.KeyboardEvent<SVGSVGElement>): void => {
if (!interactive) return
if (event.key === 'Escape') {
event.preventDefault()
updateKeyboardFocus(null)
return
}
const currentIndex = focus?.sampleIndex ?? 0
let nextIndex: number | null = null
if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = samples.length - 1
else if (event.key === 'ArrowLeft') nextIndex = Math.max(0, currentIndex - 1)
else if (event.key === 'ArrowRight') nextIndex = Math.min(samples.length - 1, currentIndex + 1)
if (nextIndex == null) return
event.preventDefault()
updateKeyboardFocus(focusAtDistance(samples[nextIndex].distanceMeters))
}
return (
<div className="relative w-full">
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height={height}
preserveAspectRatio="none"
className="bg-surface-tertiary"
style={{ display: 'block', borderRadius: 6, touchAction: interactive ? 'pan-y' : undefined, fontFamily: 'var(--font-system)' }}
role={interactive ? 'group' : undefined}
aria-label={interactive ? ariaLabel : undefined}
aria-describedby={interactive ? `${gradientId}-keyboard-announcement` : undefined}
aria-keyshortcuts={interactive ? 'ArrowLeft ArrowRight Home End Escape' : undefined}
tabIndex={interactive ? 0 : undefined}
onFocus={interactive ? () => { if (!focus) updateKeyboardFocus(focusAtDistance(0)) } : undefined}
onBlur={interactive ? () => updateKeyboardFocus(null) : undefined}
onKeyDown={interactive ? handleKeyDown : undefined}
onPointerDown={interactive ? event => {
if (event.pointerType !== 'mouse') {
event.currentTarget.focus({ preventScroll: true })
focusAtPointer(event.clientX, event.currentTarget)
}
} : undefined}
onPointerMove={interactive ? event => focusAtPointer(event.clientX, event.currentTarget) : undefined}
onPointerLeave={interactive ? clearPointerFocus : undefined}
onPointerCancel={interactive ? clearPointerFocus : undefined}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
{focusX != null && (
<g pointerEvents="none" aria-hidden="true">
<line data-profile-focus-line="true" x1={focusX} x2={focusX} y1={4} y2={height - 4} stroke="var(--text-muted)" strokeOpacity="0.85" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
{focusY != null && <circle data-profile-focus-point="true" cx={focusX} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />}
</g>
)}
</svg>
{interactive && focusX != null && (
<div
data-profile-focus-readout="true"
className="absolute top-1 z-10 overflow-hidden rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-content shadow-elevated"
style={{
left: tooltipAtRight ? undefined : `${tooltipX / width * 100}%`,
right: tooltipAtRight ? 4 : undefined,
maxWidth: 'min(164px, calc(100% - 8px))',
pointerEvents: 'none',
fontFamily: 'var(--font-system)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 500,
lineHeight: 1.25,
overflowWrap: 'anywhere',
}}
>
{focusText}
</div>
)}
{interactive && <span id={`${gradientId}-keyboard-announcement`} className="sr-only" aria-live="polite" aria-atomic="true">{keyboardAnnouncement}</span>}
</div>
)
}
@@ -0,0 +1,55 @@
import type { DistanceIndexedProfileSample } from '../../utils/routeGeometry'
export interface ElevationProfileChartPoint {
x: number
y: number
}
export interface ElevationProfileChartModel {
minElevation: number
maxElevation: number
elevationSpan: number
maxDistance: number
hasGeographicCoordinates: boolean
points: ElevationProfileChartPoint[]
linePath: string
areaPath: string
}
export function prepareElevationProfileChart(
samples: readonly DistanceIndexedProfileSample[],
width: number,
height: number,
): ElevationProfileChartModel {
if (samples.length === 0) return {
minElevation: 0,
maxElevation: 0,
elevationSpan: 1,
maxDistance: 1,
hasGeographicCoordinates: false,
points: [],
linePath: '',
areaPath: '',
}
const elevations = samples.map(sample => sample.elevationMeters)
const minElevation = Math.min(...elevations)
const maxElevation = Math.max(...elevations)
const elevationSpan = Math.max(maxElevation - minElevation, 1)
const maxDistance = samples[samples.length - 1].distanceMeters || 1
const points = samples.map(sample => ({
x: (sample.distanceMeters / maxDistance) * width,
y: height - ((sample.elevationMeters - minElevation) / elevationSpan) * (height - 8) - 4,
}))
const linePath = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
return {
minElevation,
maxElevation,
elevationSpan,
maxDistance,
hasGeographicCoordinates: samples.every(sample => Number.isFinite(sample.lat) && Number.isFinite(sample.lng)),
points,
linePath,
areaPath: `${linePath} L${width},${height} L0,${height} Z`,
}
}
+6
View File
@@ -22,6 +22,12 @@ export const SATELLITE_TILE_ATTRIBUTION =
'Imagery &copy; <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics'
export const SATELLITE_TILE_MAXZOOM = 19
// Key-free hiking basemap used only by the Tours planner's per-view switcher.
export const OPENTOPOMAP_TILE_URL = 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'
export const OPENTOPOMAP_TILE_ATTRIBUTION =
'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
export const OPENTOPOMAP_TILE_MAXZOOM = 17
/**
* The basemap for a browser that will not give MapLibre a WebGL context (#2288).
*
+2
View File
@@ -12,6 +12,7 @@
*/
export const TRIP_TAB_IDS = [
'plan',
'tour-planner',
'roadtrip',
'transports',
'buchungen',
@@ -31,6 +32,7 @@ export type TripTabId = (typeof TRIP_TAB_IDS)[number]
*/
export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
plan: 'trip.tabs.plan',
'tour-planner': 'trip.tabs.tourPlanner',
roadtrip: 'roadtrip.title',
transports: 'trip.tabs.transports',
buchungen: 'trip.tabs.reservations',
+12 -3
View File
@@ -5,14 +5,16 @@ export interface UndoEntry {
undo: () => Promise<void> | void
/** The days the step acts on. Deleting one of them drops the step. */
dayIds?: number[]
/** Place-backed steps become invalid if one of their Tour places is permanently deleted. */
placeIds?: number[]
}
export function usePlannerHistory(maxEntries = 30) {
const historyRef = useRef<UndoEntry[]>([])
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
const pushUndo = (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => {
historyRef.current = [{ label, undo: undoFn, dayIds }, ...historyRef.current].slice(0, maxEntries)
const pushUndo = (label: string, undoFn: () => Promise<void> | void, dayIds?: number[], placeIds?: number[]) => {
historyRef.current = [{ label, undo: undoFn, dayIds, placeIds }, ...historyRef.current].slice(0, maxEntries)
forceUpdate()
}
@@ -45,8 +47,15 @@ export function usePlannerHistory(maxEntries = 30) {
forceUpdate()
}, [])
const forgetPlace = useCallback((placeId: number) => {
const kept = historyRef.current.filter(entry => !entry.placeIds?.includes(placeId))
if (kept.length === historyRef.current.length) return
historyRef.current = kept
forceUpdate()
}, [])
const canUndo = historyRef.current.length > 0
const lastActionLabel = historyRef.current[0]?.label ?? null
return { pushUndo, undo, forgetDay, canUndo, lastActionLabel }
return { pushUndo, undo, forgetDay, forgetPlace, canUndo, lastActionLabel }
}
@@ -0,0 +1,82 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildAssignment, buildDay, buildPlace } from '../../tests/helpers/factories'
import { resetAllStores } from '../../tests/helpers/store'
import { useAddonStore } from '../store/addonStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore'
import { useRouteCalculation } from './useRouteCalculation'
const mocks = vi.hoisted(() => ({ route: vi.fn(), mirrorServiceStops: true }))
vi.mock('../components/Map/RouteCalculator', async importActual => ({
...await importActual<typeof import('../components/Map/RouteCalculator')>(),
calculateRouteWithLegs: mocks.route,
}))
vi.mock('./useRoadtripSettings', () => ({
useRoadtripSettings: (select: (settings: { roadtrip_service_stops_in_days: boolean }) => unknown) =>
select({ roadtrip_service_stops_in_days: mocks.mirrorServiceStops }),
}))
function seed(enabled = true, excluded = false) {
const places = [
buildPlace({ id: 1, lat: 48.1, lng: 11.1 }),
buildPlace({ id: 2, lat: 48.2, lng: 11.2, route_geometry: '[[48.2,11.2],[48.3,11.3]]' }),
buildPlace({ id: 3, lat: 48.4, lng: 11.4 }),
]
const assignments = { '1': places.map((place, order) => buildAssignment({
day_id: 1, order_index: order, place,
...(order === 1 ? { tour_place_id: 2, tour_route_geometry: '[[49,12],[49.1,12.1]]', route_excluded: excluded } : {}),
})) }
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled }] })
useTripStore.setState({ days: [buildDay({ id: 1 })], assignments, places, reservations: [] })
return places
}
beforeEach(() => {
resetAllStores()
mocks.mirrorServiceStops = true
mocks.route.mockReset().mockImplementation(async (points: Array<{ lat: number; lng: number }>) => ({
coordinates: points.map(point => [point.lat, point.lng]), legs: [],
}))
useSettingsStore.setState(state => ({ settings: { ...state.settings, optimize_from_accommodation: false } }))
})
describe('B5 useRouteCalculation', () => {
it.each([
{ enabled: true, excluded: false, expected: [[48.1, 48.2], [48.3, 48.4]] },
{ enabled: true, excluded: true, expected: [[48.1, 48.4]] },
{ enabled: false, excluded: false, expected: [[48.1, 48.2, 48.4]] },
])('routes enabled=$enabled excluded=$excluded using the shared day topology', async ({ enabled, excluded, expected }) => {
seed(enabled, excluded)
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
await waitFor(() => expect(result.current.route?.map(run => run.map(point => point[0]))).toEqual(expected))
expect(mocks.route.mock.calls.map(call => call[0].map((point: { lat: number }) => point.lat))).toEqual(expected)
expect(useTripStore.getState().assignments['1']).toHaveLength(3)
})
it('filters hidden service stops before the Tour projection', async () => {
mocks.mirrorServiceStops = false
const places = seed()
const service = buildAssignment({ day_id: 1, order_index: 0.5,
place: buildPlace({ id: 4, lat: 48.15, lng: 11.15, stop_type: 'fuel' }) })
useTripStore.setState(state => ({ assignments: { '1': [...state.assignments['1'], service] }, places }))
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
await waitFor(() => expect(result.current.route).toHaveLength(2))
expect(mocks.route.mock.calls.map(call => call[0].map((point: { lat: number }) => point.lat)))
.toEqual([[48.1, 48.2], [48.3, 48.4]])
})
it('aborts an old calculation and prevents its late answer replacing current Place geometry', async () => {
const places = seed()
let release: (answer: { coordinates: number[][]; legs: never[] }) => void = () => {}
mocks.route.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
const oldSignal = mocks.route.mock.calls[0][1].signal as AbortSignal
act(() => useTripStore.setState({ places: places.map(place => place.id === 2
? { ...place, route_geometry: '[[48.2,11.2],[48.35,11.35]]' } : place) }))
await waitFor(() => expect(result.current.route?.[1]?.[0]).toEqual([48.35, 11.35]))
expect(oldSignal.aborted).toBe(true)
await act(async () => release({ coordinates: [[49, 12], [49.1, 12.1]], legs: [] }))
expect(result.current.route?.[1]?.[0]).toEqual([48.35, 11.35])
})
})
+7 -2
View File
@@ -3,6 +3,7 @@ import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
import { resolveLegMode } from '../components/Planner/legMode'
@@ -26,6 +27,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Charging stops / rest areas a plugin route places on the drawn line.
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const places = useTripStore(s => s.places)
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Recompute when the selected day's whole-day default mode changes (#1281) —
@@ -54,6 +57,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
reservations: state.reservations || [],
accommodations,
optimizeFromAccommodation,
toursEnabled,
places,
})
const day = allDays.find(d => d.id === dayId)
@@ -138,7 +143,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -162,7 +167,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+241
View File
@@ -0,0 +1,241 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { toursApi } from '../api/client'
import { useTourPlaceIds } from './useTourPlaceIds'
vi.mock('../api/client', () => ({
toursApi: { list: vi.fn() },
}))
const tour: TourListItem = {
place_id: 42,
name: 'Ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function tourFor(placeId: number, name: string): TourListItem {
return { ...tour, place_id: placeId, name }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(toursApi.list).mockResolvedValue({ tours: [tour] })
})
describe('useTourPlaceIds', () => {
it('loads one facet list for filtering and selected-tour lookup', async () => {
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
expect(result.current.tours).toEqual([tour])
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
expect(toursApi.list).toHaveBeenCalledWith(7)
})
it('hides facet identity while disabled without fetching or deleting its cached data', async () => {
const { result, rerender } = renderHook(({ enabled }) => useTourPlaceIds(7, enabled), { initialProps: { enabled: true } })
await waitFor(() => expect(result.current.tours).toEqual([tour]))
rerender({ enabled: false })
expect(result.current.tourDataReady).toBe(true)
expect(result.current.tours).toEqual([])
expect(result.current.tourPlaceIds.size).toBe(0)
expect(toursApi.list).toHaveBeenCalledTimes(1)
})
it('inserts a fresh save immediately and replaces it after an edit', async () => {
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tours).toEqual([tour]))
const saved = { ...tour, place_id: 43, name: 'Fresh tour' }
act(() => result.current.upsertTour(saved))
expect(result.current.tours).toEqual([saved, tour])
const edited = { ...saved, name: 'Fresh tour edited' }
act(() => result.current.upsertTour(edited))
expect(result.current.tours).toEqual([edited, tour])
})
it('shows only the new trip Tours after a successful trip switch', async () => {
const tripTwoTour = tourFor(84, 'Trip two tour')
vi.mocked(toursApi.list).mockImplementation(async tripId => ({
tours: [tripId === 7 ? tour : tripTwoTour],
}))
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
initialProps: { tripId: 7 },
})
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
rerender({ tripId: 8 })
expect(result.current.tours).toEqual([])
expect(result.current.tourPlaceIds.size).toBe(0)
expect(result.current.tourDataReady).toBe(false)
expect(result.current.toursLoading).toBe(true)
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
})
it('keeps previous-trip data hidden when the new trip refresh fails', async () => {
vi.mocked(toursApi.list).mockImplementation(async tripId => {
if (tripId === 8) throw new Error('Trip two unavailable')
return { tours: [tour] }
})
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
initialProps: { tripId: 7 },
})
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
rerender({ tripId: 8 })
expect(result.current.tours).toEqual([])
expect(result.current.tourDataReady).toBe(false)
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
expect(result.current.tours).toEqual([])
expect(result.current.tourPlaceIds.size).toBe(0)
expect(result.current.tourDataReady).toBe(false)
expect(result.current.toursLoading).toBe(false)
})
it('ignores a late response from the previous trip', async () => {
const tripOne = deferred<{ tours: TourListItem[] }>()
const tripTwoTour = tourFor(84, 'Trip two tour')
const tripTwo = deferred<{ tours: TourListItem[] }>()
vi.mocked(toursApi.list).mockImplementation(tripId => tripId === 7 ? tripOne.promise : tripTwo.promise)
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
initialProps: { tripId: 7 },
})
rerender({ tripId: 8 })
await act(async () => tripTwo.resolve({ tours: [tripTwoTour] }))
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
await act(async () => tripOne.resolve({ tours: [tour] }))
expect(result.current.tours).toEqual([tripTwoTour])
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
})
it('lets only the latest request commit after rapid trip changes', async () => {
const requests = new Map<string | number, ReturnType<typeof deferred<{ tours: TourListItem[] }>>>()
vi.mocked(toursApi.list).mockImplementation(tripId => {
const request = deferred<{ tours: TourListItem[] }>()
requests.set(tripId, request)
return request.promise
})
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
initialProps: { tripId: 1 },
})
rerender({ tripId: 2 })
rerender({ tripId: 3 })
const tripThreeTour = tourFor(93, 'Trip three tour')
await act(async () => requests.get(3)!.resolve({ tours: [tripThreeTour] }))
await waitFor(() => expect(result.current.tours).toEqual([tripThreeTour]))
await act(async () => requests.get(2)!.resolve({ tours: [tourFor(92, 'Trip two tour')] }))
await act(async () => requests.get(1)!.resolve({ tours: [tour] }))
expect(result.current.tours).toEqual([tripThreeTour])
expect(result.current.tourPlaceIds).toEqual(new Set([93]))
})
it('lets only the latest same-trip refresh commit after rapid assignment mutations', async () => {
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tours).toEqual([tour]))
const beforeMutation = deferred<{ tours: TourListItem[] }>()
const afterMutation = deferred<{ tours: TourListItem[] }>()
vi.mocked(toursApi.list).mockReset()
.mockReturnValueOnce(beforeMutation.promise)
.mockReturnValueOnce(afterMutation.promise)
let firstRefresh!: Promise<void>
let secondRefresh!: Promise<void>
act(() => {
firstRefresh = result.current.reloadTourPlaceIds()
secondRefresh = result.current.reloadTourPlaceIds()
})
const plannedTour = { ...tour, planned: true }
await act(async () => { afterMutation.resolve({ tours: [plannedTour] }); await secondRefresh })
await waitFor(() => expect(result.current.tours).toEqual([plannedTour]))
await act(async () => { beforeMutation.resolve({ tours: [tour] }); await firstRefresh })
expect(result.current.tours).toEqual([plannedTour])
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
expect(result.current.tourLoadError).toBe(false)
})
it('retains same-trip last-good data and exposes a failed refresh', async () => {
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
await act(async () => { await result.current.reloadTourPlaceIds() })
expect(result.current.tours).toEqual([tour])
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
expect(result.current.tourDataReady).toBe(true)
expect(result.current.tourLoadError).toBe(true)
expect(result.current.toursLoading).toBe(false)
})
it('does not let a pending request update state after unmount', async () => {
const request = deferred<{ tours: TourListItem[] }>()
vi.mocked(toursApi.list).mockReturnValue(request.promise)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { unmount } = renderHook(() => useTourPlaceIds(7, true))
unmount()
await act(async () => request.resolve({ tours: [tour] }))
expect(consoleError).not.toHaveBeenCalled()
})
it('distinguishes an empty successful list from a failed refresh', async () => {
vi.mocked(toursApi.list).mockResolvedValueOnce({ tours: [] })
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
expect(result.current.tours).toEqual([])
expect(result.current.tourLoadError).toBe(false)
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
await act(async () => { await result.current.reloadTourPlaceIds() })
expect(result.current.tours).toEqual([])
expect(result.current.tourDataReady).toBe(true)
expect(result.current.tourLoadError).toBe(true)
})
it('does not let an older refresh overwrite a successful Tour upsert', async () => {
const { result } = renderHook(() => useTourPlaceIds(7, true))
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
const refresh = deferred<{ tours: TourListItem[] }>()
vi.mocked(toursApi.list).mockReturnValueOnce(refresh.promise)
act(() => { void result.current.reloadTourPlaceIds() })
await waitFor(() => expect(result.current.toursLoading).toBe(true))
const savedTour = tourFor(43, 'Saved while refreshing')
act(() => result.current.upsertTour(savedTour))
await act(async () => refresh.resolve({ tours: [tour] }))
expect(result.current.tours).toEqual([savedTour, tour])
expect(result.current.tourLoadError).toBe(false)
})
})
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import type { TourListItem } from '@trek/shared'
import { toursApi } from '../api/client'
export interface ToursInvalidation {
placeIds?: number[]
removedPlaceIds?: number[]
}
/**
* Place ids that are tours (a `tours` facet row exists for them), used to keep
* them out of the Places pool while the Tours addon is on.
* The single discriminator stays the `tours`
* facet row \u2014 this hook just mirrors it into a client-side Set for the
* Places filter, it does not add anything to the `places` table.
*
* Only fetches while `enabled` is true: with the addon off there is nothing to
* exclude, and the Places pool must stay byte-for-byte identical to today.
*/
export function useTourPlaceIds(tripId: number, enabled: boolean) {
const [tourData, setTourData] = useState<{ tripId: number; tours: TourListItem[] } | null>(null)
const [requestState, setRequestState] = useState<{
tripId: number
loading: boolean
error: boolean
} | null>(null)
const [pendingPlaceIds, setPendingPlaceIds] = useState<{ tripId: number; ids: Set<number> } | null>(null)
const requestIdRef = useRef(0)
const mountedRef = useRef(false)
const activeRequestRef = useRef({ tripId, enabled })
activeRequestRef.current = { tripId, enabled }
const reload = useCallback(async () => {
if (!enabled || !mountedRef.current
|| activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
const requestId = ++requestIdRef.current
setRequestState({ tripId, loading: true, error: false })
try {
const response = await toursApi.list(tripId)
if (!mountedRef.current || requestId !== requestIdRef.current) return
setTourData({ tripId, tours: response.tours })
setPendingPlaceIds(current => current?.tripId === tripId ? null : current)
setRequestState({ tripId, loading: false, error: false })
} catch {
if (!mountedRef.current || requestId !== requestIdRef.current) return
// Data is keyed by trip, so this retains only a same-trip last-good list.
setRequestState({ tripId, loading: false, error: true })
}
}, [tripId, enabled])
const invalidateTourPlaceIds = useCallback((change?: ToursInvalidation) => {
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
const added = change?.placeIds ?? []
const removed = change?.removedPlaceIds ?? []
if (added.length || removed.length) {
setPendingPlaceIds(current => {
const ids = new Set(current?.tripId === tripId ? current.ids : [])
for (const id of removed) ids.delete(id)
for (const id of added) ids.add(id)
return ids.size ? { tripId, ids } : null
})
}
void reload()
}, [enabled, tripId, reload])
useEffect(() => {
mountedRef.current = true
void reload()
return () => {
mountedRef.current = false
requestIdRef.current += 1
}
}, [reload])
const upsertTour = useCallback((tour: TourListItem) => {
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
requestIdRef.current += 1
setTourData(current => ({
tripId,
tours: [tour, ...(current?.tripId === tripId ? current.tours : [])
.filter(item => item.place_id !== tour.place_id)],
}))
setRequestState({ tripId, loading: false, error: false })
}, [enabled, tripId])
const tourDataReady = !enabled || tourData?.tripId === tripId
const visibleTours = enabled && tourData?.tripId === tripId ? tourData.tours : []
const pendingIds = enabled && pendingPlaceIds?.tripId === tripId ? pendingPlaceIds.ids : []
const currentRequest = requestState?.tripId === tripId ? requestState : null
return {
tours: visibleTours,
toursLoading: enabled && (currentRequest?.loading ?? false),
tourLoadError: enabled && (currentRequest?.error ?? false),
tourDataReady,
tourPlaceIds: new Set([...visibleTours.map(tour => tour.place_id), ...pendingIds]),
reloadTourPlaceIds: reload,
invalidateTourPlaceIds,
upsertTour,
}
}
+209
View File
@@ -0,0 +1,209 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
const mocks = vi.hoisted(() => {
const listeners = new Set<(event: Record<string, unknown>) => void>()
const toursApi = { list: vi.fn() }
const handleRemoteEvent = vi.fn()
const loadFiles = vi.fn()
const useTripStore = Object.assign(vi.fn(() => ({ loadFiles })), {
getState: () => ({ handleRemoteEvent }),
})
return {
listeners,
toursApi,
handleRemoteEvent,
loadFiles,
useTripStore,
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.add(listener)),
removeListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.delete(listener)),
}
})
vi.mock('../api/client', () => ({ toursApi: mocks.toursApi }))
vi.mock('../api/websocket', () => ({
joinTrip: mocks.joinTrip,
leaveTrip: mocks.leaveTrip,
addListener: mocks.addListener,
removeListener: mocks.removeListener,
}))
vi.mock('../store/tripStore', () => ({ useTripStore: mocks.useTripStore }))
import { useTourPlaceIds } from './useTourPlaceIds'
import { useTripWebSocket } from './useTripWebSocket'
const baseTour: TourListItem = {
place_id: 42,
name: 'Ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
function emit(event: Record<string, unknown>): void {
for (const listener of [...mocks.listeners]) listener(event)
}
function renderTrip(tripId: number, enabled = true) {
return renderHook(() => {
const tours = useTourPlaceIds(tripId, enabled)
useTripWebSocket(tripId, tours.invalidateTourPlaceIds)
return tours
})
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
beforeEach(() => {
mocks.listeners.clear()
mocks.addListener.mockClear()
mocks.removeListener.mockClear()
mocks.joinTrip.mockClear()
mocks.leaveTrip.mockClear()
mocks.handleRemoteEvent.mockClear()
mocks.loadFiles.mockClear()
mocks.toursApi.list.mockReset().mockResolvedValue({ tours: [baseTour] })
})
describe('Tours websocket invalidation', () => {
it('refreshes the active trip read model and Places exclusion after tours:changed', async () => {
const nextTour = { ...baseTour, place_id: 84, name: 'Trip 7 new tour' }
const refresh = deferred<{ tours: TourListItem[] }>()
mocks.toursApi.list.mockResolvedValueOnce({ tours: [baseTour] }).mockReturnValueOnce(refresh.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
await act(async () => refresh.resolve({ tours: [nextTour] }))
await waitFor(() => expect(result.current.tourPlaceIds).toEqual(new Set([84])))
expect(result.current.tours).toEqual([nextTour])
expect(mocks.toursApi.list).toHaveBeenCalledTimes(2)
})
it('ignores events for another trip and events without an explicit trip id', async () => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => {
emit({ type: 'tours:changed', tripId: 8, placeIds: [84] })
emit({ type: 'tours:changed', placeIds: [84] })
})
expect(mocks.toursApi.list).toHaveBeenCalledOnce()
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
})
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type, tripId: 7, placeId: 42 }))
await waitFor(() => expect(mocks.toursApi.list).toHaveBeenCalledTimes(2))
},
)
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type, tripId: 7 }))
await waitFor(() => expect(mocks.toursApi.list).toHaveBeenCalledTimes(2))
},
)
it('lets the latest rapid invalidation refresh win without leaking prior results', async () => {
const first = deferred<{ tours: TourListItem[] }>()
const second = deferred<{ tours: TourListItem[] }>()
const newest = { ...baseTour, place_id: 99, name: 'Newest' }
mocks.toursApi.list.mockResolvedValueOnce({ tours: [baseTour] })
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => {
emit({ type: 'tours:changed', tripId: 7, placeIds: [98] })
emit({ type: 'tours:changed', tripId: 7, placeIds: [99] })
})
expect(result.current.tourPlaceIds).toEqual(new Set([42, 98, 99]))
await act(async () => second.resolve({ tours: [newest] }))
await waitFor(() => expect(result.current.tours).toEqual([newest]))
await act(async () => first.resolve({ tours: [baseTour] }))
expect(result.current.tours).toEqual([newest])
expect(result.current.tourPlaceIds).toEqual(new Set([99]))
expect(mocks.toursApi.list).toHaveBeenCalledTimes(3)
})
it('removes a pending Tour exclusion when its Place is deleted before refresh completes', async () => {
const staleRefresh = deferred<{ tours: TourListItem[] }>()
const deleteRefresh = deferred<{ tours: TourListItem[] }>()
mocks.toursApi.list.mockResolvedValueOnce({ tours: [] })
.mockReturnValueOnce(staleRefresh.promise)
.mockReturnValueOnce(deleteRefresh.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
act(() => emit({ type: 'place:deleted', tripId: 7, placeId: 84 }))
expect(result.current.tourPlaceIds).toEqual(new Set())
await act(async () => deleteRefresh.resolve({ tours: [] }))
await act(async () => staleRefresh.resolve({ tours: [{ ...baseTour, place_id: 84 }] }))
expect(result.current.tourPlaceIds).toEqual(new Set())
expect(mocks.toursApi.list).toHaveBeenCalledTimes(3)
})
it('keeps same-trip last-good Tours and exposes error on refresh failure', async () => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
mocks.toursApi.list.mockRejectedValueOnce(new Error('refresh unavailable'))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
expect(result.current.tours).toEqual([baseTour])
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
})
it('leaves ordinary Place event handling intact and Tours-off refresh is a no-op', async () => {
const { result } = renderTrip(7, false)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type: 'place:created', tripId: 7, place: { id: 93, name: 'Ordinary place' } }))
act(() => emit({ type: 'tours:changed', tripId: 7 }))
expect(mocks.handleRemoteEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'place:created', tripId: 7 }))
expect(mocks.toursApi.list).not.toHaveBeenCalled()
expect(result.current.tours).toEqual([])
expect(result.current.tourPlaceIds.size).toBe(0)
})
})
+20 -2
View File
@@ -2,8 +2,9 @@ import { useEffect } from 'react'
import { useTripStore } from '../store/tripStore'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import type { WebSocketEvent } from '../types'
import type { ToursInvalidation } from './useTourPlaceIds'
export function useTripWebSocket(tripId: number | string | undefined) {
export function useTripWebSocket(tripId: number | string | undefined, onToursChanged?: (change?: ToursInvalidation) => void) {
const tripStore = useTripStore()
useEffect(() => {
@@ -17,13 +18,30 @@ export function useTripWebSocket(tripId: number | string | undefined) {
}
}
addListener(collabFileSync)
const toursInvalidation = (event: WebSocketEvent) => {
if (String(event?.tripId ?? '') !== String(tripId)) return
if (event?.type === 'tours:changed') {
const placeIds = Array.isArray(event.placeIds)
? event.placeIds.map(Number).filter(Number.isSafeInteger)
: []
onToursChanged?.(placeIds.length ? { placeIds } : undefined)
} else if (event?.type === 'place:deleted') {
const placeId = Number(event.placeId)
onToursChanged?.(Number.isSafeInteger(placeId) ? { removedPlaceIds: [placeId] } : undefined)
} else if (event?.type === 'assignment:created' || event?.type === 'assignment:deleted'
|| event?.type === 'day:deleted') {
onToursChanged?.()
}
}
addListener(toursInvalidation)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
removeListener(toursInvalidation)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
}, [tripId, onToursChanged])
}
@@ -234,7 +234,7 @@ export default function MSettingsGeneral() {
title={t('settings.startTripTab')}
value={startTripTab}
onSelect={(v) => save('start_trip_tab', v)}
options={TRIP_TAB_IDS.map((id) => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
options={TRIP_TAB_IDS.filter(id => id !== 'tour-planner').map((id) => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
/>
</>
)
@@ -286,6 +286,10 @@ export default function MTripShell({
const trTab = planner.activeTab
useEffect(() => {
if (trTab === 'tour-planner') planner.handleTabChange('plan')
}, [trTab, planner.handleTabChange])
const setTrTab = (tabId: string) => {
planner.handleTabChange(tabId)
if (mode === 'browse') setMode('go')
@@ -404,9 +408,10 @@ export default function MTripShell({
}
if (!trip) return null
const enabledTabIds = new Set(planner.TRIP_TABS.map(tab => tab.id))
const mobileTabs = planner.TRIP_TABS.filter(tab => !tab.desktopOnly)
const enabledTabIds = new Set(mobileTabs.map(tab => tab.id))
const dockTabs = pickDockTabs(enabledTabIds)
const tabLabel = (id: string) => planner.TRIP_TABS.find(tab => tab.id === id)?.label ?? id
const tabLabel = (id: string) => mobileTabs.find(tab => tab.id === id)?.label ?? id
// While the trip runs, today's chip carries a ring and a button jumps back to it
// from wherever the rail was scrolled (#2392). Null outside the trip's dates.
@@ -21,7 +21,10 @@ import { useTranslation } from '../../../../i18n'
import type { Place } from '../../../../types'
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
import MToursSelectionList from './MToursSelectionList'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds'
/**
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
@@ -35,11 +38,19 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro
* it. The header ellipsis opens the 'import' sheet (sheets/MImportSheet).
*/
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
const { t, places, categories, assignments, days, trip } = planner
const { t, places, categories, assignments, days, trip, tripId } = planner
// The planner hook carries `t` but not the locale; day labels need both.
const { locale } = useTranslation()
const canEditPlaces = planner.can('place_edit', trip)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
// Places that are tours (a `tours` facet row exists) — kept out of the
// Places pool entirely while the addon is on.
const { tourPlaceIds } = useTourPlaceIds(tripId, toursEnabled)
// Top level of the places browser, mirroring the desktop right add-panel's
// Places <-> Tours switch placement.
const [toursMode, setToursMode] = useState(false)
const [toursFilter, setToursFilter] = useState<'all' | 'unplanned' | 'planned'>('all')
const filter = useTripStore(s => s.placesFilter)
const setFilter = useTripStore(s => s.setPlacesFilter)
@@ -61,7 +72,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
if (shell.browseFromEdit) setFilter('unplanned')
}, [shell.browseFromEdit, setFilter])
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
useEffect(() => {
if (filter === 'tracks' && !hasTracks) setFilter('all')
}, [filter, hasTracks, setFilter])
@@ -73,10 +84,10 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
[assignments, planner.tripAccommodations, planner.reservations],
)
const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days])
const filtered = useMemo(
() => filterPool(places, { filter, categoryFilters, search, plannedIds }),
[places, filter, categoryFilters, search, plannedIds],
)
const filtered = useMemo(() => {
const pool = toursEnabled ? places.filter(p => !tourPlaceIds.has(p.id)) : places
return filterPool(pool, { filter, categoryFilters, search, plannedIds })
}, [places, filter, categoryFilters, search, plannedIds, toursEnabled, tourPlaceIds])
// A bulk delete (or a remote edit) can remove selected places — drop the
// stale ids so the toolbar count stays honest.
@@ -120,10 +131,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
// Compare the ids, not just the counts: a place removed remotely while another
// one is selected keeps the sizes equal without the sets matching.
const allSelected = filtered.length > 0 && filtered.every(p => selectedIds.has(p.id))
const selectablePlaces = filtered.filter(place => !planner.isTourPlace(place.id))
const allSelected = selectablePlaces.length > 0 && selectablePlaces.every(p => selectedIds.has(p.id))
const toggleAllVisible = () => {
if (allSelected) setSelectedIds(new Set())
else setSelectedIds(new Set(filtered.map(p => p.id)))
else setSelectedIds(new Set(selectablePlaces.map(p => p.id)))
}
const toggleCategory = (catId: string) => {
@@ -142,17 +154,39 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
const openRow = (place: Place) => {
if (selectMode) {
if (planner.isTourPlace(place.id)) {
planner.handlePlaceClick(place.id)
return
}
toggleSelected(place.id)
return
}
shell.openSheet('bract', { placeId: place.id, dayPicker: false })
}
const selectedTours = [...selectedIds].filter(planner.isTourPlace)
const hasUncategorized = places.some(p => p.category_id == null)
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
{toursEnabled && (
<MPlacesToursModeSwitch active={toursMode} onChange={setToursMode} />
)}
{toursEnabled && toursMode ? (
<>
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
buttons: tours are attached here, never created or bulk-managed. */}
<div className="flex items-center gap-[6px]">
<FilterChip active={toursFilter === 'all'} label={t('places.all')} onClick={() => setToursFilter('all')} />
<FilterChip active={toursFilter === 'unplanned'} label={t('places.unplanned')} onClick={() => setToursFilter('unplanned')} />
<FilterChip active={toursFilter === 'planned'} label={t('places.planned')} onClick={() => setToursFilter('planned')} />
</div>
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
</>
) : (
<>
{/* ── Filter chips + Import (Add sits in the search row below to free space) ── */}
<div className="flex items-center gap-[6px]">
<FilterChip
@@ -266,24 +300,24 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
<BulkBtn label={allSelected ? t('common.deselectAll') : t('common.selectAll')} onClick={toggleAllVisible}>
<CheckCheck size={14} strokeWidth={2} />
</BulkBtn>
<BulkBtn label={t('places.changeCategory')} disabled={selectedIds.size === 0} onClick={() => setCategoryPickerOpen(true)}>
<BulkBtn label={t('places.changeCategory')} disabled={selectedIds.size === 0 || selectedTours.length > 0} onClick={() => setCategoryPickerOpen(true)}>
<Tag size={14} strokeWidth={2} />
</BulkBtn>
{collectionsEnabled && (
<BulkBtn label={t('inspector.saveToCollection')} disabled={selectedIds.size === 0} onClick={() => setSaveToListOpen(true)}>
<BulkBtn label={t('inspector.saveToCollection')} disabled={selectedIds.size === 0 || selectedTours.length > 0} onClick={() => setSaveToListOpen(true)}>
<Bookmark size={14} strokeWidth={2} />
</BulkBtn>
)}
{collectionsEnabled && (
<BulkBtn
label={t('collections.markVisitedSelection')}
disabled={selectedIds.size === 0 || markVisitedBusy}
disabled={selectedIds.size === 0 || selectedTours.length > 0 || markVisitedBusy}
onClick={markSelectionVisited}
>
{markVisitedBusy ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} strokeWidth={2} />}
</BulkBtn>
)}
<BulkBtn label={t('places.deleteSelected')} disabled={selectedIds.size === 0} onClick={() => setConfirmDeleteOpen(true)}>
<BulkBtn label={t('places.deleteSelected')} disabled={selectedIds.size === 0 || selectedTours.length > 0} onClick={() => setConfirmDeleteOpen(true)}>
<Trash2 size={14} strokeWidth={2} />
</BulkBtn>
</div>
@@ -349,7 +383,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
return (
<div key={place.id} className="flex items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[2px] py-[9px]">
<button type="button" onClick={() => openRow(place)} className="flex min-w-0 flex-1 items-center gap-[11px] text-left">
{selectMode && <SquareCheck big checked={selectedIds.has(place.id)} />}
{selectMode && !planner.isTourPlace(place.id) && <SquareCheck big checked={selectedIds.has(place.id)} />}
<PlaceAvatar place={place} category={cat} size={40} />
<div className="min-w-0 flex-1">
<span className="flex items-center gap-[6px]">
@@ -389,6 +423,8 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
)
})
)}
</>
)}
</div>
<MPlacesBulkCategorySheet
@@ -418,8 +454,12 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
title={t('places.deleteSelected')}
message={t('trip.confirm.deletePlaces', { count: selectedIds.size })}
confirmLabel={t('common.delete')}
message={selectedTours.length > 0
? t('tours.delete.bulkConfirmBody')
: t('trip.confirm.deletePlaces', { count: selectedIds.size })}
confirmLabel={selectedTours.length > 0
? t('tours.delete.confirmAction')
: t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={async () => {
@@ -0,0 +1,43 @@
import { MapPin, Mountain } from 'lucide-react'
import { useTranslation } from '../../../../i18n/TranslationContext'
interface MPlacesToursModeSwitchProps {
active: boolean
onChange: (tours: boolean) => void
}
/** Mobile counterpart of PlacesToursModeSwitch: top-level "Places <-> Tours"
* switch of the places browser, with m-token styling to match the browser chrome. */
export default function MPlacesToursModeSwitch({ active, onChange }: MPlacesToursModeSwitchProps) {
const { t } = useTranslation()
const options: [boolean, string, typeof MapPin][] = [
[false, t('tours.mode.places'), MapPin],
[true, t('tours.mode.tours'), Mountain],
]
return (
<div
role="tablist"
aria-label={t('tours.mode.label')}
className="mb-3 flex gap-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-1"
>
{options.map(([value, label, Icon]) => {
const selected = active === value
return (
<button
key={label}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(value)}
className={`flex h-[34px] flex-1 items-center justify-center gap-1.5 rounded-[10px] px-2 text-[0.78125rem] transition-colors ${
selected ? 'bg-m-card font-semibold text-m-ink shadow-sm' : 'font-medium text-m-muted'
}`}
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,137 @@
import { render, screen } from '../../../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import en from '@trek/shared/i18n/en'
import de from '@trek/shared/i18n/de'
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
import { useSettingsStore } from '../../../../store/settingsStore'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import MToursSelectionList from './MToursSelectionList'
const tour: TourListItem = {
place_id: 42,
name: 'Camping tour with a deliberately long readable name',
tour_type: 'hike',
distance: 12.5,
elevation_gain: 420,
elevation_loss: 390,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
has_waypoints: true,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
const reloadTourPlaceIds = vi.fn().mockResolvedValue(undefined)
const handlePlaceClick = vi.fn()
const openSheet = vi.fn()
function planner(): TripPlanner {
return {
t: (key: string) => {
const value = en[key]
return typeof value === 'string' ? value : key
},
tripId: 7,
tours: [tour],
selectedPlaceId: null,
selectedPlace: null,
selectedTour: null,
selectedDayId: null,
selectedAssignmentId: null,
assignments: {},
days: [],
files: [],
canUploadFiles: false,
reloadTourPlaceIds,
handlePlaceClick,
} as unknown as TripPlanner
}
function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi {
return {
sheet,
openSheet,
} as unknown as MTripShellApi
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
describe('MToursSelectionList', () => {
it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => {
const germanPlanner = { ...planner(), t: (key: string) => {
const value = de[key] ?? en[key]
return typeof value === 'string' ? value : key
} }
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />)
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
})
it('gives a tour name the full row and up to two lines', () => {
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
const name = screen.getByText(tour.name)
expect(name).toHaveClass('line-clamp-2', 'break-words')
expect(name).toHaveAttribute('title', tour.name)
expect(name).not.toHaveClass('truncate')
})
it('keeps metrics atomic and gives the mobile day action a separate full-width row', () => {
const { container } = render(<MToursSelectionList planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} filter="all" canAssign />)
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
const metrics = screen.getByTestId('mobile-tour-metrics')
expect(metrics).toHaveTextContent('12.5 km')
expect(metrics).toHaveTextContent('420 m')
expect(metrics).toHaveTextContent('390 m')
for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap')
expect(metrics.children[0].getAttribute('aria-label')).toContain('12.5 km')
expect(metrics.children[1].getAttribute('aria-label')).toContain('420 m')
expect(metrics.children[2].getAttribute('aria-label')).toContain('390 m')
expect(screen.getByText('TREK')).toBeInTheDocument()
expect(screen.getByTitle(en['tours.planner.difficulty.t2'] as string)).toHaveAttribute('aria-label', en['tours.planner.difficulty.t2'])
const add = screen.getByRole('button', { name: 'Add to day' })
expect(add).toHaveClass('w-full')
add.click()
expect(openSheet).toHaveBeenCalledWith('bract', { placeId: tour.place_id, dayPicker: true })
expect(container.querySelector('[data-testid="mobile-tour-metrics"]')).toBeTruthy()
})
it('keeps a long German name and source/difficulty labels intact', () => {
const germanPlanner = {
...planner(),
tours: [{ ...tour, name: 'Eine sehr lange deutsche Wanderung durch das abgelegene Gebirge', has_waypoints: false }],
t: (key: string) => {
const value = de[key] ?? en[key]
return typeof value === 'string' ? value : key
},
}
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toHaveClass('w-full')
})
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
const { rerender } = render(<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />)
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,121 @@
import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../../utils/units'
import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
interface MToursSelectionListProps extends TourPermissionProps {
planner: TripPlanner
shell: MTripShellApi
/** All / Unplanned / Planned — same three states as the Places pool. */
filter: 'all' | 'unplanned' | 'planned'
}
/**
* Tours mode of the mobile places browser: a selection list that mirrors
* the desktop ToursSidebar's data, with viewing and day assignment only.
* This list does not provide GPX import.
*
* "Add to day" reuses the existing 'bract' place-actions sheet's day picker
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
* Place under the hood, so no new linking code is needed here either.
*/
export default function MToursSelectionList({ planner, shell, filter, canEdit, canAssign: assignPermission }: MToursSelectionListProps) {
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canEdit, canAssign: assignPermission })
const { t } = planner
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const tours: TourListItem[] = planner.tours
const filtered = tours.filter(tr => {
if (filter === 'unplanned') return !tr.planned
if (filter === 'planned') return tr.planned
return true
})
if (filtered.length === 0) {
return (
<>
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
{t('tours.planner.mobileHint')}
</p>
<div className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
<Mountain className="mx-auto mb-2 h-6 w-6 text-m-faint" strokeWidth={1.6} />
<p className="font-semibold text-m-ink">{t('tours.empty.title')}</p>
<p className="mt-1 font-geist text-[0.78125rem] text-m-muted">{t('tours.empty.body')}</p>
</div>
</>
)
}
return (
<>
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
{t('tours.planner.mobileHint')}
</p>
<ul className="mt-3 space-y-2">
{filtered.map(tour => {
const isSelected = planner.selectedPlaceId === tour.place_id
return (
<li
key={tour.place_id}
role="option"
tabIndex={0}
aria-selected={isSelected}
onClick={() => planner.handlePlaceClick(tour.place_id)}
onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); planner.handlePlaceClick(tour.place_id) } }}
className="rounded-2xl border border-[color:var(--m-rowbr)] p-3"
style={{ background: isSelected ? 'var(--border-faint)' : 'var(--m-ic)' }}
>
<div className="min-w-0">
<span className="block min-w-0 line-clamp-2 break-words font-semibold text-m-ink" title={tour.name}>{tour.name}</span>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-geist text-[0.75rem] text-m-muted">
<span className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-medium text-m-faint">
{hikeSourceBadgeLabel(tour, t)}
</span>
<span
title={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
aria-label={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-semibold text-m-muted"
>T{tour.max_hiking_difficulty}</span>
{tour.caution && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-[color:var(--warning)] px-1.5 py-0.5 text-[10px] text-[color:var(--warning)]" aria-label={t('tours.caution.badge')} title={t('tours.caution.tooltip')}>
<AlertTriangle className="h-3 w-3" strokeWidth={2} aria-hidden="true" />{t('tours.caution.badge')}
</span>
)}
</div>
<div data-testid="mobile-tour-metrics" className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 font-geist text-[0.75rem] text-m-muted">
<span className="whitespace-nowrap" aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, distanceUnit)}`}>
{formatDistance(tour.distance ?? 0, distanceUnit)}
</span>
{tour.elevation_gain != null && (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, distanceUnit)}`}>
<ArrowUp className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
{formatElevation(tour.elevation_gain, distanceUnit)}
</span>
)}
{tour.elevation_loss != null && (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, distanceUnit)}`}>
<ArrowDown className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
{formatElevation(tour.elevation_loss, distanceUnit)}
</span>
)}
</div>
<button
type="button"
disabled={!canAssign}
onClick={(e) => { e.stopPropagation(); if (canAssign) shell.openSheet('bract', { placeId: tour.place_id, dayPicker: true }) }}
className="mt-2 inline-flex min-h-9 w-full items-center justify-center gap-1 rounded-lg border border-[color:var(--m-rowbr)] px-3 py-1.5 text-[0.8125rem] font-medium text-m-ink"
>
<CalendarDays className="h-3.5 w-3.5" strokeWidth={2} />
{t('tours.addToDay')}
</button>
</div>
</li>
)
})}
</ul>
</>
)
}
@@ -163,6 +163,11 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
className="absolute left-4 right-4 overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }}
>
{planner.toursEnabled && tl.rows.some(row => row.kind === 'place' && row.invalidTour) && (
<div role="status" className="border-b border-[color:var(--warning)] p-2 text-sm text-[color:var(--warning)]">
{t('tours.dayRoute.endpointUnknown')}
</div>
)}
{day && (
<TimelineHeader
tl={tl}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { buildPlanRows } from './planTimelineModel'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { Reservation } from '../../../../types'
import type { Reservation, RouteSegment } from '../../../../types'
// FE-MOBILE-PLANROWS-001 to FE-MOBILE-PLANROWS-003
@@ -47,4 +47,22 @@ describe('buildPlanRows', () => {
if (place.kind !== 'place') throw new Error('expected a place row')
expect(place.linkedReservations).toEqual([])
})
it('shows the incoming and outgoing connectors at opposite Tour endpoints', () => {
const start: [number, number] = [48.103, 11.103]
const end: [number, number] = [48.12, 11.12]
const before: [number, number] = [48.1, 11.1]
const after: [number, number] = [48.13, 11.13]
const merged = [
{ type: 'place', sortKey: 0, data: { id: 11, place: { id: 1, lat: before[0], lng: before[1] } } },
{ type: 'place', sortKey: 1, data: { id: 12, tour_place_id: 2,
tour_route_geometry: JSON.stringify([start, end]), place: { id: 2, lat: start[0], lng: start[1] } } },
{ type: 'place', sortKey: 2, data: { id: 13, place: { id: 3, lat: after[0], lng: after[1] } } },
] as unknown as MergedItem[]
const segment = (from: [number, number], to: [number, number]) => ({ from, to }) as RouteSegment
const segments = [segment(before, start), segment(end, after)]
const rows = buildPlanRows({ merged, reservations: [], routeSegments: segments, dayId: 5, toursEnabled: true })
expect(rows.filter(row => row.kind === 'conn').map(row => row.seg)).toEqual(segments)
expect(buildPlanRows({ merged, reservations: [], routeSegments: segments, dayId: 5 }).some(row => row.kind === 'conn' && row.seg.from[0] === end[0])).toBe(false)
})
})
@@ -7,7 +7,8 @@ import {
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
import type { Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn } from '../../../../types'
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
/**
* Pure derivations for the mobile plan timeline: merged-item → row mapping,
@@ -27,7 +28,7 @@ export interface TransitMeta {
}
export type PlanRow =
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedReservations: Reservation[] }
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedReservations: Reservation[]; invalidTour?: boolean }
| { key: string; kind: 'transport'; item: MergedItem; res: TransportEntry }
| { key: string; kind: 'transit'; item: MergedItem; res: TransportEntry; transit: TransitMeta }
| { key: string; kind: 'note'; item: MergedItem; note: DayNote }
@@ -87,9 +88,16 @@ export function buildPlanRows(opts: {
reservations: Reservation[]
routeSegments: RouteSegment[]
dayId: number
toursEnabled?: boolean
places?: Place[]
}): PlanRow[] {
const { merged, reservations, routeSegments, dayId } = opts
const pool = routeSegments.filter(s => !s.hotelBookend)
const projected = new Map(projectDayItinerary(
merged.flatMap(item => item.type === 'place' ? [item.data as Assignment] : []),
opts.toursEnabled ?? false,
opts.places,
).map(item => [item.assignment.id, item]))
const takeSegment = (from: [number, number], to: [number, number]): RouteSegment | null => {
const idx = pool.findIndex(s => sameCoord(s.from, from) && sameCoord(s.to, to))
return idx >= 0 ? pool.splice(idx, 1)[0] : null
@@ -99,6 +107,7 @@ export function buildPlanRows(opts: {
for (const item of merged) {
if (item.type === 'place') {
const assignment = item.data as Assignment
const itineraryItem = projected.get(assignment.id)
base.push({
key: `pl-${assignment.id}`,
kind: 'place',
@@ -108,6 +117,7 @@ export function buildPlanRows(opts: {
// attraction, and getTransportForDay keeps every linked booking out of the
// timeline, so anything dropped here is gone from the plan tab (#2201).
linkedReservations: getAssignmentReservations(reservations, assignment.id),
invalidTour: itineraryItem?.kind === 'tour' && !itineraryItem.valid,
})
} else if (item.type === 'note') {
const note = item.data as DayNote
@@ -156,9 +166,17 @@ export function buildPlanRows(opts: {
const row = base[i]
if (row.kind === 'place') {
const place = row.assignment.place
if (place?.lat == null || place?.lng == null) continue
// Out of the route (#2532): the drive passes it by, so no leg starts or ends here.
if (row.assignment.route_excluded) continue
const itineraryItem = projected.get(row.assignment.id)
if (itineraryItem?.kind === 'tour') {
if (itineraryItem.start) connect(prev, [itineraryItem.start.lat, itineraryItem.start.lng])
prev = itineraryItem.end
? { at: [itineraryItem.end.lat, itineraryItem.end.lng], row: i, assignmentId: row.assignment.id }
: null
continue
}
if (place?.lat == null || place?.lng == null) continue
const at: [number, number] = [place.lat, place.lng]
connect(prev, at)
// The leg's mode is stored on its ORIGIN place assignment (#1281), so carry
@@ -82,8 +82,8 @@ export function useMPlanTimeline(planner: TripPlanner) {
const rows = useMemo<PlanRow[]>(() => {
if (!day) return []
return buildPlanRows({ merged, reservations, routeSegments: connSegments, dayId: day.id })
}, [day, merged, reservations, connSegments])
return buildPlanRows({ merged, reservations, routeSegments: connSegments, dayId: day.id, toursEnabled: planner.toursEnabled, places: planner.places })
}, [day, merged, reservations, connSegments, planner.toursEnabled, planner.places])
// Accommodation bookend legs (hotel → first stop, last stop → hotel). The
// segments already sit in connSegments; hotelLegsForDay picks the two out.
@@ -26,6 +26,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
const canEditPlaces = planner.can('place_edit', planner.trip)
const canEditDays = planner.can('day_edit', planner.trip)
const isTourPlace = (placeId: number) => planner.isTourPlace(placeId)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
const openSavePicker = useSaveToCollectionStore(s => s.open)
@@ -56,9 +57,9 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
openSavePicker(collectionTargetFromPlace(place))
}
const assignToDay = (dayId: number) => {
shell.closeSheet()
planner.handleAssignToDay(place.id, dayId)
const assignToDay = async (dayId: number) => {
const assigned = await planner.handleAssignToDay(place.id, dayId)
if (assigned !== false) shell.closeSheet()
}
const deletePlace = () => {
@@ -84,13 +85,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
<Eye size={16} strokeWidth={2} className="flex-none text-m-muted" />
{t('mobileTrip.viewDetails')}
</button>
{canEditPlaces && (
{canEditPlaces && !isTourPlace(place.id) && (
<button type="button" onClick={editPlace} className={rowCls}>
<Pencil size={16} strokeWidth={2} className="flex-none text-m-muted" />
{t('common.edit')}
</button>
)}
{collectionsEnabled && (
{collectionsEnabled && !isTourPlace(place.id) && (
<button type="button" onClick={saveToCollection} className={rowCls}>
<Bookmark size={16} strokeWidth={2} className="flex-none text-m-muted" />
{t('inspector.saveToCollection')}
@@ -111,7 +112,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
<button
key={d.id}
type="button"
onClick={() => assignToDay(d.id)}
onClick={() => { void assignToDay(d.id) }}
className={`flex w-full items-center gap-2 px-3 py-[10px] text-left ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">
@@ -131,7 +132,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
)}
</>
)}
{canEditPlaces && (
{canEditPlaces && !isTourPlace(place.id) && (
<button type="button" onClick={deletePlace} className={`${rowCls} text-[color:var(--m-st-danger)]`}>
<Trash2 size={16} strokeWidth={2} className="flex-none" />
{t('common.delete')}
@@ -6,6 +6,7 @@ import MIconBtn from '../../../components/MIconBtn'
import { FormSheetHeader } from './PlSheetChrome'
import ImpFileStep from './ImpFileStep'
import ImpListStep from './ImpListStep'
import { useAddonStore } from '../../../../store/addonStore'
import type { TripPlanner } from '../MTripShell'
export interface MImportSheetProps {
@@ -24,6 +25,9 @@ type ImportStep = 'menu' | 'file' | 'list'
export default function MImportSheet({ planner, open, onClose }: MImportSheetProps) {
const { t } = planner
const [step, setStep] = useState<ImportStep>('menu')
// File import (GPX/KML/KMZ) belongs to Tours mode while the addon is on;
// list import stays here in both states.
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
useEffect(() => {
if (open) setStep('menu')
@@ -50,12 +54,14 @@ export default function MImportSheet({ planner, open, onClose }: MImportSheetPro
{step === 'menu' && (
<div className="px-[14px] pb-[14px] pt-1">
{!toursEnabled && (
<ImpMenuRow
icon={FileDown}
title={t('places.importFile')}
sub="GPX · KML · KMZ"
onClick={() => setStep('file')}
/>
)}
<ImpMenuRow
icon={MapPin}
title={t('places.importList')}
@@ -28,8 +28,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
// Derived from the same priority list the dock seats itself from, so a section
// can never show up in both places at once.
const seated = dockTabIds(new Set(planner.TRIP_TABS.map(tab => tab.id)))
const gridTabs = planner.TRIP_TABS.filter(tab => !seated.has(tab.id))
const mobileTabs = planner.TRIP_TABS.filter(tab => !tab.desktopOnly)
const seated = dockTabIds(new Set(mobileTabs.map(tab => tab.id)))
const gridTabs = mobileTabs.filter(tab => !seated.has(tab.id))
const tileStat = (id: string): string | null => {
if (id === 'dateien') {
@@ -331,7 +331,9 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
// Two-tap confirm: arming also stages the id the planner's confirm reads.
setDeletePlaceId(sheetPlace.id)
setDeleteArmed(true)
toast.warning(t('mobileTrip.tapAgainToDelete'))
toast.warning(planner.isTourPlace(sheetPlace.id)
? t('tours.delete.confirmBody')
: t('mobileTrip.tapAgainToDelete'))
return
}
await confirmDeletePlace()
@@ -490,7 +492,9 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
<FormSheetFooter
onDelete={sheetPlace ? handleDelete : undefined}
deleteLabel={t('common.delete')}
deleteLabel={deleteArmed && sheetPlace && planner.isTourPlace(sheetPlace.id)
? t('tours.delete.confirmAction')
: t('common.delete')}
deleteArmed={deleteArmed}
onCancel={handleClose}
cancelLabel={t('common.cancel')}
@@ -0,0 +1,102 @@
import { render, screen } from '../../../../../tests/helpers/render'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildPlace } from '../../../../../tests/helpers/factories'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import MPlaceSheet from './MPlaceSheet'
vi.mock('../../../components/MSheet', () => ({
default: ({ open, children, ariaLabel }: { open: boolean; children?: ReactNode; ariaLabel?: string }) => (
<div data-testid="mobile-place-sheet" data-open={String(open)}>{open && <div role="dialog" aria-label={ariaLabel}>{children}</div>}</div>
),
}))
const place = buildPlace({ id: 42, name: 'Ridge walk' })
function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
return {
selectedPlace: place,
selectedTour: null,
toursEnabled: false,
tourDataReady: false,
can: () => false,
trip: {},
categories: [],
days: [],
assignments: {},
selectedDayId: null,
selectedAssignmentId: null,
reservations: [],
files: [],
tripMembers: [],
language: 'en',
isTourPlace: () => false,
canUploadFiles: false,
...overrides,
} as unknown as TripPlanner
}
const shell = {} as MTripShellApi
beforeEach(() => {
vi.clearAllMocks()
})
describe('MPlaceSheet tour exclusion', () => {
it('keeps ordinary place details open when Tours is off', () => {
render(<MPlaceSheet planner={planner()} shell={shell} />)
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'true')
})
it('waits for the Tours facet before choosing a detail surface', () => {
render(<MPlaceSheet planner={planner({ toursEnabled: true, tourDataReady: false })} shell={shell} />)
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'false')
})
it('does not open underneath the purpose-built dialog for a selected tour', () => {
render(<MPlaceSheet planner={planner({
toursEnabled: true,
tourDataReady: true,
selectedTour: { place_id: place.id } as TripPlanner['selectedTour'],
})} shell={shell} />)
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'false')
})
it('opens for an ordinary place after the Tours facet is ready', () => {
render(<MPlaceSheet planner={planner({ toursEnabled: true, tourDataReady: true })} shell={shell} />)
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'true')
})
it('keeps a dormant facet-backed Tour view-only while retaining day assignment controls', () => {
const tourPlace = buildPlace({ id: 42, name: 'Ridge walk', route_geometry: '[[48,11,500],[48.01,11.01,510]]', image_url: '/uploads/cover.jpg' })
const plannerWithTour = planner({
selectedPlace: tourPlace,
toursEnabled: false,
can: (permission: string) => permission === 'place_edit' || permission === 'day_edit',
isTourPlace: (placeId: number) => placeId === tourPlace.id,
days: [{ id: 7, day_number: 1, title: 'Day 1' } as never],
selectedDayId: 7,
selectedAssignmentId: 8,
assignments: { '7': [{ id: 8, day_id: 7, place: tourPlace, route_excluded: false }] as never },
tripActions: {
uploadPlaceImage: vi.fn(), updatePlace: vi.fn(), addFile: vi.fn(),
setAssignmentRouteExcluded: vi.fn(),
} as never,
canUploadFiles: true,
})
render(<MPlaceSheet planner={plannerWithTour} shell={shell} />)
expect(screen.getByRole('dialog', { name: 'Ridge walk' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.upload' })).not.toBeInTheDocument()
expect(screen.queryByRole('radiogroup', { name: 'places.yourRating' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'dayplan.excludeFromRoute' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Remove from Day' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'inspector.addToDay' })).not.toBeInTheDocument()
})
})
@@ -38,10 +38,15 @@ import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const { t } = useTranslation()
const place = planner.selectedPlace ?? null
const open = !!place
// Match the desktop inspector gate: while the Tours facet is loading, wait
// before choosing a surface; once identified as a tour, its purpose-built
// dialog is the only detail surface that should open.
const open = !!place && (!planner.toursEnabled || planner.tourDataReady) && !planner.selectedTour
const canEditPlaces = planner.can('place_edit', planner.trip)
const canEditDays = planner.can('day_edit', planner.trip)
const isTourPlace = place != null && planner.isTourPlace(place.id)
const canManagePlace = canEditPlaces && !isTourPlace
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
const openSavePicker = useSaveToCollectionStore(s => s.open)
@@ -166,7 +171,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !place) return
if (!file || !place || isTourPlace) return
setImgBusy(true)
try {
await planner.tripActions.uploadPlaceImage(planner.tripId, place.id, await normalizeImageFile(file))
@@ -178,7 +183,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
}
const handleTrackColor = async (color: string | null) => {
if (!place) return
if (!place || isTourPlace) return
try {
await planner.tripActions.updatePlace(planner.tripId, place.id, { route_color: color })
} catch (err: unknown) {
@@ -187,7 +192,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
}
const handleImageRemove = async () => {
if (!place) return
if (!place || isTourPlace) return
setImgBusy(true)
try {
await planner.tripActions.updatePlace(planner.tripId, place.id, { image_url: null })
@@ -200,7 +205,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || [])
if (!selected.length || !place) return
if (!selected.length || !place || isTourPlace) return
setUploading(true)
try {
for (const file of selected) {
@@ -267,7 +272,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<CatIcon size={20} strokeWidth={1.8} className="text-m-muted" />
</div>
)}
{canEditPlaces && (
{canManagePlace && (
<>
{/* Tap the thumbnail to set a custom image (#1136). */}
<button
@@ -339,7 +344,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */}
<div className={`mt-[10px] rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={handleRate} size={18} />
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={isTourPlace ? undefined : handleRate} size={18} />
</div>
{place.description && (
@@ -362,33 +367,37 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<>
<Eyebrow className="mb-[6px] mt-3">{t('inspector.trackColor')}</Eyebrow>
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
<button
type="button"
onClick={() => setColorPickerOpen(v => !v)}
aria-expanded={colorPickerOpen}
className="flex w-full items-center justify-between gap-3"
>
{/* The eyebrow above already names the section — this row says
which colour is in effect, not the same word again. */}
<span className="flex min-w-0 items-center gap-2">
<Route size={14} className="shrink-0 text-m-muted" />
<span className="truncate font-geist text-[0.75rem] font-medium">
{place.route_color ?? t('inspector.trackColorAuto')}
</span>
</span>
<span
className="h-6 w-6 shrink-0 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15"
style={{ background: resolveTrackColor(place) }}
/>
</button>
{colorPickerOpen && (
<div className="mt-[10px] border-t border-[color:var(--m-faint)] pt-[10px]">
<TrackColorPicker
value={place.route_color ?? null}
inheritedColor={inheritedTrackColor(place)}
onChange={handleTrackColor}
/>
</div>
{isTourPlace ? (
<span aria-label={t('inspector.trackColor')} style={{ display: 'block', width: 24, height: 24, borderRadius: 12, background: resolveTrackColor(place) }} />
) : (
<>
<button
type="button"
onClick={() => setColorPickerOpen(v => !v)}
aria-expanded={colorPickerOpen}
className="flex w-full items-center justify-between gap-3"
>
<span className="flex min-w-0 items-center gap-2">
<Route size={14} className="shrink-0 text-m-muted" />
<span className="truncate font-geist text-[0.75rem] font-medium">
{place.route_color ?? t('inspector.trackColorAuto')}
</span>
</span>
<span
className="h-6 w-6 shrink-0 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15"
style={{ background: resolveTrackColor(place) }}
/>
</button>
{colorPickerOpen && (
<div className="mt-[10px] border-t border-[color:var(--m-faint)] pt-[10px]">
<TrackColorPicker
value={place.route_color ?? null}
inheritedColor={inheritedTrackColor(place)}
onChange={handleTrackColor}
/>
</div>
)}
</>
)}
</div>
</>
@@ -403,7 +412,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
className={`flex items-center gap-1 rounded-full py-1 pl-[10px] text-[0.75rem] font-semibold ${INNER_CLS} ${canEditDays ? 'pr-1' : 'pr-[10px]'}`}
>
{day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })}
{canEditDays && place.lat != null && place.lng != null && (
{canEditDays && !isTourPlace && place.lat != null && place.lng != null && (
// In or out of that day's route (#2532); the icon says which it is.
<button
type="button"
@@ -511,7 +520,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
{/* ── Participants of the selected day's assignment ── */}
{assignmentInDay && members.length > 1 && (
{!isTourPlace && assignmentInDay && members.length > 1 && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('inspector.participants')}</Eyebrow>
<div className="flex flex-wrap items-center gap-[6px]">
@@ -580,7 +589,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{planner.canUploadFiles && (
{planner.canUploadFiles && !isTourPlace && (
<button
type="button"
onClick={e => { e.stopPropagation(); fileInputRef.current?.click() }}
@@ -612,7 +621,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{/* ── Action row ── */}
<div className="mt-[14px] flex items-center gap-[7px]">
{collectionsEnabled && (
{collectionsEnabled && !isTourPlace && (
<ActionCircle onClick={saveToCollection} label={t('inspector.saveToCollection')}>
<Bookmark size={15} strokeWidth={2} />
</ActionCircle>
@@ -649,7 +658,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<ExternalLink size={15} strokeWidth={2} />
</ActionCircle>
)}
{canEditPlaces && (
{canManagePlace && (
<ActionCircle
onClick={() => { planner.openPlaceEditor(place, assignmentInDay?.id ?? null); close() }}
label={t('common.edit')}
@@ -659,7 +668,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<Pencil size={15} strokeWidth={2} />
</ActionCircle>
)}
{canEditPlaces && (
{canManagePlace && (
<ActionCircle
onClick={() => { planner.handleDeletePlace(place.id); close() }}
label={t('common.delete')}
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from '../../../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
import { useSettingsStore } from '../../../../store/settingsStore'
import type { TripPlanner } from '../MTripShell'
import { MSelectedTourDetail } from './MTripSheets'
const place = buildPlace({
id: 42,
name: 'Ridge walk',
route_geometry: JSON.stringify([
[48, 11, 500],
[48, 11.01, 550],
[48, 11.03, 520],
]),
})
const tour: TourListItem = {
place_id: place.id,
name: place.name,
tour_type: 'hike',
distance: 999,
elevation_gain: 999,
elevation_loss: 999,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
return {
selectedPlace: place,
selectedTour: tour,
selectedDayId: null,
selectedAssignmentId: null,
assignments: {},
days: [],
files: [],
tripId: 7,
can: vi.fn<TripPlanner['can']>((actionKey) => actionKey === 'day_edit'),
canUploadFiles: false,
setSelectedPlaceId: vi.fn(),
reloadTourPlaceIds: vi.fn().mockResolvedValue(undefined),
tripActions: {
updatePlace: vi.fn().mockResolvedValue(undefined),
addFile: vi.fn().mockResolvedValue(undefined),
},
handleAssignToDay: vi.fn(),
handleRemoveAssignment: vi.fn(),
handleDeletePlace: vi.fn(),
...overrides,
} as unknown as TripPlanner
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
describe('MSelectedTourDetail', () => {
it('is the single mobile modal owner for Escape, focus containment, scroll lock and focus return', () => {
const opener = document.createElement('button')
opener.textContent = 'Open mobile Tour'
document.body.appendChild(opener)
opener.focus()
const setSelectedPlaceId = vi.fn()
const view = render(<MSelectedTourDetail planner={planner({ setSelectedPlaceId })} />)
const dialog = screen.getByRole('dialog', { name: 'Ridge walk' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1)
expect(document.body.style.overflow).toBe('hidden')
expect(document.activeElement).toBe(dialog)
expect(fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })).toBe(false)
const nestedSheet = document.createElement('div')
nestedSheet.dataset.mSheet = 'open'
document.body.appendChild(nestedSheet)
fireEvent.keyDown(document, { key: 'Escape' })
expect(setSelectedPlaceId).not.toHaveBeenCalled()
document.body.removeChild(nestedSheet)
fireEvent.keyDown(document, { key: 'Escape' })
expect(setSelectedPlaceId).toHaveBeenCalledWith(null)
view.unmount()
expect(document.activeElement).toBe(opener)
expect(document.body.style.overflow).not.toBe('hidden')
document.body.removeChild(opener)
})
it('renders the selected tour through the shared detail dialog and elevation profile', () => {
render(<MSelectedTourDetail planner={planner()} />)
expect(screen.getAllByText('Track Stats')).toHaveLength(1)
expect(document.querySelector('#tour-elevation-42')).toBeInTheDocument()
})
it('renders nothing for an ordinary selected place', () => {
render(<MSelectedTourDetail planner={planner({ selectedTour: null })} />)
expect(screen.queryByText('Track Stats')).not.toBeInTheDocument()
})
})
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal'
import BookingImportModal from '../../../../components/Planner/BookingImportModal'
import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal'
import TripFormModal from '../../../../components/Trips/TripFormModal'
import TripMembersModal from '../../../../components/Trips/TripMembersModal'
import TourDetailDialog from '../../../../components/Tours/TourDetailDialog'
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
import { expenseEditorFor } from '../../../../components/Budget/CostsPanel.helpers'
import { useAuthStore } from '../../../../store/authStore'
@@ -34,6 +36,62 @@ import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../../../../types'
import type { MTripSheetsProps } from '../MTripShell'
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
import { focusDialog, trapTab } from '../../../../components/shared/dialogFocus'
/** The one global mobile tour-detail owner, independent of which surface selected it. */
export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner'>) {
const panelRef = useRef<HTMLDivElement>(null)
const selectedTourId = planner.selectedTour?.place_id ?? null
const selectedPlaceId = planner.selectedPlace?.id ?? null
useEffect(() => {
if (selectedTourId == null || selectedPlaceId == null) return
const previous = document.activeElement as HTMLElement | null
const release = lockBodyScroll()
const panel = panelRef.current
if (panel) focusDialog(panel)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
const openPanels = document.querySelectorAll('[data-m-sheet="open"]')
if (openPanels.length && openPanels[openPanels.length - 1] !== panel) return
event.preventDefault()
planner.setSelectedPlaceId(null)
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
release()
if (previous?.isConnected) previous.focus()
}
}, [planner.setSelectedPlaceId, selectedPlaceId, selectedTourId])
if (!planner.selectedTour || !planner.selectedPlace) return null
return createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => planner.setSelectedPlaceId(null)}>
<div ref={panelRef} style={{ width: '100%', maxHeight: '85vh' }} role="dialog" aria-modal="true" aria-label={planner.selectedPlace.name}
data-m-sheet="open"
tabIndex={-1} onClick={event => event.stopPropagation()} onKeyDown={event => trapTab(event, panelRef.current!)}>
<TourDetailDialog
tour={planner.selectedTour}
place={planner.selectedPlace}
days={planner.days}
selectedDayId={planner.selectedDayId}
selectedAssignmentId={planner.selectedAssignmentId}
assignments={planner.assignments}
files={planner.files}
readOnly
canEdit={false}
canAssign={planner.can('day_edit', planner.trip)}
onClose={() => planner.setSelectedPlaceId(null)}
onAssignToDay={planner.handleAssignToDay}
onRemoveAssignment={planner.handleRemoveAssignment}
/>
</div>
</div>,
document.body,
)
}
/**
* Sheet host of the mobile trip screen — always mounted below the shell. Two
@@ -66,6 +124,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
<>
{/* ── Mobile sheets (shell.sheet routing + the place selection) ── */}
<MPlaceSheet planner={planner} shell={shell} />
<MSelectedTourDetail planner={planner} />
<MDaySheet planner={planner} shell={shell} />
<MDaysSheet planner={planner} shell={shell} />
<MAccommodationSheet planner={planner} shell={shell} />
@@ -191,13 +250,19 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
open={planner.deletePlaceId != null && !planner.showPlaceForm}
onClose={() => planner.setDeletePlaceId(null)}
title={t('common.delete')}
message={planner.deletePlaceNote ? (
message={planner.deletePlaceId != null && planner.isTourPlace(planner.deletePlaceId) ? (
<>
<span className="block">{t('tours.delete.confirmBody')}</span>
{planner.deletePlaceNote && <span className="mt-1 block">{planner.deletePlaceNote}</span>}
</>
) : planner.deletePlaceNote ? (
<>
<span className="block">{t('trip.confirm.deletePlace')}</span>
<span className="mt-1 block">{planner.deletePlaceNote}</span>
</>
) : t('trip.confirm.deletePlace')}
confirmLabel={t('common.delete')}
confirmLabel={planner.deletePlaceId != null && planner.isTourPlace(planner.deletePlaceId)
? t('tours.delete.confirmAction') : t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
+1 -1
View File
@@ -411,7 +411,7 @@ describe('TripPlannerPage', () => {
renderPlannerPage(15);
await waitFor(() => {
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15);
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15, expect.any(Function));
});
});
});
+213 -33
View File
@@ -11,6 +11,9 @@ import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import PlacesToursModeSwitch from '../components/Tours/PlacesToursModeSwitch'
import ToursSidebar from '../components/Tours/ToursSidebar'
import TourDetailDialog from '../components/Tours/TourDetailDialog'
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector'
@@ -53,8 +56,7 @@ import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo, ListPlus, Download, Plus, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
@@ -84,6 +86,12 @@ const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/Roa
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
const RoadtripTrackModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripTrackModal'))
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
const TourPlannerRail = lazyWithRetry(() =>
import('../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerRail }))
)
const TourPlannerToursRail = lazyWithRetry(() =>
import('../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerToursRail }))
)
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
// — about 200 kB of packed zone geometry that every trip used to load.
@@ -273,6 +281,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
function TripPlannerPageDesktop(): React.ReactElement | null {
// Page = wiring container: the entire planner state machine (store, tabs,
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const pageState = useTripPlannerPage()
const {
tripId, navigate, toast, t, language, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
@@ -319,6 +328,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
routeShown, setRouteShown, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
deletePlaceIsTour, deletePlacesIncludeTours,
stayRelease, setStayRelease, confirmStayRelease,
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
@@ -332,9 +342,17 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
clearDayId, clearDayTitle, handleClearDay, cancelClearDay, confirmClearDay,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
reloadTourPlaceIds, invalidateTourPlaceIds, selectedTour,
mapTileUrl, fontStyle, splashDone,
} = useTripPlanner()
} = pageState.planner
const {
poi,
tourPlanner,
permissions: { canPlaceEdit, canDayEdit },
tourDetails,
tourMap,
} = pageState
// 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.
@@ -356,7 +374,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
setMobileSidebarOpen(null)
} : undefined
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<CompassMap | null>(null)
// The corridor search draws into the same map channel and answers the same question for
// a drive, so the explore pill stands down while road trip mode is on.
@@ -519,7 +536,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onHighlightAlternative={setHighlightedAlternative}
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
onViewportChange={poi.onViewportChange}
onViewportChange={tourMap.captureViewport}
onMapReady={setGlMap}
/>
@@ -801,27 +818,41 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
</LazyPanel>
) : (
<PlacesSidebar
tripId={tripId}
places={places}
categories={categories}
assignments={assignments}
accommodations={tripAccommodations}
selectedDayId={selectedDayId}
onClearSelectedDay={() => handleSelectDay(null)}
selectedPlaceId={selectedPlaceId}
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true) }}
onAddPlaceToSelectedDay={selectedDayId != null ? () => { setEditingPlace(null); setPlaceFormDayId(selectedDayId); setShowPlaceForm(true) } : undefined}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => openPlaceEditor(place)}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
pushUndo={pushUndo}
days={days}
isMobile={false}
/>
<>
{/* Top level of the right add-panel, mirroring the always-visible
Days <-> Roadtrip switch on the left rail. Hidden entirely
when the tours addon is off. */}
{toursEnabled && (
<PlacesToursModeSwitch active={toursMode} onChange={setToursMode} />
)}
{toursEnabled && toursMode ? (
<ToursSidebar tripId={tripId} days={days} tours={tours} loading={toursLoading} selectedPlaceId={selectedPlaceId} canEdit={canPlaceEdit} canAssign={canDayEdit} onAssignToDay={handleAssignToDay} onToursChanged={placeIds => invalidateTourPlaceIds(placeIds ? { placeIds } : undefined)} onSelectTour={tourDetails.onSelectTour} />
) : (
<PlacesSidebar
tripId={tripId}
places={places}
toursEnabled={toursEnabled}
excludePlaceIds={toursEnabled ? tourPlaceIds : undefined}
categories={categories}
assignments={assignments}
accommodations={tripAccommodations}
selectedDayId={selectedDayId}
onClearSelectedDay={() => handleSelectDay(null)}
selectedPlaceId={selectedPlaceId}
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true) }}
onAddPlaceToSelectedDay={selectedDayId != null ? () => { setEditingPlace(null); setPlaceFormDayId(selectedDayId); setShowPlaceForm(true) } : undefined}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => openPlaceEditor(place)}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
pushUndo={pushUndo}
days={days}
isMobile={false}
/>
)}
</>
)}
</div>
</div>
@@ -882,7 +913,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
)
})()}
{selectedPlace && !isMobile && (
{selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && !isMobile && (
<PlaceInspector
roadtripEndDay={roadtripEndDay}
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
@@ -926,7 +957,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
)}
{selectedPlace && isMobile && createPortal(
{selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && isMobile && createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
<PlaceInspector
@@ -974,6 +1005,55 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
document.body
)}
{selectedTour && selectedPlace && !isMobile && (
<TourDetailDialog
tour={selectedTour}
desktopNonModal
desktopFocusReturnTarget={tourDetails.openerRef.current?.placeId === selectedTour.place_id ? tourDetails.openerRef.current.element : null}
canEdit={canPlaceEdit}
canAssign={canDayEdit}
place={selectedPlace}
days={days}
selectedDayId={selectedDayId}
selectedAssignmentId={selectedAssignmentId}
assignments={assignments}
files={files}
onClose={() => setSelectedPlaceId(null)}
onUpdatePlace={async (placeId, data) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() }}
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
onDelete={() => handleDeletePlace(selectedPlace.id)}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
/>
)}
{selectedTour && selectedPlace && isMobile && createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={event => event.stopPropagation()}>
<TourDetailDialog
tour={selectedTour}
canEdit={canPlaceEdit}
canAssign={canDayEdit}
place={selectedPlace}
days={days}
selectedDayId={selectedDayId}
selectedAssignmentId={selectedAssignmentId}
assignments={assignments}
files={files}
onClose={() => setSelectedPlaceId(null)}
onUpdatePlace={async (placeId, data) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() }}
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
/>
</div>
</div>,
document.body
)}
{mobileSidebarOpen && createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999 }} role="presentation" onClick={() => setMobileSidebarOpen(null)}>
<div className="bg-surface-card" style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} role="presentation" onClick={e => e.stopPropagation()}>
@@ -996,6 +1076,100 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
)}
{activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && (
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
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}
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}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
<button type="button" onClick={toggleLeft}
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
aria-expanded={!leftHidden}
className={`${leftHidden ? 'bg-accent text-accent-text shadow-md hover:opacity-90' : 'text-content-faint hover:text-content'} focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent`}
style={{
position: leftHidden ? 'fixed' : 'absolute', top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14,
left: leftHidden ? 10 : undefined, right: leftHidden ? undefined : -28, zIndex: -1,
width: 36, height: 36, borderRadius: leftHidden ? 10 : '0 10px 10px 0',
background: leftHidden ? undefined : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.15s',
}}>
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
</Tooltip>
<div style={{
width: leftHidden ? 0 : leftPanelPx, height: '100%', position: 'relative',
background: 'var(--sidebar-bg)', backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)', boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
borderRadius: 16, overflow: 'hidden', display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease', opacity: leftHidden ? 0 : 1,
}}>
{!leftHidden && <PanelResizeHandle side="left" width={leftWidth} min={resizeMin} max={resizeMax} onStart={startResizeLeft} onNudge={nudgeLeft} />}
<LazyPanel id="tour-planner-rail"><TourPlannerRail planner={tourPlanner} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
</div>
</div>
<div className="absolute bottom-[10px] right-[10px] top-[10px] z-20 hidden md:block">
<Tooltip label={rightHidden ? t('tours.planner.tripTours') : t('common.collapse')} placement="left">
<button type="button" onClick={toggleRight}
aria-label={rightHidden ? t('tours.planner.tripTours') : t('common.collapse')}
aria-expanded={!rightHidden}
className={`absolute flex h-9 w-9 items-center justify-center border-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${rightHidden ? 'right-0 top-1 rounded-xl bg-accent text-accent-text shadow-md hover:opacity-90' : '-left-7 top-3.5 rounded-l-xl text-content-faint hover:text-content'}`}
style={{ zIndex: -1, background: rightHidden ? undefined : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)' }}>
{rightHidden ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
</button>
</Tooltip>
<div
className="h-full overflow-hidden rounded-2xl"
style={{
width: rightHidden ? 0 : rightWidth,
opacity: rightHidden ? 0 : 1,
transition: 'width 0.25s ease, opacity 0.25s ease',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
boxShadow: rightHidden ? 'none' : 'var(--sidebar-shadow)',
}}
>
{!rightHidden && (
<PanelResizeHandle side="right" width={rightWidth} min={resizeMin} max={resizeMax} onStart={startResizeRight} onNudge={nudgeRight} />
)}
{!rightHidden && (
<LazyPanel id="tour-planner-tours">
<TourPlannerToursRail
planner={tourPlanner}
canEdit={canPlaceEdit}
canAssign={canDayEdit}
tours={tours}
days={days}
loading={toursLoading}
onAssignToDay={(placeId, dayId) => handleAssignToDay(placeId, dayId)}
onViewGpxTour={tour => {
const place = places.find(item => item.id === tour.place_id)
tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null)
}}
/>
</LazyPanel>
)}
</div>
</div>
</div>
)}
{activeTab === 'transports' && (
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<LazyPanel id="transports">
@@ -1206,16 +1380,22 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onClose={() => setDeletePlaceId(null)}
onConfirm={confirmDeletePlace}
title={t('common.delete')}
message={deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')}
message={deletePlaceIsTour
? [t('tours.delete.confirmBody'), deletePlaceNote].filter(Boolean).join(' ')
: deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')}
confirmLabel={deletePlaceIsTour ? t('tours.delete.confirmAction') : undefined}
/>
<ConfirmDialog
isOpen={!!deletePlaceIds?.length}
onClose={() => setDeletePlaceIds(null)}
onConfirm={confirmDeletePlaces}
title={t('common.delete')}
message={deletePlacesNote
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
message={deletePlacesIncludeTours
? [t('tours.delete.bulkConfirmBody'), deletePlacesNote].filter(Boolean).join(' ')
: deletePlacesNote
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
confirmLabel={deletePlacesIncludeTours ? t('tours.delete.confirmAction') : undefined}
/>
<ConfirmDialog
isOpen={clearDayId != null}
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { assignmentsApi } from '../api/client'
import { analyzeRouteGeometry } from '../utils/routeGeometry'
import TripPlannerPage from './TripPlannerPage'
import type { Day, Place, Reservation, Settings } from '../types'
@@ -21,6 +22,17 @@ import type { Day, Place, Reservation, Settings } from '../types'
// 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(),
openTour: vi.fn(),
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) => {
@@ -86,6 +98,37 @@ vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('book
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: vi.fn(),
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
elevationProfileExpanded: true,
toggleElevationProfile: vi.fn(),
newDraftGeneration: 0,
mode: plannerActions.mode,
isSaving: plannerActions.isSaving,
selectedWaypointId: null,
editingPlaceId: null,
saveOutcome: null,
hasUnsavedChanges: false,
setSelectedWaypointId: vi.fn(),
mapBaseLayer: 'map',
setMapBaseLayer: vi.fn(),
openTour: plannerActions.openTour,
viewGpxTour: plannerActions.viewGpxTour,
addWaypoint: plannerActions.addWaypoint,
closeGpxTour: vi.fn(),
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
discard: vi.fn(),
}),
}))
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }))
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }))
@@ -355,6 +398,16 @@ function baseState(): HookState {
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,
@@ -379,12 +432,238 @@ beforeEach(() => {
resetAllStores()
for (const key of Object.keys(captured)) delete captured[key]
confirmDialogs.length = 0
plannerActions.viewGpxTour.mockReset()
plannerActions.addWaypoint.mockReset()
plannerActions.openTour.mockReset()
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('UI-R2: Tours Planner rails use shared resize bounds, touch handles and keyboard nudges', () => {
const { container } = renderPage({ activeTab: 'tour-planner', toursEnabled: true,
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true } })
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(handles).toHaveLength(2)
expect(Array.from(handles, handle => handle.getAttribute('data-resize'))).toEqual(['left', 'right'])
for (const handle of handles) {
expect(handle).toHaveAttribute('aria-valuemin', '200')
expect(handle).toHaveAttribute('aria-valuemax', '520')
expect(handle.style.touchAction).toBe('none')
expect(handle).toHaveAttribute('aria-label')
}
fireEvent.touchStart(handles[0])
expect(hookState.startResizeLeft).toHaveBeenCalled()
fireEvent.keyDown(handles[1], { key: 'ArrowLeft' })
expect(hookState.nudgeRight).toHaveBeenCalledWith(16)
expect(props('map').leftWidth).toBe(320)
expect(props('map').rightWidth).toBe(320)
})
it('UI-R2: collapsed Tours rails retain semantic restore controls and report zero map insets', () => {
renderPage({ activeTab: 'tour-planner', toursEnabled: true, leftHidden: true, rightHidden: true,
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true } })
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
expect(screen.getByRole('button', { name: 'trip.mobilePlan' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'tours.planner.tripTours' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'trip.mobilePlan' })[0]).toHaveClass('focus-visible:outline')
expect(document.querySelectorAll('div[role="separator"]')).toHaveLength(0)
})
it.each([
{ leftHidden: false, rightHidden: true, visibleSide: 'left', leftInset: 300, rightInset: 0 },
{ leftHidden: true, rightHidden: false, visibleSide: 'right', leftInset: 0, rightInset: 300 },
])('UI-R2: the 768-1023px panel state shows only its selected rail ($visibleSide)', ({ leftHidden, rightHidden, visibleSide, leftInset, rightInset }) => {
const { container } = renderPage({ activeTab: 'tour-planner', toursEnabled: true, narrowPanels: true,
leftHidden, rightHidden, leftWidth: 300, rightWidth: 300, resizeMax: 300,
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true } })
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(handles).toHaveLength(1)
expect(handles[0]).toHaveAttribute('data-resize', visibleSide)
expect(handles[0]).toHaveAttribute('aria-valuemax', '300')
expect(props('map').leftWidth).toBe(leftInset)
expect(props('map').rightWidth).toBe(rightInset)
})
it('UI-R2: rail collapse changes only map insets, not the fit target or routed draft', () => {
const view = renderPage({ activeTab: 'tour-planner', toursEnabled: true,
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true } })
const focusPoints = props('map').focusPoints
const route = props('map').route
const fitKey = props('map').fitKey
hookState = { ...hookState, leftHidden: true, rightHidden: true }
view.rerender(<TripPlannerPage />)
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
expect(props('map').focusPoints).toBe(focusPoints)
expect(props('map').route).toBe(route)
expect(props('map').fitKey).toBe(fitKey)
expect(plannerActions.mode).toEqual({ type: 'neutral' })
})
it('FE-PAGE-TPW-066: Tours entry stays neutral when saved Tours exist and does not auto-select one', async () => {
const savedTour = {
place_id: 92,
name: 'Editable ridge tour',
tour_type: 'hike',
distance: 6,
elevation_gain: 300,
elevation_loss: 290,
duration: 120,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
}
renderPage({ activeTab: 'tour-planner', toursEnabled: true, enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, tours: [savedTour] })
expect(await screen.findByText('Plan a tour')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Plan new tour' })).toBeInTheDocument()
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
expect(screen.getByRole('option', { name: /Editable ridge tour/i })).not.toHaveAttribute('aria-selected', 'true')
expect(plannerActions.openTour).not.toHaveBeenCalled()
})
it('FE-PAGE-TPW-063: uses the last normal Plan viewport before falling back to trip Places', () => {
const first = buildPlace({ id: 81, lat: 47.1, lng: 11.2 })
const second = buildPlace({ id: 82, lat: 47.3, lng: 11.6 })
const view = renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true, places: [first, second] })
expect(props('map').focusPoints).toEqual([[47.1, 11.2], [47.3, 11.6]])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').onMapClick).toBeUndefined()
plannerActions.mode = { type: 'new-draft' }
view.rerender(<TripPlannerPage />)
plannerActions.isSaving = true
view.rerender(<TripPlannerPage />)
expect(props('map').onMapClick).toBeUndefined()
expect(screen.getByTestId('map-view')).toBeInTheDocument()
plannerActions.isSaving = false
view.rerender(<TripPlannerPage />)
act(() => props('map').onMapClick({ latlng: { lat: 47.11, lng: 11.21 } }))
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(47.11, 11.21)
expect(props('map').plannerWaypoints).toEqual([])
hookState = { ...hookState, activeTab: 'plan' }
view.rerender(<TripPlannerPage />)
act(() => props('map').onViewportChange({ south: 46.9, west: 10.8, north: 47.5, east: 12.1 }))
hookState = { ...hookState, activeTab: 'tour-planner' }
view.rerender(<TripPlannerPage />)
expect(props('map').focusPoints).toEqual([[46.9, 10.8], [47.5, 12.1]])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
})
it('keeps editable fit targets stable while transient profile focus changes', () => {
const waypoints = [
{ id: 'start', lat: 47.1, lng: 11.2, role: 'start' },
{ id: 'end', lat: 47.3, lng: 11.6, role: 'end' },
]
plannerActions.mode = { type: 'edit-saved', placeId: 92 }
plannerActions.waypoints = waypoints
const view = renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true })
const fitTargets = props('map').focusPoints
const route = props('map').route
const mapWaypoints = props('map').plannerWaypoints
plannerActions.routeProfileFocus = { distanceMeters: 10, elevationMeters: 100, lat: 47.11, lng: 11.21, sampleIndex: 1 }
view.rerender(<TripPlannerPage />)
expect(props('map').routeProfileFocus).toBe(plannerActions.routeProfileFocus)
expect(props('map').focusPoints).toBe(fitTargets)
expect(props('map').route).toBe(route)
expect(props('map').plannerWaypoints).toBe(mapWaypoints)
plannerActions.routeProfileFocus = { distanceMeters: 20, elevationMeters: 110, lat: 47.12, lng: 11.22, sampleIndex: 1 }
view.rerender(<TripPlannerPage />)
expect(props('map').focusPoints).toBe(fitTargets)
expect(props('map').route).toBe(route)
expect(props('map').plannerWaypoints).toBe(mapWaypoints)
plannerActions.waypoints = [...waypoints, { id: 'via', lat: 47.2, lng: 11.4, role: 'via' }]
view.rerender(<TripPlannerPage />)
expect(props('map').focusPoints).not.toBe(fitTargets)
expect(props('map').focusPoints).toEqual([[47.1, 11.2], [47.3, 11.6], [47.2, 11.4]])
})
it('FE-PAGE-TPW-064: falls back to the normal map default when there is no viewport or geo Place', () => {
renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true, places: [] })
expect(props('map').focusPoints).toEqual([])
expect(props('map').center).toBeUndefined()
expect(props('map').zoom).toBeUndefined()
})
it('FE-PAGE-TPW-065: Tours OFF stays on native Plan and does not create the Tours Planner hook', () => {
const view = renderPage({ activeTab: 'plan', toursEnabled: false })
expect(screen.getByTestId('map-view')).toBeInTheDocument()
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument()
hookState = { ...hookState, activeTab: 'plan' }
view.rerender(<TripPlannerPage />)
expect(screen.getByTestId('map-view')).toBeInTheDocument()
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-062: opens GPX geometry in the read-only Planner path without mounting the general detail dialog', async () => {
const gpxPlace = buildPlace({ id: 91, name: 'Imported ridge GPX', lat: 47.1, lng: 11.2, route_geometry: '[[47.1,11.2,500],[47.2,11.3,550]]' })
const gpxTour = {
place_id: 91,
name: 'Imported ridge GPX',
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 420,
duration: 180,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
planned: false,
caution: false,
has_waypoints: false,
}
const trekTour = { ...gpxTour, place_id: 92, name: 'Editable ridge tour', has_waypoints: true }
const view = renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true, places: [gpxPlace], tours: [gpxTour, trekTour] })
fireEvent.click(await screen.findByRole('option', { name: /Imported ridge GPX/i }))
expect(plannerActions.viewGpxTour).toHaveBeenCalledWith(gpxTour, gpxPlace.route_geometry)
expect(screen.queryByTestId('tour-detail-dialog')).not.toBeInTheDocument()
const analysis = analyzeRouteGeometry(gpxPlace.route_geometry)
plannerActions.readOnlyGpxTour = { tour: gpxTour, routeGeometry: gpxPlace.route_geometry }
plannerActions.readOnlyGpxAnalysis = analysis
plannerActions.mode = { type: 'view-gpx', placeId: gpxTour.place_id, tour: gpxTour }
view.rerender(<TripPlannerPage />)
expect(props('map').route).toEqual([analysis?.routeCoordinates])
expect(props('map').focusPoints).toEqual(analysis?.routeCoordinates)
expect(props('map').onMapClick).toBeUndefined()
expect(props('map').plannerWaypoints).toEqual([])
expect(screen.getByRole('heading', { name: 'GPX tour' })).toBeInTheDocument()
expect(screen.getAllByText('Imported ridge GPX')).toHaveLength(2)
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
expect(plannerActions.viewGpxTour).toHaveBeenCalledOnce()
})
it('FE-PAGE-TPW-002: the splash holds the page until the trip finished loading', () => {
renderPage({ isLoading: true, splashDone: false })
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as ts from 'typescript'
import { test, vi } from 'vitest'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in static tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in static tests') })
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in static tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in static tests') })
const pagePath = 'client/src/pages/TripPlannerPage.tsx'
const hookPath = 'client/src/pages/tripPlanner/useTripPlanner.ts'
const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8')
const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8')
const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
const page = parse(pagePath, pageText)
const hook = parse(hookPath, hookText)
function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] {
const matches: ts.Node[] = []
function visit(node: ts.Node) {
if (predicate(node)) matches.push(node)
ts.forEachChild(node, visit)
}
visit(source)
return matches
}
function initializer(name: string): ts.Expression {
const declaration = collect(hook, node => ts.isVariableDeclaration(node)
&& ts.isIdentifier(node.name) && node.name.text === name)[0]
assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name)
return declaration.initializer
}
function jsxTags(source: ts.SourceFile, name: string) {
return collect(source, node => (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
&& node.tagName.getText(source) === name)
}
test('B4A Core testability debt: verify source without importing the page, hook, config, or database', () => {
for (const source of [page, hook]) {
const diagnostics = ts.transpileModule(source.text, {
fileName: source.fileName,
reportDiagnostics: true,
compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2022 },
}).diagnostics ?? []
assert.equal(diagnostics.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error).length, 0, source.fileName)
assert.doesNotMatch(source.text, /^(<<<<<<<|=======|>>>>>>>)/m)
}
assert.doesNotMatch(pageText, /\bnarrowPanels\b/)
const self = parse('b4a-wiring.test.ts', readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/b4a-wiring.test.ts'), 'utf8'))
const imports = collect(self, ts.isImportDeclaration).map(node => {
assert.ok(ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier))
return node.moduleSpecifier.text
})
assert.deepEqual(imports, ['node:assert/strict', 'node:fs', 'node:path', 'typescript', 'vitest'])
assert.equal(collect(self, node => ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword).length, 0)
})
test('B4A Tours tab is readable, desktop-only, and absent with addon OFF', () => {
const tabs = initializer('TRIP_TABS')
const branches = collect(tabs, node => ts.isConditionalExpression(node)
&& node.whenTrue.getText(hook).includes("id: 'tour-planner'"))
assert.equal(branches.length, 1)
const branch = branches[0]
assert.ok(ts.isConditionalExpression(branch))
assert.equal(branch.condition.getText(hook), 'enabledAddons.tours && !isMobile')
assert.equal(branch.whenFalse.getText(hook), '[]')
assert.match(branch.whenTrue.getText(hook), /desktopOnly: true/)
assert.doesNotMatch(tabs.getText(hook), /\bcan\b|canPlaceEdit|canDayEdit/)
assert.match(hookText, /!validTabIds\.includes\(activeTab\)[\s\S]*?setActiveTab\('plan'\)/)
assert.match(pageText, /activeTab === 'tour-planner' && enabledAddons\.tours && !isMobile/)
})
test('B4A selectedTour is derived only from explicit place selection and addon state', () => {
const selection = initializer('selectedTour')
assert.ok(ts.isConditionalExpression(selection))
assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId')
assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/)
assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword)
})
test('B4A approved permissions reach every Tours surface and gate map edits', () => {
assert.match(pageText, /const canPlaceEdit = can\('place_edit', trip\)/)
assert.match(pageText, /const canDayEdit = can\('day_edit', trip\)/)
assert.match(pageText, /useTourPlanner\(\{\s*tripId,\s*canEdit: canPlaceEdit,\s*canAssign: canDayEdit,/)
for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) {
const tags = jsxTags(page, name)
assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1)
for (const tag of tags) {
assert.match(tag.getText(page), /canEdit=\{canPlaceEdit\}/)
assert.match(tag.getText(page), /canAssign=\{canDayEdit\}/)
}
}
assert.match(pageText, /onMapClick=\{canPlaceEdit && !tourPlanner\.isSaving && \(tourPlanner\.mode\.type === 'new-draft' \|\| tourPlanner\.mode\.type === 'edit-saved'\)/)
})
test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
const entry = collect(page, node => ts.isFunctionDeclaration(node)
&& node.name?.text === 'TripPlannerPageDesktop')[0]
assert.ok(entry)
assert.equal(collect(entry, node => ts.isCallExpression(node)
&& node.expression.getText(page) === 'useTourPlanner').length, 1)
assert.doesNotMatch(hookText, /\buseTourPlanner\b/)
for (const mode of ['view-gpx', 'new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`))
assert.match(pageText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
assert.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
assert.match(pageText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/)
assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/)
})
test('B4A retains non-Tours panels and protected page integrations', () => {
for (const name of ['HelpAnchor', 'RoadtripSidebar', 'RoadtripCorridorPanel', 'DayDetailPanel', 'PlaceInspector', 'BookingDetailPopup']) {
assert.ok(jsxTags(page, name).length > 0, name)
}
assert.ok(jsxTags(page, 'PanelResizeHandle').length >= 2)
for (const tab of ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab']) {
const panels = collect(page, node => ts.isJsxExpression(node)
&& !!node.expression && ts.isBinaryExpression(node.expression)
&& node.expression.left.getText(page) === `activeTab === '${tab}'`)
assert.equal(panels.length, 1, tab)
}
assert.match(hookText, /dayDetail && days\.some\(d => d\.id === dayDetail\.id\) \? dayDetail : null/)
assert.match(hookText, /!stop\.automaticNight && !stop\.bookend/)
})
+7 -4
View File
@@ -15,7 +15,8 @@ interface DayClearOptions {
toast: { error: (message: string) => unknown }
roadtripVias: Pick<RoadtripVias, 'byDay' | 'reanchor'>
updateRouteForDay: (dayId: number) => void
pushUndo: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => void
pushUndo: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[], placeIds?: number[]) => void
onToursChanged?: () => void | Promise<void>
}
export interface DayClear {
@@ -37,7 +38,7 @@ const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number
* places back in their old order.
*/
export function useDayClear(options: DayClearOptions): DayClear {
const { tripId, days, canEditDays, t, locale, toast, roadtripVias, updateRouteForDay, pushUndo } = options
const { tripId, days, canEditDays, t, locale, toast, roadtripVias, updateRouteForDay, pushUndo, onToursChanged } = options
const [clearDayId, setClearDayId] = useState<number | null>(null)
const ordered = useMemo(() => [...days].sort(byNumber), [days])
@@ -68,14 +69,16 @@ export function useDayClear(options: DayClearOptions): DayClear {
return
}
updateRouteForDay(dayId)
await onToursChanged?.()
const placeIds = cleared.map(a => a.place?.id).filter((id): id is number => id != null)
pushUndo(t('undo.clearDay'), async () => {
// One after the other: each lands at the position the one before it left free.
for (const [position, placeId] of placeIds.entries()) {
await useTripStore.getState().assignPlaceToDay(tripId, dayId, placeId, position)
}
}, [dayId])
}, [clearDayId, tripId, roadtripVias, toast, t, updateRouteForDay, pushUndo])
await onToursChanged?.()
}, [dayId], placeIds)
}, [clearDayId, tripId, roadtripVias, toast, t, updateRouteForDay, pushUndo, onToursChanged])
return { clearDayId, clearDayTitle, handleClearDay, cancelClearDay, confirmClearDay }
}
@@ -10,15 +10,17 @@ import { useSettingsStore } from '../../store/settingsStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { usePluginStore } from '../../store/pluginStore'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { useAddonStore } from '../../store/addonStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation, buildBudgetItem } from '../../../tests/helpers/factories'
import {
addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi,
healthApi, airtrailApi, mapsApi,
healthApi, airtrailApi, mapsApi, toursApi,
} from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb, saveImportFiles, getImportFiles } from '../../db/offlineDb'
import { getCached, fetchPhoto } from '../../services/photoService'
import type { TourListItem } from '@trek/shared'
import type { Accommodation, Place, Reservation, Settings } from '../../types'
// ── Router ────────────────────────────────────────────────────────────────────
@@ -1262,6 +1264,62 @@ describe('useTripPlanner — place CRUD', () => {
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 900, 2)
})
it('permanently deletes a Tour without Place restore and forgets only its stale assignment undo', async () => {
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 })
const unrelatedUndo = vi.fn()
const tourAssignmentUndo = vi.fn()
seedTrip({ places: [tourPlace] })
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] })
const { result } = await renderPlanner()
act(() => {
result.current.pushUndo('Unrelated place edit', unrelatedUndo)
result.current.pushUndo('Remove Tour from day', tourAssignmentUndo, [7], [1])
result.current.handleDeletePlace(1)
})
expect(result.current.deletePlaceIsTour).toBe(true)
await act(async () => { await result.current.confirmDeletePlace() })
expect(actions.deletePlace).toHaveBeenCalledWith(42, 1)
expect(actions.addPlace).not.toHaveBeenCalled()
expect(result.current.lastActionLabel).toBe('Unrelated place edit')
expect(result.current.canUndo).toBe(true)
await act(async () => { await result.current.undo() })
expect(unrelatedUndo).toHaveBeenCalledTimes(1)
expect(tourAssignmentUndo).not.toHaveBeenCalled()
})
it('uses the server facet result to suppress Undo for a dormant Tour when Tours is off', async () => {
const dormantPlace = buildPlace({ id: 1, name: 'Dormant route' })
seedTrip({ places: [dormantPlace] })
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] })
const { result } = await renderPlanner()
act(() => { result.current.handleDeletePlace(1) })
await act(async () => { await result.current.confirmDeletePlace() })
expect(result.current.enabledAddons.tours).toBe(false)
expect(actions.addPlace).not.toHaveBeenCalled()
expect(result.current.canUndo).toBe(false)
})
it('does not offer a partial bulk Undo when the deleted selection includes a Tour', async () => {
const ordinary = buildPlace({ id: 1, name: 'Ordinary place' })
const tourPlace = buildPlace({ id: 2, name: 'Tour', tour_place_id: 2 })
seedTrip({ places: [ordinary, tourPlace] })
actions.deletePlacesMany.mockResolvedValue({ deleted: [1, 2], count: 2, tourPlaceIds: [2] })
const { result } = await renderPlanner()
act(() => { result.current.setDeletePlaceIds([1, 2]) })
expect(result.current.deletePlacesIncludeTours).toBe(true)
await act(async () => { await result.current.confirmDeletePlaces() })
expect(actions.deletePlacesMany).toHaveBeenCalledWith(42, [1, 2])
expect(actions.addPlace).not.toHaveBeenCalled()
expect(result.current.canUndo).toBe(false)
})
it('FE-TP-HOOK-056: confirmDeletePlace is a no-op until a place is queued', async () => {
seedTrip()
@@ -1454,6 +1512,101 @@ describe('useTripPlanner — place CRUD', () => {
})
describe('useTripPlanner — day plan CRUD', () => {
it('refreshes Tours only after a successful Tour assignment write', async () => {
const unplanned: TourListItem = {
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
}
const planned = { ...unplanned, planned: true }
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] })
const place = buildPlace({ id: 1, tour_place_id: 1 })
seedTrip({ places: [place] })
let resolveAssignment!: (value: { id: number }) => void
const assignment = new Promise<{ id: number }>(resolve => { resolveAssignment = resolve })
actions.assignPlaceToDay.mockReturnValue(assignment)
const { result } = await renderPlanner()
await waitFor(() => expect(result.current.tours).toEqual([unplanned]))
listTours.mockClear().mockResolvedValue({ tours: [planned] })
let assignPromise!: Promise<boolean>
act(() => { assignPromise = result.current.handleAssignToDay(1, 7) })
expect(listTours).not.toHaveBeenCalled()
await act(async () => { resolveAssignment({ id: 555 }); await assignPromise })
await waitFor(() => expect(result.current.tours).toEqual([planned]))
expect(actions.assignPlaceToDay.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0])
listTours.mockClear().mockResolvedValue({ tours: [unplanned] })
let resolveUndoRemoval!: () => void
const undoRemoval = new Promise<void>(resolve => { resolveUndoRemoval = resolve })
actions.removeAssignment.mockReturnValueOnce(undoRemoval)
let undoPromise!: Promise<unknown>
act(() => { undoPromise = result.current.undo() })
expect(listTours).not.toHaveBeenCalled()
await act(async () => { resolveUndoRemoval(); await undoPromise })
await waitFor(() => expect(result.current.tours).toEqual([unplanned]))
expect(actions.removeAssignment.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0])
})
it('does not refresh or report a successful planned state when Tour assignment fails', async () => {
const unplanned: TourListItem = {
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
}
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] })
const place = buildPlace({ id: 1, tour_place_id: 1 })
seedTrip({ places: [place] })
const { result } = await renderPlanner()
await waitFor(() => expect(result.current.tours).toEqual([unplanned]))
listTours.mockClear()
actions.assignPlaceToDay.mockRejectedValueOnce(new Error('assignment failed'))
await act(async () => { await result.current.handleAssignToDay(1, 7) })
expect(listTours).not.toHaveBeenCalled()
expect(result.current.tours).toEqual([unplanned])
expect(toasts.some(toast => toast.message === 'assignment failed' && toast.type === 'error')).toBe(true)
})
it('refreshes after Tour removal and each successful assignment Undo direction', async () => {
const unplanned: TourListItem = {
place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
}
const planned: TourListItem = { ...unplanned, planned: true }
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [planned] })
const place = buildPlace({ id: 1, tour_place_id: 1 })
seedTrip({
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 1 })] },
})
const { result } = await renderPlanner()
await waitFor(() => expect(result.current.tours).toEqual([planned]))
listTours.mockClear().mockResolvedValue({ tours: [unplanned] })
let resolveRemoval!: () => void
const removal = new Promise<void>(resolve => { resolveRemoval = resolve })
actions.removeAssignment.mockReturnValueOnce(removal)
let removePromise!: Promise<void>
act(() => { removePromise = result.current.handleRemoveAssignment(7, 10) })
expect(listTours).not.toHaveBeenCalled()
await act(async () => { resolveRemoval(); await removePromise })
await waitFor(() => expect(result.current.tours).toEqual([unplanned]))
expect(actions.removeAssignment.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0])
listTours.mockClear().mockResolvedValue({ tours: [planned] })
actions.assignPlaceToDay.mockResolvedValueOnce({ id: 777 })
await act(async () => { await result.current.undo() })
await waitFor(() => expect(result.current.tours).toEqual([planned]))
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 1)
expect(actions.assignPlaceToDay.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0])
})
it('FE-TP-HOOK-063: assigning to the selected day registers an undo that removes it again', async () => {
seedTrip({ selectedDayId: 7 })
@@ -1511,8 +1664,8 @@ describe('useTripPlanner — day plan CRUD', () => {
expect(actions.assignPlaceToDay).toHaveBeenNthCalledWith(2, 42, 7, 1, 1)
})
it('FE-TP-HOOK-066: removing an assignment can be undone back to its old position', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
it('FE-TP-HOOK-066: removing a Tour assignment can be undone without deleting the Tour', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1 })
seedTrip({
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })] },
@@ -1522,6 +1675,8 @@ describe('useTripPlanner — day plan CRUD', () => {
await act(async () => { await result.current.handleRemoveAssignment(7, 10) })
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 7, 10)
expect(actions.deletePlace).not.toHaveBeenCalled()
expect(actions.addPlace).not.toHaveBeenCalled()
await act(async () => { await result.current.undo() })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 4)
@@ -2494,6 +2649,24 @@ describe('useTripPlanner — misc state', () => {
expect(result.current.deletePlaceId).toBeNull()
})
it('routes mobile Place-editor entry for a dormant Tour to read-only Tour detail', async () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 390 })
try {
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 })
seedTrip({ places: [tourPlace] })
const { result } = await renderPlanner()
act(() => result.current.openPlaceEditor(tourPlace))
expect(result.current.isMobile).toBe(true)
expect(result.current.selectedPlaceId).toBe(1)
expect(result.current.showPlaceForm).toBe(false)
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalWidth })
}
})
it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
seedTrip()
const { result } = await renderPlanner()
+92 -33
View File
@@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../../components/Roadtrip/dayColors'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Route } from 'lucide-react'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, Route } from 'lucide-react'
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
@@ -62,6 +62,8 @@ import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { MAX_TRIP_DAYS, normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { useAddonStore } from '../../store/addonStore'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { useIsTouch } from '../../hooks/useIsTouch'
@@ -129,7 +131,7 @@ export function useTripPlanner() {
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canUploadFiles = can('file_upload', trip)
const { pushUndo, undo, forgetDay, canUndo, lastActionLabel } = usePlannerHistory()
const { pushUndo, undo, forgetDay, forgetPlace, canUndo, lastActionLabel } = usePlannerHistory()
// A step that could not be taken back says so instead of claiming it was.
const handleUndo = useCallback(async () => {
@@ -139,7 +141,7 @@ export function useTripPlanner() {
else if (undone) toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, tours: false, dawarich: false })
// The values above are an optimistic guess until the addon feed answers. The
// tab guard below waits for this before evicting anything, so a tab we were
// asked to open ('collab' in particular, guessed off) survives the gap.
@@ -225,7 +227,7 @@ export function useTripPlanner() {
addonsApi.enabled().then(data => {
const map: Record<string, boolean> = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, tours: !!map.tours, dawarich: !!map.dawarich })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {}).finally(() => setAddonsLoaded(true))
authApi.getAppConfig().then(config => {
@@ -242,6 +244,7 @@ export function useTripPlanner() {
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
const TRIP_TABS = [
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
...(enabledAddons.tours && !isMobile ? [{ id: 'tour-planner', label: t(TRIP_TAB_LABEL_KEYS['tour-planner']), icon: Mountain, desktopOnly: true }] : []),
{ id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train },
{ id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
// Phone only: the desktop reaches the drive through the mode switch beside the
@@ -282,7 +285,7 @@ export function useTripPlanner() {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded])
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
const handleTabChange = (rawTabId: string): void => {
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
@@ -313,6 +316,21 @@ export function useTripPlanner() {
startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax,
} = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const toursEnabled = useAddonStore(state => state.isEnabled('tours'))
const [toursMode, setToursMode] = useState(false)
const previousToursModeRef = useRef(false)
const { tours, toursLoading, tourDataReady, tourPlaceIds, reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour } = useTourPlaceIds(tripId, toursEnabled)
useEffect(() => {
if (!toursEnabled) {
setToursMode(false)
previousToursModeRef.current = false
return
}
const enteredToursMode = toursMode && !previousToursModeRef.current
previousToursModeRef.current = toursMode
if (enteredToursMode) setSelectedPlaceId(null)
}, [toursEnabled, toursMode, setSelectedPlaceId])
const [dayDetail, setShowDayDetail] = useState<Day | null>(null)
// A day deleted while its panel is open, here or by a fellow traveller, takes
// the panel along instead of leaving it on a day that is gone.
@@ -575,6 +593,10 @@ export function useTripPlanner() {
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
const isTourPlace = useCallback((placeId: number) => tourPlaceIds.has(placeId)
|| places.some(place => place.id === placeId && place.tour_place_id === placeId), [places, tourPlaceIds])
const deletePlaceIsTour = deletePlaceId != null && isTourPlace(deletePlaceId)
const deletePlacesIncludeTours = !!deletePlaceIds?.some(isTourPlace)
/**
* The sentence the delete question adds when a night is booked at one of the places.
*
@@ -705,7 +727,7 @@ export function useTripPlanner() {
return () => window.removeEventListener('accommodations:refresh', onRefresh)
}, [loadAccommodations])
useTripWebSocket(tripId)
useTripWebSocket(tripId, invalidateTourPlaceIds)
// Same filter the places sidebar renders — shared via the store so tab
// switches can't desync the marker set from the filter UI (#1541).
@@ -776,7 +798,7 @@ export function useTripPlanner() {
// Road trip mode already draws the whole trip its own way, so the overview stands
// down there rather than drawing a second set of lines over it.
const overviewActive = overviewShown && !roadtripMode
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places)
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
@@ -2343,6 +2365,10 @@ export function useTripPlanner() {
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
if (isMobile && isTourPlace(place.id)) {
handlePlaceClick(place.id, preferredAssignmentId)
return
}
if (!can('place_edit', trip)) return
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
@@ -2368,7 +2394,7 @@ export function useTripPlanner() {
setPlaceFormDayId(null)
setServiceStopForm(false)
setShowPlaceForm(true)
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
}, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
/**
* How long the drive stands here, for every stop alike.
@@ -2395,11 +2421,16 @@ export function useTripPlanner() {
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
try {
await tripActions.deletePlace(tripId, deletePlaceId)
const deletion = await tripActions.deletePlace(tripId, deletePlaceId)
const deletedTour = Array.isArray(deletion?.tourPlaceIds)
? deletion.tourPlaceIds.includes(deletePlaceId)
: isTourPlace(deletePlaceId)
void reloadTourPlaceIds()
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
if (deletedTour) forgetPlace(deletePlaceId)
else if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
@@ -2420,7 +2451,7 @@ export function useTripPlanner() {
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
@@ -2431,12 +2462,17 @@ export function useTripPlanner() {
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
)
try {
await tripActions.deletePlacesMany(tripId, targetIds)
const deletion = await tripActions.deletePlacesMany(tripId, targetIds)
const deletedTourIds = Array.isArray(deletion?.tourPlaceIds)
? deletion.tourPlaceIds.filter(id => targetIds.includes(id))
: targetIds.filter(isTourPlace)
void reloadTourPlaceIds()
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
if (!ids) setDeletePlaceIds(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
if (capturedPlaces.length > 0) {
if (deletedTourIds.length > 0) deletedTourIds.forEach(forgetPlace)
else if (capturedPlaces.length > 0) {
pushUndo(t('undo.deletePlaces'), async () => {
const live = new Set(useTripStore.getState().days.map(d => d.id))
for (const place of capturedPlaces) {
@@ -2453,7 +2489,7 @@ export function useTripPlanner() {
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace])
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
if (!ids.length) return
@@ -2483,26 +2519,36 @@ export function useTripPlanner() {
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
const place = places.find(p => p.id === placeId)
// A place with a start of its own is drawn by it, so it is stored there too, the
// way a stop moved over from another day is. Without one it goes where it was put.
const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position
const plan = viasAfterInsert(target, slot, place)
let assignment: Awaited<ReturnType<typeof tripActions.assignPlaceToDay>>
try {
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
toast.success(t('trip.toast.assignedToDay'))
if (plan) await roadtripVias.reanchor(target, plan)
updateRouteForDay(target)
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
}, [capturedTarget])
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return false
}
if (isTourPlace(placeId)) await reloadTourPlaceIds()
toast.success(t('trip.toast.assignedToDay'))
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
if (isTourPlace(placeId)) await reloadTourPlaceIds()
}, [capturedTarget], [placeId])
}
if (plan) {
try { await roadtripVias.reanchor(target, plan) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
updateRouteForDay(target)
return true
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
/**
* Moves a stop from the day list onto another day, at a row of that day or at its
@@ -2525,6 +2571,7 @@ export function useTripPlanner() {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId)
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
// Worked out before the delete, while the day still has the stop the vias
// were measured against. `after_order_index` is a POSITION, so taking a stop
@@ -2540,6 +2587,7 @@ export function useTripPlanner() {
: reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length)
try {
await tripActions.removeAssignment(tripId, dayId, assignmentId)
if (removedTour) await reloadTourPlaceIds()
if (plan) await roadtripVias.reanchor(dayId, plan)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
@@ -2547,15 +2595,18 @@ export function useTripPlanner() {
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
}, [capturedDayId])
if (removedTour) await reloadTourPlaceIds()
}, [capturedDayId], [capturedPlaceId])
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf])
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace])
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || []
const prevIds = assignmentsBefore
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))]
// The rail counts anchors over routable stops only, so the plan is built in
// that space. A drag here hands a whole new ordering rather than one move,
// and any permutation is possible — so the anchors follow the stop they were
@@ -2576,7 +2627,7 @@ export function useTripPlanner() {
const capturedPrevIds = prevIds
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}, [capturedDayId])
}, [capturedDayId], placeIdsBefore)
})
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
@@ -2619,7 +2670,8 @@ export function useTripPlanner() {
forgetDay(dayId)
loadAccommodations()
updateRouteForDay(useTripStore.getState().selectedDayId)
}, [loadAccommodations, updateRouteForDay, forgetDay])
void reloadTourPlaceIds()
}, [loadAccommodations, updateRouteForDay, forgetDay, reloadTourPlaceIds])
const dayDelete = useDayDelete({
tripId, trip, days, places: allPlaces, reservations, accommodations: tripAccommodations,
canEditDays: can('day_edit', trip), t, locale, toast, onDeleted: afterDayDeleted,
@@ -2627,6 +2679,7 @@ export function useTripPlanner() {
const dayClear = useDayClear({
tripId, days, canEditDays: can('day_edit', trip), t, locale, toast, roadtripVias, updateRouteForDay, pushUndo,
onToursChanged: reloadTourPlaceIds,
})
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
@@ -2890,6 +2943,9 @@ export function useTripPlanner() {
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
const selectedTour = toursEnabled && selectedPlaceId
? tours.find(tour => tour.place_id === selectedPlaceId) ?? null
: null
// The stops the inspector speaks for. A booked night at a day's edge stands on the
// hotel's place without being a stop of the day, so it is left out: counted, the hotel's
// own stop lost its stay and its day end to a second match.
@@ -2959,6 +3015,8 @@ export function useTripPlanner() {
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax,
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour, selectedTour,
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
stayPickerDayId, setStayPickerDayId,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
@@ -2999,6 +3057,7 @@ export function useTripPlanner() {
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
isTourPlace, deletePlaceIsTour, deletePlacesIncludeTours,
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourListItem } from '@trek/shared'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
import { useTripPlanner } from './useTripPlanner'
export function useTripPlannerPage() {
const planner = useTripPlanner()
const {
activeTab,
can,
enabledAddons,
isMobile,
places,
reloadTourPlaceIds,
setSelectedPlaceId,
t,
toast,
trip,
tripActions,
tripId,
upsertTour,
} = planner
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const tourPlanner = useTourPlanner({
tripId,
canEdit: canPlaceEdit,
canAssign: canDayEdit,
active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
onSaved: async result => {
upsertTour(result.tour)
toast.success(t('tours.planner.saved'))
await tripActions.loadTrip(tripId)
void reloadTourPlaceIds()
},
})
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState<Bbox | null>(lastPlanMapViewportRef.current)
useEffect(() => {
if (activeTab === 'tour-planner') setPlannerViewportSnapshot(lastPlanMapViewportRef.current)
}, [activeTab, tourPlanner.newDraftGeneration])
const plannerFallbackFocusPoints = useMemo<[number, number][]>(() => {
const bounds = plannerViewportSnapshot
if (bounds) {
const latitudeSpan = bounds.north - bounds.south
const longitudeSpan = bounds.east - bounds.west
if (latitudeSpan > 0 && latitudeSpan < 160 && longitudeSpan > 0 && longitudeSpan < 340) {
return [[bounds.south, bounds.west], [bounds.north, bounds.east]]
}
}
return places
.filter(place => Number.isFinite(place.lat) && Number.isFinite(place.lng))
.map(place => [place.lat, place.lng] as [number, number])
}, [places, plannerViewportSnapshot])
const focusPoints = useMemo<[number, number][]>(() => {
if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? []
if ((tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') && tourPlanner.waypoints.length) {
return tourPlanner.waypoints.map(point => [point.lat, point.lng])
}
return plannerFallbackFocusPoints
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
const route = useMemo<[number, number][][] | null>(() => {
if (tourPlanner.mode.type === 'view-gpx') {
const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates
return coordinates?.length ? [coordinates] : null
}
if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') {
return tourPlanner.route ? [tourPlanner.route] : null
}
return null
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route])
const { closeGpxTour } = tourPlanner
useEffect(() => {
if (activeTab !== 'tour-planner') closeGpxTour()
}, [activeTab, closeGpxTour])
const poi = usePoiExplore()
const poiViewportChangeRef = useRef(poi.onViewportChange)
poiViewportChangeRef.current = poi.onViewportChange
const captureViewport = useCallback((bounds: Bbox) => {
lastPlanMapViewportRef.current = bounds
poiViewportChangeRef.current(bounds)
}, [])
const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => {
tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null
setSelectedPlaceId(tour?.place_id ?? null)
}
return {
planner,
poi,
permissions: { canPlaceEdit, canDayEdit },
tourPlanner,
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints, captureViewport },
}
}
+2 -2
View File
@@ -80,7 +80,7 @@ export const placeRepo = {
return result
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
async delete(tripId: number | string, id: number | string): Promise<{ success?: boolean; tourPlaceIds?: number[] }> {
if (isEffectivelyOffline()) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
@@ -102,7 +102,7 @@ export const placeRepo = {
return result
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
async deleteMany(tripId: number | string, ids: number[]): Promise<{ deleted?: number[]; count?: number; tourPlaceIds?: number[] }> {
if (isEffectivelyOffline()) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
+7 -5
View File
@@ -15,8 +15,8 @@ export interface PlacesSlice {
uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise<Place>
setPlaceImageFromFile: (tripId: number | string, placeId: number, fileId: number) => Promise<Place>
ratePlace: (tripId: number | string, placeId: number, rating: number | null) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
deletePlace: (tripId: number | string, placeId: number) => Promise<{ success?: boolean; tourPlaceIds?: number[] }>
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<{ deleted?: number[]; count?: number; tourPlaceIds?: number[] }>
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
}
@@ -135,7 +135,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
deletePlace: async (tripId, placeId) => {
try {
await placeRepo.delete(tripId, placeId)
const result = await placeRepo.delete(tripId, placeId)
set(state => {
const updatedAssignments = { ...state.assignments }
let changed = false
@@ -150,15 +150,16 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
...(changed ? { assignments: updatedAssignments } : {}),
}
})
return result
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
}
},
deletePlacesMany: async (tripId, placeIds) => {
if (placeIds.length === 0) return
if (placeIds.length === 0) return { deleted: [], count: 0, tourPlaceIds: [] }
try {
await placeRepo.deleteMany(tripId, placeIds)
const result = await placeRepo.deleteMany(tripId, placeIds)
const idSet = new Set(placeIds)
set(state => {
const updatedAssignments = { ...state.assignments }
@@ -174,6 +175,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
...(changed ? { assignments: updatedAssignments } : {}),
}
})
return result
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
}
+16 -1
View File
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest'
import { isVectorStyle, resolveBasemap, resolveTileUrl } from './tileUrl'
import { OFM_POSITRON, attributionForTile, OFM_ATTRIBUTION } from '../constants/mapDefaults'
import {
OFM_POSITRON,
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
attributionForTile,
OFM_ATTRIBUTION,
} from '../constants/mapDefaults'
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const CUSTOM = 'https://tiles.example.test/{z}/{x}/{y}.png'
@@ -68,4 +75,12 @@ describe('attributionForTile', () => {
expect(attributionForTile(CUSTOM)).toMatch(/OpenStreetMap/)
expect(attributionForTile(null)).toMatch(/OpenStreetMap/)
})
it('FE-UTIL-BASEMAP-010: OpenTopoMap carries its required data and style credits', () => {
expect(OPENTOPOMAP_TILE_URL).toBe('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenStreetMap')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('SRTM')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenTopoMap')
expect(OPENTOPOMAP_TILE_MAXZOOM).toBe(17)
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { analyzeRouteGeometry, focusRouteProfileAtDistance, parseRenderableRouteGeometry } from './routeGeometry'
describe('analyzeRouteGeometry', () => {
it('shares a validated rendering parser that retains valid elevation values', () => {
expect(parseRenderableRouteGeometry('[[48,11,512.125],[48.01,11.02,520.75]]'))
.toEqual([[48, 11, 512.125], [48.01, 11.02, 520.75]])
})
it('derives distance, elevation extrema, gain, loss and cumulative-distance samples together', () => {
const analysis = analyzeRouteGeometry(JSON.stringify([
[0, 0, 100],
[0, 0.01, 130],
[0, 0.03, 110],
]))
expect(analysis).not.toBeNull()
expect(analysis!.routeCoordinates).toEqual([[0, 0], [0, 0.01], [0, 0.03]])
expect(analysis!.distanceKm).toBeCloseTo(3.3358, 3)
expect(analysis!.minEle).toBe(100)
expect(analysis!.maxEle).toBe(130)
expect(analysis!.gain).toBe(30)
expect(analysis!.loss).toBe(20)
expect(analysis!.distanceIndexedProfileSamples).toHaveLength(3)
expect(analysis!.distanceIndexedProfileSamples[1]).toMatchObject({ lat: 0, lng: 0.01 })
expect(analysis!.distanceIndexedProfileSamples[1].distanceMeters).toBeCloseTo(1111.95, 1)
expect(analysis!.distanceIndexedProfileSamples[2].distanceMeters).toBeCloseTo(3335.85, 1)
})
it('maps route endpoints and interpolates focus between prepared samples', () => {
const samples = [
{ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20 },
{ distanceMeters: 100, elevationMeters: 200, lat: 12, lng: 24 },
{ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28 },
]
expect(focusRouteProfileAtDistance(samples, -20)).toMatchObject({ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20, sampleIndex: 0 })
expect(focusRouteProfileAtDistance(samples, 150)).toMatchObject({ distanceMeters: 150, elevationMeters: 225, lat: 12.5, lng: 25, sampleIndex: 1 })
expect(focusRouteProfileAtDistance(samples, 500)).toMatchObject({ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28, sampleIndex: 2 })
})
it('does not create geographic focus for samples without coordinates', () => {
expect(focusRouteProfileAtDistance([
{ distanceMeters: 0, elevationMeters: 100 },
{ distanceMeters: 10, elevationMeters: 110 },
], 5)).toBeNull()
})
it('keeps distance but omits all elevation outputs when any point lacks elevation', () => {
const analysis = analyzeRouteGeometry(JSON.stringify([
[48, 11, 500],
[48.01, 11.01],
]))
expect(analysis?.distanceKm).toBeGreaterThan(0)
expect(analysis?.minEle).toBeNull()
expect(analysis?.maxEle).toBeNull()
expect(analysis?.gain).toBeNull()
expect(analysis?.loss).toBeNull()
expect(analysis?.distanceIndexedProfileSamples).toEqual([])
})
it.each([undefined, null, '', 'not json', '[]', '[[0,0]]', '[[0,"bad"],[1,1]]'])('returns null for unusable geometry %s', geometry => {
expect(analyzeRouteGeometry(geometry)).toBeNull()
})
})
+140
View File
@@ -0,0 +1,140 @@
export interface DistanceIndexedProfileSample {
distanceMeters: number
elevationMeters: number
lat?: number
lng?: number
}
export interface RouteProfileFocus {
distanceMeters: number
elevationMeters: number | null
lat: number
lng: number
sampleIndex: number
}
export interface RouteGeometryAnalysis {
routeCoordinates: [number, number][]
distanceKm: number
minEle: number | null
maxEle: number | null
gain: number | null
loss: number | null
distanceIndexedProfileSamples: DistanceIndexedProfileSample[]
}
export type RouteGeometryPoint = [number, number] | [number, number, number]
function isRoutePoint(value: unknown): value is RouteGeometryPoint {
return Array.isArray(value) && (value.length === 2 || value.length === 3) &&
Number.isFinite(value[0]) && Number.isFinite(value[1]) &&
Math.abs(value[0]) <= 90 && Math.abs(value[1]) <= 180 &&
(value.length === 2 || Number.isFinite(value[2]))
}
/** Parses and filters route coordinates for analysis and map rendering. */
export function parseRenderableRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryPoint[] | null {
if (!routeGeometry) return null
let parsed: unknown
try {
parsed = JSON.parse(routeGeometry)
} catch {
return null
}
if (!Array.isArray(parsed)) return null
const points = parsed.filter(isRoutePoint)
return points.length >= 2 ? points : null
}
function haversineMeters(a: RouteGeometryPoint, b: RouteGeometryPoint): number {
const earthRadiusMeters = 6_371_000
const toRadians = (degrees: number) => degrees * Math.PI / 180
const [lat1, lng1] = a
const [lat2, lng2] = b
const deltaLat = toRadians(lat2 - lat1)
const deltaLng = toRadians(lng2 - lng1)
const chord = Math.sin(deltaLat / 2) ** 2 +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) ** 2
const boundedChord = Math.min(1, Math.max(0, chord))
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(boundedChord), Math.sqrt(1 - boundedChord))
}
/** Interpolates a focus point from prepared samples in O(log n). */
export function focusRouteProfileAtDistance(
samples: readonly DistanceIndexedProfileSample[],
requestedDistanceMeters: number,
): RouteProfileFocus | null {
if (samples.length < 2 || !Number.isFinite(requestedDistanceMeters)) return null
const first = samples[0]
const last = samples[samples.length - 1]
if (![first.lat, first.lng, last.lat, last.lng].every(Number.isFinite)) return null
const distanceMeters = Math.max(first.distanceMeters, Math.min(last.distanceMeters, requestedDistanceMeters))
let low = 0
let high = samples.length - 1
while (low + 1 < high) {
const middle = (low + high) >>> 1
if (samples[middle].distanceMeters < distanceMeters) low = middle
else high = middle
}
if (distanceMeters <= first.distanceMeters) low = high = 0
else if (distanceMeters >= last.distanceMeters) low = high = samples.length - 1
const start = samples[low]
const end = samples[high]
if (![start.lat, start.lng, end.lat, end.lng].every(Number.isFinite)) return null
const span = end.distanceMeters - start.distanceMeters
const ratio = low === high || span <= 0 ? 0 : (distanceMeters - start.distanceMeters) / span
const interpolate = (a: number | undefined, b: number | undefined): number | null =>
Number.isFinite(a) && Number.isFinite(b) ? a! + (b! - a!) * ratio : null
return {
distanceMeters,
elevationMeters: interpolate(start.elevationMeters, end.elevationMeters),
lat: interpolate(start.lat, end.lat)!,
lng: interpolate(start.lng, end.lng)!,
sampleIndex: ratio < 0.5 ? low : high,
}
}
/**
* Derives all route metrics and chart samples from the current place geometry.
* Persisted tour metrics are only a list/display cache; detail views use this
* live analysis so their values and profile can never drift apart.
*/
export function analyzeRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryAnalysis | null {
const points = parseRenderableRouteGeometry(routeGeometry)
if (!points) return null
const hasElevation = points.every(point => point.length >= 3 && Number.isFinite(point[2]))
let distanceMeters = 0
let minEle = hasElevation ? points[0][2]! : null
let maxEle = minEle
let gain = hasElevation ? 0 : null
let loss = hasElevation ? 0 : null
const distanceIndexedProfileSamples: DistanceIndexedProfileSample[] = hasElevation
? [{ distanceMeters: 0, elevationMeters: points[0][2]!, lat: points[0][0], lng: points[0][1] }]
: []
for (let index = 1; index < points.length; index++) {
distanceMeters += haversineMeters(points[index - 1], points[index])
if (!hasElevation) continue
const elevation = points[index][2]!
minEle = Math.min(minEle!, elevation)
maxEle = Math.max(maxEle!, elevation)
const delta = elevation - points[index - 1][2]!
if (delta > 0) gain! += delta
else loss! += Math.abs(delta)
distanceIndexedProfileSamples.push({ distanceMeters, elevationMeters: elevation, lat: points[index][0], lng: points[index][1] })
}
return {
routeCoordinates: points.map(([lat, lng]) => [lat, lng]),
distanceKm: distanceMeters / 1000,
minEle,
maxEle,
gain,
loss,
distanceIndexedProfileSamples,
}
}
+1
View File
@@ -73,6 +73,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
tripActions: buildTripActions(),
can: vi.fn(() => true),
canUploadFiles: true,
isTourPlace: vi.fn(() => false),
pushUndo: vi.fn(),
undo: vi.fn(),
@@ -71,11 +71,22 @@ describe('useTripWebSocket', () => {
expect(registeredFunctions).toContain(handler);
});
it('FE-HOOK-WS-006b: collab file sync listener is also registered (second addListener call)', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
// Two listeners registered: handleRemoteEvent + collabFileSync
expect((wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
it('FE-HOOK-WS-006b: collab file sync and Tours invalidation listeners are registered', () => {
const onToursChanged = vi.fn();
const { unmount } = renderHook(() => useTripWebSocket(42, onToursChanged));
const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
// Listener order: handleRemoteEvent, collabFileSync, Tours invalidation.
expect(addListenerCalls).toHaveLength(3);
const toursInvalidation = addListenerCalls[2]?.[0];
expect(toursInvalidation).toBeTypeOf('function');
act(() => {
toursInvalidation({ type: 'tours:changed', tripId: 42, placeIds: [101, 102] });
toursInvalidation({ type: 'tours:changed', tripId: 43, placeIds: [201] });
});
expect(onToursChanged).toHaveBeenCalledExactlyOnceWith({ placeIds: [101, 102] });
unmount();
expect(wsMock.removeListener).toHaveBeenCalledWith(toursInvalidation);
});
it('FE-HOOK-WS-006c: collab file sync listener reacts to collab:note:deleted events', () => {
@@ -6,7 +6,7 @@ import { useSaveToCollectionStore } from '../../../../src/store/saveToCollection
import type { Day, Place } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-BRACT-001 to FE-MOB-BRACT-013
// This sheet reads its labels from the real TranslationProvider, so the
@@ -107,14 +107,57 @@ describe('MBrowseActionsSheet', () => {
expect(screen.getByRole('button', { name: /Tokyo Bay/ })).toBeInTheDocument()
})
it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', () => {
it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', async () => {
const { planner, shell } = setup({}, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } })
expect(screen.getByRole('button', { name: /Add to a day\?/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Thu, Apr 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2)
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
})
it('waits for the assignment handler before closing the mobile sheet', async () => {
let resolveAssignment!: () => void
const assignment = new Promise<void>(resolve => { resolveAssignment = resolve })
const { planner, shell } = setup({ handleAssignToDay: vi.fn(() => assignment.then(() => true)) }, {
sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } },
})
fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ }))
expect(shell.closeSheet).not.toHaveBeenCalled()
expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2)
await act(async () => { resolveAssignment(); await assignment })
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce())
})
it('keeps the mobile sheet open when the assignment handler reports failure', async () => {
const { planner, shell } = setup({ handleAssignToDay: vi.fn().mockResolvedValue(false) }, {
sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } },
})
fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ }))
await waitFor(() => expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2))
expect(shell.closeSheet).not.toHaveBeenCalled()
})
it('offers only view and day attachment for a dormant Tour in the generic Places pool', () => {
enableCollections()
const tourPlace = { ...PLACE, tour_place_id: PLACE.id }
const { planner } = setup({
places: [tourPlace],
can: vi.fn(() => true),
isTourPlace: vi.fn((placeId: number) => placeId === PLACE.id),
})
expect(screen.getByRole('button', { name: /View details/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Delete/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Save to Collection/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Add to a day/ })).toBeInTheDocument()
expect(planner.handleDeletePlace).not.toHaveBeenCalled()
})
it('FE-MOB-BRACT-011: numbers an untitled, undated day by its position when day_number is missing or zero', () => {
@@ -279,6 +279,16 @@ describe('MPlaceEditSheet', () => {
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
})
it('requires explicit permanent confirmation when a dormant Tour is deleted from the Place editor', async () => {
const tour = { ...EDITED, tour_place_id: EDITED.id } as Place
const { planner } = setup({ editingPlace: tour, isTourPlace: vi.fn(() => true) })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(planner.toast.warning).toHaveBeenCalledWith('tours.delete.confirmBody')
fireEvent.click(screen.getByRole('button', { name: 'tours.delete.confirmAction' }))
await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1))
})
it('FE-MOB-PLEDIT-022: cancelling an armed delete un-stages the id', () => {
const { planner } = setup({ editingPlace: EDITED })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
@@ -104,6 +104,7 @@ function makePlanner(overrides: Record<string, unknown> = {}) {
addFile: vi.fn().mockResolvedValue(undefined),
ratePlace: vi.fn().mockResolvedValue(undefined),
},
isTourPlace: vi.fn<TripPlanner['isTourPlace']>(() => false),
can: vi.fn(() => true),
reservations: [] as unknown[],
TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
@@ -35,6 +35,7 @@ function place(over: Partial<Place>): Place {
const LOUVRE = place({ id: 1, name: 'Louvre', address: 'Rue de Rivoli', category_id: 1 })
const EIFFEL = place({ id: 2, name: 'Eiffel Tower', description: 'Iron lady' })
const SEINE = place({ id: 3, name: 'Seine Track', category_id: 2, route_geometry: 'abc', route_color: '#ff0000' })
const TOUR = place({ id: 4, name: 'Ridge walk', tour_place_id: 4 })
const PLACES = [LOUVRE, EIFFEL, SEINE]
@@ -290,6 +291,23 @@ describe('MPlacesBrowser', () => {
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
})
it('routes a Tour row to read-only detail instead of mobile Place bulk management', () => {
const planner = makePlanner({
places: [LOUVRE, TOUR],
isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id),
})
renderBrowser(planner)
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(row('Ridge walk'))
expect(planner.handlePlaceClick).toHaveBeenCalledWith(TOUR.id)
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'common.deselectAll' })).toBeInTheDocument()
})
it('FE-MOB-PBROW-020b: a failed bulk delete keeps the selection for a retry', async () => {
const planner = makePlanner()
;(planner.confirmDeletePlaces as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'))
@@ -358,6 +358,33 @@ describe('MPlanTimeline', () => {
})
})
describe('Tours read-only timeline integration', () => {
const tour = { ...MUSEUM, id: 91, place_id: 901,
place: { ...MUSEUM.place, id: 901, name: 'Assigned Tour' }, tour_place_id: 901 } as Assignment
const item: MergedItem = { type: 'place', sortKey: 0, data: tour }
const row: PlanRow = { key: 'pl-91', kind: 'place', item, assignment: tour, linkedReservations: [], invalidTour: true }
it('shows one localized invalid-endpoint warning and selects the Tour through the shared Place path', () => {
const { planner } = renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: true })
expect(screen.getAllByText('tours.dayRoute.endpointUnknown')).toHaveLength(1)
fireEvent.click(screen.getByText('Assigned Tour'))
expect(planner.handlePlaceClick).toHaveBeenCalledWith(901, 91)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText('tour-planner')).not.toBeInTheDocument()
})
it('does not show the warning for valid status or with Tours disabled', () => {
const validRow: PlanRow = { ...row, invalidTour: false }
const first = renderTimeline({ rows: [validRow], merged: [item], upNext: null }, { toursEnabled: true })
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
first.unmount()
renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false })
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
})
})
describe('per-segment travel mode', () => {
/** The timeline only offers the mode menu in edit mode, where many buttons exist. */
const renderEditing = () => renderTimeline({}, {}, { mode: 'edit' })
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { BedDouble, MapPin } from 'lucide-react'
import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types'
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
import type { TourListItem } from '@trek/shared'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { BudgetItem, Reservation, Trip } from '../../../../src/types'
import { useAuthStore } from '../../../../src/store/authStore'
@@ -187,7 +188,7 @@ const JOURNEY = {
} as unknown as Reservation
function renderHost(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner(plannerOverrides)
const planner = buildPlanner({ isTourPlace: vi.fn(() => false), ...plannerOverrides })
const shell = buildShell(shellOverrides)
render(<MTripSheets planner={planner} shell={shell} />)
return { planner, shell }
@@ -418,6 +419,44 @@ describe('MTripSheets', () => {
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1')
})
it.each([[false, false], [true, false], [false, true], [true, true]])(
'keeps the mobile Tour host View/Attach-only with place_edit=%s day_edit=%s', (placeEdit, dayEdit) => {
const mobilePlace = {
id: 42, trip_id: 1, name: 'Mobile ridge', lat: 48, lng: 11,
route_geometry: '[[48,11,500],[48.01,11.01,510]]', route_color: '#ff0000',
} as never
const mobileTour = {
place_id: 42, name: 'Mobile ridge', tour_type: 'hike', distance: 1,
elevation_gain: 10, elevation_loss: 0, duration: null, difficulty: null,
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
max_hiking_difficulty: 2, planned: false, caution: false,
} as TourListItem
const readOnlyFile = {
id: 19, trip_id: 1, place_id: 42, filename: 'ridge.gpx', original_name: 'ridge.gpx',
file_size: 32, mime_type: 'application/gpx+xml', url: '/api/files/19', created_at: '2026-01-01',
}
const can = vi.fn((permission: string) => permission === 'place_edit' ? placeEdit : permission === 'day_edit' && dayEdit)
renderHost({
selectedPlace: mobilePlace,
selectedTour: mobileTour,
selectedDayId: 7,
days: [{ id: 7, trip_id: 1, day_number: 1, title: 'Day one' } as never],
files: [readOnlyFile as never],
can: can as TripPlanner['can'],
canUploadFiles: true,
})
expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Edit track colour' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '1 files' }))
expect(screen.getByText('ridge.gpx')).toBeInTheDocument()
expect(Boolean(screen.queryByRole('button', { name: /Add to day/i }))).toBe(dayEdit)
},
)
it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => {
const { planner } = renderHost({ deletePlaceId: 101 })
expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete')
@@ -428,6 +467,17 @@ describe('MTripSheets', () => {
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
})
it('shows permanent Tour deletion language in the mobile confirmation', () => {
const { planner } = renderHost({ deletePlaceId: 101, isTourPlace: vi.fn(() => true) })
const confirm = screen.getByTestId('stub-confirm')
expect(confirm).toHaveAttribute('data-confirm', 'tours.delete.confirmAction')
expect(confirm).toHaveTextContent('tours.delete.confirmBody')
expect(confirm).not.toHaveTextContent('Undo')
fireEvent.click(screen.getByText('confirm delete'))
expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHOST-029: a night booked at the place is said before the yes, and only then', () => {
// The server takes the night down with the place, and the booking and the
// expense with the night. The planner builds the sentence; the sheet has to
@@ -9,7 +9,7 @@ import { getDisplayTimeForDay, getMergedItems, getTransportForDay, type MergedIt
import { buildDayRouteRuns, hotelBookendOf, type DayRoutePoint } from '../../../../src/components/Map/dayRoutePlan'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-060
@@ -56,6 +56,23 @@ const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.or
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
function tourAssignment(id: number, order: number, geometry: [number, number][], currentGeometry = JSON.stringify(geometry)): Assignment {
const [lat, lng] = geometry[0]
const tourPlace = buildPlace({ id: id + 100, name: `Tour ${id}`, lat, lng, route_geometry: currentGeometry })
return buildAssignment({ id, day_id: 2, order_index: order, place_id: tourPlace.id, place: tourPlace,
tour_place_id: tourPlace.id, tour_route_geometry: JSON.stringify(geometry) })
}
function tourRows(assignments: Assignment[], routeSegments: RouteSegment[], toursEnabled = true) {
return buildPlanRows({
merged: assignments.map(placeItem), reservations: [], routeSegments, dayId: 2, toursEnabled,
places: assignments.flatMap(assignment => assignment.place ? [assignment.place as Place] : []),
})
}
const connectors = (rows: ReturnType<typeof buildPlanRows>) =>
rows.flatMap(row => row.kind === 'conn' ? [row.seg] : [])
function accommodation(overrides: Partial<Accommodation>): Accommodation {
return {
id: 1,
@@ -269,6 +286,100 @@ describe('planTimelineModel — buildPlanRows', () => {
// Only the first Museum → Park hop finds the single matching leg.
expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1)
})
it('uses only Tour boundary connectors and leaves saved geometry unrouted', () => {
const before = assignment(31, 0, place(301, 'Before', 48.1, 11.1))
const tour = tourAssignment(32, 1, [[48.103, 11.103], [48.11, 11.11], [48.12, 11.12]])
const after = assignment(33, 2, place(303, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.103, 11.103])
const outgoing = seg([48.12, 11.12], [48.13, 11.13])
const automaticTourRoute = seg([48.103, 11.103], [48.12, 11.12])
const rows = tourRows([before, tour, after], [incoming, outgoing, automaticTourRoute])
expect(connectors(rows)).toEqual([incoming, outgoing])
expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place'])
expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, tour.id])
})
it('uses the current Place projection when the saved assignment geometry has not refreshed', () => {
const before = assignment(35, 0, place(351, 'Before', 48.1, 11.1))
const tour = tourAssignment(36, 1, [[48.103, 11.103], [48.12, 11.12]], JSON.stringify([[48.105, 11.105], [48.125, 11.125]]))
const after = assignment(37, 2, place(353, 'After', 48.13, 11.13))
const currentIncoming = seg([48.1, 11.1], [48.105, 11.105])
const currentOutgoing = seg([48.125, 11.125], [48.13, 11.13])
const staleIncoming = seg([48.1, 11.1], [48.103, 11.103])
expect(connectors(tourRows([before, tour, after], [currentIncoming, currentOutgoing, staleIncoming])))
.toEqual([currentIncoming, currentOutgoing])
})
it('connects first, last and consecutive Tours only at valid boundaries', () => {
const before = assignment(41, 0, place(401, 'Before', 48.1, 11.1))
const first = tourAssignment(42, 1, [[48.11, 11.11], [48.12, 11.12]])
const second = tourAssignment(43, 2, [[48.13, 11.13], [48.14, 11.14]])
const after = assignment(44, 3, place(404, 'After', 48.15, 11.15))
const segments = [
seg([48.1, 11.1], [48.11, 11.11]),
seg([48.12, 11.12], [48.13, 11.13]),
seg([48.14, 11.14], [48.15, 11.15]),
]
const firstToAfter = seg([48.12, 11.12], [48.15, 11.15])
expect(connectors(tourRows([first, after], [firstToAfter]))).toEqual([firstToAfter])
expect(connectors(tourRows([before, first], [segments[0]]))).toEqual([segments[0]])
const rows = tourRows([before, first, second, after], segments)
expect(connectors(rows)).toEqual(segments)
expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, first.id, second.id])
})
it('connects a loop Tour at its shared boundary without routing through its geometry', () => {
const before = assignment(51, 0, place(501, 'Before', 48.1, 11.1))
const loop = tourAssignment(52, 1, [[48.11, 11.11], [48.12, 11.12], [48.11, 11.11]])
const after = assignment(53, 2, place(503, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.11, 11.11])
const outgoing = seg([48.11, 11.11], [48.13, 11.13])
const inside = seg([48.11, 11.11], [48.11, 11.11])
expect(connectors(tourRows([before, loop, after], [incoming, outgoing, inside]))).toEqual([incoming, outgoing])
})
it('keeps excluded Tours visible and bridges surrounding Places as the central route builder does', () => {
const before = assignment(61, 0, place(601, 'Before', 48.1, 11.1))
const tour = { ...tourAssignment(62, 1, [[48.11, 11.11], [48.12, 11.12]]), route_excluded: true }
const after = assignment(63, 2, place(603, 'After', 48.13, 11.13))
const bridge = seg([48.1, 11.1], [48.13, 11.13])
const rows = tourRows([before, tour, after], [bridge])
expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'place'])
expect(connectors(rows)).toEqual([bridge])
expect(rows.some(row => row.kind === 'place' && row.assignment.id === tour.id)).toBe(true)
})
it('exposes invalid-Tour warning state and never invents an outgoing endpoint', () => {
const before = assignment(71, 0, place(701, 'Before', 48.1, 11.1))
const tour = tourAssignment(72, 1, [[48.11, 11.11], [48.12, 11.12]], 'not valid geometry')
const after = assignment(73, 2, place(703, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.11, 11.11])
const falseOutgoing = seg([48.11, 11.11], [48.13, 11.13])
const rows = tourRows([before, tour, after], [incoming, falseOutgoing])
expect(connectors(rows)).toEqual([incoming])
expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true })
const noPlaceAnchor = { ...tour, place: buildPlace({ id: tour.place!.id, name: 'Tour without anchor', lat: null, lng: null, route_geometry: 'not valid geometry' }) }
const noAnchorRows = tourRows([before, noPlaceAnchor, after], [seg([48.1, 11.1], [48.13, 11.13])])
expect(connectors(noAnchorRows)).toEqual([])
expect(noAnchorRows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true })
})
it('keeps Tours-off routing on the legacy single Place anchor', () => {
const before = assignment(81, 0, place(801, 'Before', 48.1, 11.1))
const tour = tourAssignment(82, 1, [[48.11, 11.11], [48.12, 11.12]])
const after = assignment(83, 2, place(803, 'After', 48.13, 11.13))
const rows = tourRows([before, tour, after], [
seg([48.1, 11.1], [48.11, 11.11]),
seg([48.11, 11.11], [48.13, 11.13]),
], false)
expect(connectors(rows).map(item => [item.from, item.to])).toEqual([
[[48.1, 11.1], [48.11, 11.11]],
[[48.11, 11.11], [48.13, 11.13]],
])
expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: false })
})
})
describe('planTimelineModel — hotel chips and legs', () => {

Some files were not shown because too many files have changed in this diff Show More