Merge dev into fix/prettier

Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
Maurice
2026-10-07 15:43:13 +02:00
242 changed files with 22952 additions and 731 deletions
@@ -1,7 +1,7 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-006
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { MAP_ROUND_CONTROL_SIZE, MapLayerSwitcher } from './MapLayerSwitcher';
import { MAP_ROUND_CONTROL_SIZE, MapLayerSwitcher, TourMapLayerSwitcher } from './MapLayerSwitcher';
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -41,7 +41,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;
+67 -14
View File
@@ -1,9 +1,22 @@
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,19 +41,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
@@ -78,3 +79,55 @@ 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 { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import { buildPlace } from '../../../tests/helpers/factories';
import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { AMAP_ROAD } from '../../constants/mapDefaults';
import { useSettingsStore } from '../../store/settingsStore';
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({
@@ -141,6 +143,73 @@ 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]);
+314 -5
View File
@@ -6,14 +6,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildReservation } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults';
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults';
import * as photoService from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { renderIconMarkup } from '../../utils/iconMarkup';
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
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(),
@@ -99,12 +101,18 @@ 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}>
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) => {
@@ -166,7 +174,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,
@@ -220,6 +235,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)
@@ -256,6 +275,78 @@ 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 }),
@@ -404,6 +495,224 @@ 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('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
const route: [number, number][][] = [
[
[48, 11],
[48.2, 11.3],
],
];
const { rerender } = render(
<MapView places={[]} route={null} followSelection={false} focusKey={1} focusPoints={[]} />
);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
rerender(
<MapView
places={[]}
route={route}
followSelection={false}
focusKey={1}
focusPoints={[
[48, 11],
[48.2, 11.3],
]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
mapMock.fitBounds.mockClear();
rerender(
<MapView
places={[]}
route={[
[
[48, 11],
[48.2, 11.3],
[48.4, 11.6],
],
]}
followSelection={false}
focusKey={1}
focusPoints={[
[48, 11],
[48.2, 11.3],
[48.4, 11.6],
]}
/>
);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
const mapContainer = screen.getByTestId('map-container');
const retainedCenter = { lat: 35.2, lng: 135.8 };
mapMock.getCenter.mockReturnValue(retainedCenter);
mapMock.getZoom.mockReturnValue(12);
mapMock.setView([retainedCenter.lat, retainedCenter.lng], 12);
mapMock.fitBounds.mockClear();
mapMock.setView.mockClear();
mapMock.panTo.mockClear();
rerender(<MapView places={[]} followSelection={false} focusKey={2} focusPoints={[]} />);
expect(screen.getByTestId('map-container')).toBe(mapContainer);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
expect(mapMock.setView).not.toHaveBeenCalled();
expect(mapMock.panTo).not.toHaveBeenCalled();
expect(mapMock.getCenter()).toEqual(retainedCenter);
expect(mapMock.getZoom()).toBe(12);
rerender(
<MapView
places={[]}
followSelection={false}
focusKey={2}
focusPoints={[
[48, 11],
[48.5, 11.8],
]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
});
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
+126 -21
View File
@@ -12,6 +12,9 @@ import {
DEFAULT_MAP_ZOOM,
MAP_MAX_ZOOM,
OFM_POSITRON,
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
SATELLITE_TILE_MAXZOOM,
SATELLITE_TILE_URL,
} from '../../constants/mapDefaults';
@@ -21,6 +24,7 @@ import type { Day, Place, Reservation, RouteVia } from '../../types';
import { renderIconMarkup } from '../../utils/iconMarkup';
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { visibleRouteReservations } from '../../utils/reservationRoutes';
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
import { safeHexColor } from '../../utils/safeColor';
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl';
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors';
@@ -33,7 +37,12 @@ import type { DayBoundaryControls } from './dayBoundaryDrag';
import { crsForBasemap } from './gcj02Crs';
import HazardLayers from './HazardLayers';
import type { MapHoverInfo } from './mapHover';
import { MAP_LAYER_SWITCHER_INSET, MapLayerSwitcher } from './MapLayerSwitcher';
import {
MAP_LAYER_SWITCHER_INSET,
MapLayerSwitcher,
TourMapLayerSwitcher,
type TourBaseLayer,
} from './MapLayerSwitcher';
import { MapLockPill } from './MapLockPill';
import { PluginMapLayers } from './MapPluginLayers';
import { PluginMapMarkers } from './MapPluginMarkers';
@@ -423,6 +432,22 @@ function ViewportController({
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;
@@ -507,6 +532,8 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][];
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number;
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean;
/**
@@ -536,6 +563,7 @@ function BoundsController({
paddingOpts,
framedOnMount = false,
focusPoints,
focusKey,
fitPadding,
follow = true,
}: BoundsControllerProps) {
@@ -543,6 +571,8 @@ function BoundsController({
const prevFitKey = useRef(-1);
const awaitingRoute = useRef(false);
const fitRan = useRef(false);
const didInitialFocus = useRef(false);
const prevFocusKey = useRef(focusKey);
const fitTo = useCallback(
(coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
@@ -610,10 +640,16 @@ function BoundsController({
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return;
if (focusKey !== undefined) {
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current;
prevFocusKey.current = focusKey;
if (!shouldFit) return;
didInitialFocus.current = true;
}
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false;
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts);
}, [focusPoints, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, focusKey, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
}
@@ -860,6 +896,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 = [],
@@ -887,6 +933,7 @@ export const MapView = memo(function MapView({
// the shape a caller without one gets.
tileUrl = OFM_POSITRON,
fitKey = 0,
focusKey,
dayOrderMap = {},
leftWidth = 0,
rightWidth = 0,
@@ -925,7 +972,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.
@@ -937,7 +996,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(
() =>
@@ -1013,6 +1072,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);
@@ -1218,14 +1281,10 @@ export const MapView = memo(function MapView({
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]
);
@@ -1315,9 +1374,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(() => {});
@@ -1346,8 +1402,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
@@ -1381,6 +1437,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} />
) : (
@@ -1397,6 +1465,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 : []}
@@ -1404,6 +1473,7 @@ export const MapView = memo(function MapView({
paddingOpts={paddingOpts}
framedOnMount={initialView.framed}
focusPoints={focusPoints}
focusKey={focusKey}
fitPadding={fitPadding}
follow={followSelection}
/>
@@ -1572,10 +1642,45 @@ 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
@@ -1651,10 +1756,6 @@ 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',
@@ -1668,7 +1769,11 @@ export const MapView = memo(function MapView({
}}
>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
{hasViewBaseLayer ? (
<TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
) : (
<MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />
)}
</div>
</div>
@@ -31,6 +31,7 @@ const glMap = vi.hoisted(() => ({
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
@@ -913,6 +914,153 @@ 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('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
loadOnAttach();
const { rerender } = render(
<MapViewGL
places={[]}
route={null}
followSelection={false}
focusKey={1}
focusPoints={[
[48, 11],
[48.2, 11.3],
]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
glMap.fitBounds.mockClear();
const mapConstructions = (maplibregl.Map as any).mock.calls.length;
const retainedCenter = { lng: 135.8, lat: 35.2 };
glMap.getCenter.mockReturnValue(retainedCenter);
glMap.getZoom.mockReturnValue(12);
rerender(<MapViewGL places={[]} followSelection={false} focusKey={2} focusPoints={[]} glProvider="maplibre-gl" />);
await act(async () => {});
expect(glMap.fitBounds).not.toHaveBeenCalled();
expect(glMap.flyTo).not.toHaveBeenCalled();
expect(glMap.jumpTo).not.toHaveBeenCalled();
expect(glMap.easeTo).not.toHaveBeenCalled();
expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions);
expect(glMap.getCenter()).toEqual(retainedCenter);
expect(glMap.getZoom()).toBe(12);
rerender(
<MapViewGL
places={[]}
followSelection={false}
focusKey={2}
focusPoints={[[35.01, 135.76]]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
});
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 = () =>
@@ -2089,6 +2237,53 @@ 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));
@@ -2107,6 +2302,77 @@ 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({
+141 -33
View File
@@ -5,6 +5,9 @@ import { pluginsApi, type PluginMapLayer, type PluginMapMarker } from '../../api
import {
DEFAULT_MAP_CENTER,
DEFAULT_MAP_ZOOM,
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
SATELLITE_TILE_ATTRIBUTION,
SATELLITE_TILE_MAXZOOM,
SATELLITE_TILE_URL,
@@ -20,6 +23,7 @@ import type { Day, Place, Reservation, RouteVia } from '../../types';
import { renderIconMarkup } from '../../utils/iconMarkup';
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport';
import { visibleRouteReservations } from '../../utils/reservationRoutes';
import { parseRenderableRouteGeometry, type RouteProfileFocus } from '../../utils/routeGeometry';
import { safeHexColor } from '../../utils/safeColor';
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors';
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
@@ -38,7 +42,13 @@ import {
wantsTerrain,
} from './mapboxSetup';
import type { MapHoverInfo } from './mapHover';
import { MAP_LAYER_SWITCHER_INSET, MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher';
import {
MAP_LAYER_SWITCHER_INSET,
MapLayerSwitcher,
TourMapLayerSwitcher,
type BaseLayer,
type TourBaseLayer,
} from './MapLayerSwitcher';
import { MapLockPill } from './MapLockPill';
import { draggedPoiId, makeMarkerDraggable, makePoiDraggable } from './markerDrag';
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook';
@@ -220,6 +230,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.
*
@@ -228,6 +245,8 @@ interface Props {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][];
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number;
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -351,23 +370,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.
@@ -376,9 +399,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
@@ -751,6 +774,7 @@ export function MapViewGL({
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
focusKey,
fitPadding,
clusterLoosely = false,
hazards,
@@ -778,6 +802,12 @@ export function MapViewGL({
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -791,7 +821,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(() => {
@@ -878,6 +910,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
@@ -888,6 +922,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
@@ -906,6 +942,68 @@ 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.
*
@@ -2389,24 +2487,19 @@ 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]) },
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,
},
];
} catch {
return [];
}
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
},
];
});
src.setData({ type: 'FeatureCollection', features });
}, [places, mapReady]);
@@ -2539,12 +2632,20 @@ export function MapViewGL({
const fitPaddingKey = fitPadding
? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ')
: '';
const didInitialFocusRef = useRef(false);
const prevFocusKeyRef = useRef(focusKey);
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
const map = mapRef.current;
if (!map || !focusPoints?.length) return;
if (focusKey !== undefined) {
const shouldFit = !didInitialFocusRef.current || focusKey !== prevFocusKeyRef.current;
prevFocusKeyRef.current = focusKey;
if (!shouldFit) return;
didInitialFocusRef.current = true;
}
const bounds = new gl.LngLatBounds();
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]));
// A day fit still waiting on its route must not overwrite this a moment later.
@@ -2559,7 +2660,7 @@ export function MapViewGL({
} catch {
/* noop */
}
}, [focusPoints, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, focusKey, fitPaddingKey]); // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
@@ -2643,13 +2744,16 @@ 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 (
@@ -2704,7 +2808,11 @@ export function MapViewGL({
}}
>
{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()
+10
View File
@@ -61,6 +61,7 @@ import {
import { renderIconMarkup } from '../../utils/iconMarkup';
import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { projectDayItinerary } from '../Map/dayTourProjection';
import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry';
import { planCosts } from '../Planner/planCosts';
import { isServiceStopType } from '../Roadtrip/roadtripModel';
@@ -406,6 +407,12 @@ export async function downloadTripPDF({
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(
{
@@ -414,6 +421,8 @@ export async function downloadTripPDF({
reservations,
accommodations: accommodationList,
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
toursEnabled,
places,
},
{ profile: 'driving', tripId: trip.id, timeoutMs: 8000 }
);
@@ -1152,6 +1161,7 @@ export async function downloadTripPDF({
</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
@@ -214,6 +214,7 @@ const emptyDataTransfer = { setData: vi.fn(), effectAllowed: '', getData: vi.fn(
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
mockPermissions.canEdit = true;
mockPermissions.denied.clear();
// clearAllMocks keeps implementations, so tests that swap the router out would
@@ -1731,6 +1732,10 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days, onSelectDay })} />);
await waitFor(() => expect(onSelectDay).toHaveBeenCalledWith(days[1].id, true));
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(Element.prototype.scrollTo).toHaveBeenCalled();
});
it('FE-PLANNER-DAYPLAN-196: a trip that is not running is left alone', async () => {
@@ -1870,6 +1875,70 @@ describe('DayPlanSidebar', () => {
expect(onDeletePlace).toHaveBeenCalledWith(42);
});
it('hides permanent Tour Delete from the TRIP-PLAN day card while retaining edit, route, view, and remove-from-day', async () => {
const user = userEvent.setup();
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
const place = buildPlace({ id: 42, name: 'Ridge walk' });
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place });
const onPlaceClick = vi.fn();
const onRemoveAssignment = vi.fn();
const onDeletePlace = vi.fn();
render(
<DayPlanSidebar
{...makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([place.id]),
assignments: { '10': [assignment] },
onPlaceClick,
onRemoveAssignment,
onDeletePlace,
})}
/>
);
await user.click(screen.getByText('Ridge walk'));
expect(onPlaceClick).toHaveBeenCalled();
fireEvent.contextMenu(screen.getByText('Ridge walk'));
expect(screen.getByText(/Edit/i)).toBeInTheDocument();
expect(screen.getByText(/Leave out of route/i)).toBeInTheDocument();
expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument();
await user.click(screen.getByText(/Remove from day/i));
expect(onRemoveAssignment).toHaveBeenCalledWith(10, 99);
expect(onDeletePlace).not.toHaveBeenCalled();
});
it('TRIP-PLAN preserves legacy route-backed Place Delete when Tours is disabled', async () => {
const user = userEvent.setup();
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
const place = buildPlace({
id: 43,
name: 'Old trail',
route_geometry: '[[48,11],[48.01,11.02]]',
tour_place_id: 43,
});
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const assignment = buildAssignment({ id: 100, day_id: 10, order_index: 0, place });
const onDeletePlace = vi.fn();
render(
<DayPlanSidebar
{...makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([place.id]),
assignments: { '10': [assignment] },
onDeletePlace,
})}
/>
);
fireEvent.contextMenu(screen.getByText('Old trail'));
await user.click(screen.getByText(/Delete/i));
expect(onDeletePlace).toHaveBeenCalledWith(place.id);
});
// ── Note card editing (#2249) ─────────────────────────
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
@@ -3765,6 +3834,174 @@ describe('DayPlanSidebar', () => {
]);
});
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'));
@@ -108,6 +108,7 @@ import {
optimizeRoute,
type NamedWaypoint,
} from '../Map/RouteCalculator';
import { projectDayItinerary } from '../Map/dayTourProjection';
import {
PluginDayScheduleRow,
dayTintBackground,
@@ -156,6 +157,7 @@ interface DayPlanSidebarProps {
trip: Trip;
days: Day[];
places: Place[];
tourPlaceIds?: ReadonlySet<number>;
categories: Category[];
assignments: AssignmentsMap;
selectedDayId: number | null;
@@ -209,7 +211,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;
@@ -330,6 +332,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);
@@ -801,6 +804,12 @@ 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.
@@ -826,8 +835,31 @@ 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).
@@ -906,7 +938,28 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
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,
@@ -939,6 +992,19 @@ 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 ||
@@ -949,10 +1015,16 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// 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 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 };
@@ -1072,7 +1144,17 @@ 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();
@@ -1092,10 +1174,14 @@ 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)] || [])
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
@@ -1221,7 +1307,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds);
},
[capturedDayId]
[capturedDayId],
prevPlaceIds
);
}
} catch (err: unknown) {
@@ -1365,6 +1452,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
.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
@@ -1425,7 +1515,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds);
},
[capturedDayId]
[capturedDayId],
placeIds
);
};
@@ -1684,6 +1775,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
@@ -1981,6 +2073,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
>
{/* 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}
@@ -2806,7 +2907,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
},
{ divider: true },
canEditPlaces &&
onDeletePlace && {
onDeletePlace &&
!(
toursEnabled &&
(props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)
) && {
label: t('common.delete'),
icon: Trash2,
danger: true,
@@ -730,26 +730,25 @@ describe('PlaceInspector', () => {
});
it('FE-PLANNER-INSPECTOR-2472: the own e-mail and hours show, the hours ahead of looked-up ones', async () => {
// The collapsed row shows today's hours, so on a Sunday it reads "Closed"
// and the button below is not found. Pin a Monday; only Date is faked so
// userEvent's timers keep running.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-28T10:00:00'));
onTestFinished(() => {
try {
vi.setSystemTime(new Date('2026-06-02T12:00:00Z'));
const user = userEvent.setup();
const week = JSON.stringify(
Array.from({ length: 7 }, (_, i) =>
i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' }
)
);
const placeWithHours = { id: 302, email: 'hi@bakery.test', opening_hours: week };
const p = buildPlace(placeWithHours);
render(<PlaceInspector {...defaultProps} place={p} />);
expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test');
expect(screen.getByText('Opening Hours')).toBeInTheDocument();
await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ }));
expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument();
} finally {
vi.useRealTimers();
});
const user = userEvent.setup();
const week = JSON.stringify(
Array.from({ length: 7 }, (_, i) =>
i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' }
)
);
const p = buildPlace({ id: 302, email: 'hi@bakery.test', opening_hours: week } as any);
render(<PlaceInspector {...defaultProps} place={p} />);
expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test');
expect(screen.getByText('Opening Hours')).toBeInTheDocument();
await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ }));
expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument();
}
});
// ── File size display ──────────────────────────────────────────────────────
@@ -0,0 +1,187 @@
import userEvent from '@testing-library/user-event';
import { buildAssignment, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
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]);
});
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();
});
});
@@ -609,6 +609,7 @@ function AddRow(S: SidebarState) {
setFileImportOpen,
setListImportOpen,
hasMultipleListImportProviders,
toursEnabled,
} = S;
const dayOpen = selectedDayId != null;
const split = dayOpen && !!onAddPlaceToSelectedDay;
@@ -619,7 +620,9 @@ 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,
@@ -210,6 +210,71 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['GPX Track']);
});
it('Tours addon excludes Tour-backed Places from rows, counts, and localities', () => {
const legacyTrack = buildPlace({
id: 41,
name: 'Legacy track',
route_geometry: '[[1,2],[3,4]]',
rating_avg: 4,
address: 'Vienna, Austria',
});
const ordinary = buildPlace({ id: 42, name: 'Ordinary place', address: 'Linz, Austria' });
const tour = buildPlace({
id: 43,
name: 'Tour place',
tour_place_id: 43,
route_geometry: '[[5,6],[7,8]]',
address: 'Denver, United States',
});
render(
<Host
{...makeProps({
places: [legacyTrack, ordinary, tour],
toursEnabled: true,
excludePlaceIds: new Set([tour.id]),
})}
/>
);
expect(S.hasTracks).toBe(false);
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Ordinary place']));
expect(names()).not.toContain('Tour place');
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 1 });
expect(S.localities).toEqual([{ country: 'Austria', count: 2, regions: [] }]);
act(() => {
S.setRatingFilter(4);
});
expect(names()).toEqual(['Legacy track']);
expect(S.filterCounts.all).toBe(2);
});
it('Tours addon OFF preserves legacy track Places and ignores the exclusion set', () => {
const legacyTrack = buildPlace({ id: 51, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]' });
const dormantTour = buildPlace({
id: 52,
name: 'Dormant Tour',
tour_place_id: 52,
route_geometry: '[[5,6],[7,8]]',
});
render(
<Host
{...makeProps({
places: [legacyTrack, dormantTour],
toursEnabled: false,
excludePlaceIds: new Set([dormantTour.id]),
})}
/>
);
expect(S.hasTracks).toBe(true);
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 2 });
act(() => {
S.setFilter('tracks');
});
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Dormant Tour']));
});
// FE-PLANNER-PSHOOK-008 (a tracks filter without any track falls back to "all")
// moved to useTripPlanner (FE-TP-HOOK-177): the fallback now lives there, so it
// also holds while no places list is mounted.
@@ -66,6 +66,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>
}
/**
@@ -78,6 +85,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()
@@ -108,14 +119,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()
}
@@ -128,7 +139,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)
@@ -196,8 +207,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
// Country, or country and region, from each place's resolved position (#2537). List-only,
// 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 localityOf = useMemo(() => new Map(poolPlaces.map(p => [p.id, placeLocality(p, language)])), [poolPlaces, language])
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)
@@ -259,9 +270,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) }
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
// Whether to offer the "Tracks" tab. useTripPlanner moves the filter back to
// "all" once the last track is gone, whichever screen is in front.
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
const hasTracks = useMemo(
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
[poolPlaces, props.toursEnabled],
)
const plannedIds = useMemo(
() => plannedPlaceIds({ assignments, accommodations, reservations }),
@@ -289,14 +301,14 @@ 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 (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
if (!placeMatchesSearch(p, search)) return false
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
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
@@ -306,14 +318,14 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
const base = places.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
const base = poolPlaces.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
return {
all: base.length,
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
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,52 @@
import { MapPin, Mountain } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n/TranslationContext';
import { NEUTRAL_TINT, fs } 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-0.5 pt-2.5" style={{ background: NEUTRAL_TINT }}>
<div
role="tablist"
aria-label={t('tours.mode.label')}
className="flex w-full gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]"
>
{options.map(([value, label, Icon]) => {
const selected = active === value;
return (
<button
key={label}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(value)}
style={fs(12.5, 'body')}
className={`flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium transition-colors ${
selected ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'
}`}
>
<Icon size={13} strokeWidth={2} aria-hidden />
<span className="truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,176 @@
import type { TourListItem } from '@trek/shared';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
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,544 @@
import type { TourListItem } from '@trek/shared';
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
File,
FileImage,
FileText,
Info,
MapPin,
Minus,
Mountain,
Navigation,
Pencil,
Plus,
Upload,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Markdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { translateApiError, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types';
import { openFile } from '../../utils/fileDownload';
import { filesForPlace } from '../../utils/placeFiles';
import { analyzeRouteGeometry } from '../../utils/routeGeometry';
import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
import { BOX } from '../Planner/bookings/bookingParts';
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
import { SoftPill, TimePill, tintOf } from '../Planner/planParts';
import DetailShell from '../shared/DetailShell';
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import ElevationProfile from '../shared/ElevationProfile';
import { NavigationMenu } from '../shared/NavigationMenu';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourMetricFields, TourNotice, TourTile } from './tourParts';
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
);
}
/**
* The card a tour opens over the map, in the place inspector's language: a head
* band tinted by the track colour with the tile, the name and the facts as
* pills, then each part of the tour under its own label, and the actions in a
* footer that stays in reach.
*/
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 [colorOpen, setColorOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationAnchorRef = useRef<HTMLSpanElement>(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 navigationLabel =
navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation');
const source = tourSource(tour);
const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto');
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 = (
<>
<div className="flex-none rounded-full bg-surface-card p-[2.5px] shadow-sm">
<TourTile size={52} color={trackColor} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
{canEdit && editingName ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void saveName();
if (event.key === 'Escape') {
event.preventDefault();
setNameDraft(place.name);
setEditingName(false);
}
}}
aria-label={t('places.formName')}
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
style={fs(17, 'subtitle')}
/>
<Tooltip label={t('common.save')}>
<button
type="button"
onClick={() => void saveName()}
disabled={savingName}
aria-label={t('common.save')}
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
>
<Check size={14} strokeWidth={2.4} />
</button>
</Tooltip>
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5">
<h2
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
style={fs(17, 'subtitle')}
>
{place.name}
</h2>
{canEdit && onUpdatePlace && (
<Tooltip label={t('common.edit')}>
<button
type="button"
onClick={() => {
setNameDraft(place.name);
setEditingName(true);
}}
aria-label={t('common.edit')}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
>
<Pencil size={12} strokeWidth={2.2} />
</button>
</Tooltip>
)}
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>
{t(`tourTypes.${tour.tour_type}`)}
</SoftPill>
{tour.difficulty && (
<SoftPill>
{t('tours.detail.difficulty')}: {tour.difficulty}
</SoftPill>
)}
{tour.duration != null && <TimePill>{t('tours.durationMinutes', { count: tour.duration })}</TimePill>}
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
<span className="inline-flex">
<SoftPill tone="warning" icon={<AlertTriangle size={11} strokeWidth={2} className="flex-none" />}>
{t('tours.caution.badge')}
</SoftPill>
</span>
</Tooltip>
)}
</div>
</div>
</>
);
const footer = (
<>
{canAssign &&
selectedDayId != null &&
(assignment
? onRemoveAssignment && (
<DialogButton
onClick={() => {
if (canAssign) onRemoveAssignment(selectedDayId, assignment.id);
}}
icon={<Minus size={14} strokeWidth={2} />}
aria-label={t('inspector.removeFromDay')}
>
<span className="max-sm:hidden">{t('inspector.removeFromDay')}</span>
</DialogButton>
)
: onAssignToDay && (
<DialogButton
variant="primary"
onClick={() => {
if (canAssign) onAssignToDay(place.id);
}}
icon={<Plus size={14} strokeWidth={2} />}
>
{t('inspector.addToDay')}
</DialogButton>
))}
{navigationTargets.length > 0 && (
<>
<span ref={navigationAnchorRef} className="inline-flex">
<DialogButton
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]);
else setNavigationOpen((open) => !open);
}}
aria-label={navigationLabel}
aria-haspopup={navigationTargets.length > 1 ? 'menu' : undefined}
aria-expanded={navigationTargets.length > 1 ? navigationOpen : undefined}
icon={<Navigation size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{navigationLabel}</span>
</DialogButton>
</span>
{navigationOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navigationAnchorRef.current}
onClose={() => setNavigationOpen(false)}
/>
)}
</>
)}
<FooterSpacer />
{canEdit && onDelete && (
<DeleteButton
onClick={() => {
if (canEdit) onDelete();
}}
/>
)}
</>
);
return (
<DetailShell
header={header}
footer={footer}
onClose={onClose}
closeLabel={t('common.close')}
leftWidth={leftWidth}
rightWidth={rightWidth}
tint={tintOf(trackColor)}
closeButtonClassName="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
>
{source === 'gpx' && (
<TourNotice icon={<Info size={13} strokeWidth={2} className="text-content-faint" />}>
{t('tours.detail.gpxReadOnly')}
{/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */}
</TourNotice>
)}
{analysis && (
<DialogSection className="flex-none" label={t('inspector.trackStats')}>
<div className="flex flex-col gap-2">
<TourMetricFields analysis={analysis} unit={distanceUnit} />
{analysis.distanceIndexedProfileSamples.length >= 2 && (
<div className={`${BOX} p-2`}>
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={trackColor}
gradientId={`tour-elevation-${place.id}`}
/>
</div>
)}
</div>
</DialogSection>
)}
<DialogSection className="flex-none" label={t('tours.detail.trackColor')}>
{canEdit && onUpdatePlace ? (
<div className={BOX}>
<button
type="button"
onClick={() => setColorOpen((open) => !open)}
aria-expanded={colorOpen}
className="flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-left transition-colors hover:bg-surface-hover"
>
<span
className="h-4 w-4 flex-none rounded-full ring-1 ring-inset ring-edge"
style={{ backgroundColor: trackColor }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
{colorLabel}
</span>
{colorOpen ? (
<ChevronUp size={13} className="flex-none text-content-faint" />
) : (
<ChevronDown size={13} className="flex-none text-content-faint" />
)}
</button>
{colorOpen && (
<div className="border-t border-edge-faint px-3 py-2.5">
<TrackColorPicker
value={place.route_color ?? null}
inheritedColor={inheritedColor}
onChange={(color) => {
if (canEdit) void onUpdatePlace(place.id, { route_color: color });
}}
/>
</div>
)}
</div>
) : (
<div className={`${BOX} flex items-center gap-2 px-3 py-2`}>
<span
aria-label={t('tours.detail.trackColor')}
className="h-4 w-4 flex-none rounded-full ring-1 ring-inset ring-edge"
style={{ backgroundColor: trackColor }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
{colorLabel}
</span>
</div>
)}
</DialogSection>
{place.description && (
<DialogSection className="flex-none" label={t('places.formDescription')}>
<MarkdownBox>{place.description}</MarkdownBox>
</DialogSection>
)}
{place.notes && (
<DialogSection className="flex-none" label={t('places.formNotes')}>
<MarkdownBox>{place.notes}</MarkdownBox>
</DialogSection>
)}
{assignment?.notes && (
<DialogSection className="flex-none" label={t('places.assignmentNotes')}>
<MarkdownBox>{assignment.notes}</MarkdownBox>
</DialogSection>
)}
{(placeFiles.length > 0 || (canEdit && onFileUpload)) && (
<DialogSection
className="flex-none"
label={t('inspector.files')}
action={
canEdit &&
onFileUpload && (
<label
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-surface-card px-2 py-[2px] font-geist font-semibold text-content-muted shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent hover:text-content"
style={fs(10.5)}
>
<input ref={fileInputRef} type="file" multiple className="sr-only" onChange={uploadFiles} />
{uploading ? (
<span aria-live="polite">…</span>
) : (
<>
<Upload size={11} strokeWidth={2} />
{t('common.upload')}
</>
)}
</label>
)
}
>
<div className={BOX}>
<button
type="button"
onClick={() => setFilesExpanded((expanded) => !expanded)}
aria-expanded={filesExpanded}
className="flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-left transition-colors hover:bg-surface-hover"
>
<FileText size={13} strokeWidth={2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
{placeFiles.length ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? (
<ChevronUp size={13} className="flex-none text-content-faint" />
) : (
<ChevronDown size={13} className="flex-none text-content-faint" />
)}
</button>
{filesExpanded && placeFiles.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-edge-faint p-1">
{placeFiles.map((file) => (
<button
type="button"
key={file.id}
onClick={() => void openFile(file.url)}
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-left transition-colors hover:bg-surface-hover"
>
{(file.mime_type || '').startsWith('image/') ? (
<FileImage size={12} strokeWidth={2} className="flex-none text-content-muted" />
) : (
<File size={12} strokeWidth={2} className="flex-none text-content-muted" />
)}
<span className="min-w-0 flex-1 truncate text-content-secondary" style={fs(12, 'body')}>
{file.original_name}
</span>
{file.file_size ? (
<span className="flex-none tabular-nums text-content-faint" style={fs(11)}>
{formatFileSize(file.file_size)}
</span>
) : null}
</button>
))}
</div>
)}
</div>
</DialogSection>
)}
{navigationTargets.length > 0 && (
<div className="flex flex-none items-center gap-1.5 text-content-faint" style={fs(11)}>
<MapPin size={12} strokeWidth={2} className="flex-none" />
{t('tours.detail.navigationHint')}
</div>
)}
</DetailShell>
);
}
/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */
function MarkdownBox({ children }: { children: string }) {
return (
<div
className={`${BOX} collab-note-md px-3 py-2 text-content-secondary`}
style={{ ...fs(12.5, 'body'), lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>
{children}
</Markdown>
</div>
);
}
@@ -0,0 +1,71 @@
import type { TourListItem } from '@trek/shared';
import { useId, type ReactNode } from 'react';
import { fs } from '../shared/DialogShell';
import { TourFactPills, TourTile } from './tourParts';
interface TourListRowProps {
tour: TourListItem;
selected?: boolean;
disabled?: boolean;
onSelect: (tour: TourListItem, opener: HTMLElement) => void;
action?: ReactNode;
children?: ReactNode;
}
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
export default function TourListRow({
tour,
selected = false,
disabled = false,
onSelect,
action,
children,
}: TourListRowProps) {
const nameId = useId();
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover';
return (
<li
role="option"
tabIndex={disabled ? -1 : 0}
aria-selected={selected}
aria-disabled={disabled || undefined}
aria-labelledby={nameId}
className={`group mb-px rounded-[12px] px-2.5 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${tone} ${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}`}
onClick={(event) => {
if (!disabled) onSelect(tour, event.currentTarget);
}}
onKeyDown={(event) => {
if (disabled || event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(tour, event.currentTarget);
}
}}
>
<div className="flex items-start gap-2.5">
<TourTile />
<div className="min-w-0 flex-1 pt-px">
<span
id={nameId}
className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}
>
{tour.name}
</span>
<TourFactPills tour={tour} className="mt-1.5" />
</div>
{action && (
<div
data-testid="tour-action-row"
role="presentation"
className="flex flex-none items-center gap-1.5 pt-1"
onClick={(event) => event.stopPropagation()}
>
{action}
</div>
)}
</div>
{children}
</li>
);
}
@@ -0,0 +1,293 @@
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { toursApi } from '../../api/client';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
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).toHaveClass('bg-surface-selected');
});
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, fact pills and the day action in one place-style row', (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).toHaveTextContent(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');
const labels = [...metrics.querySelectorAll('[aria-label]')].map((element) => element.getAttribute('aria-label'));
expect(labels[0]).toContain('1.9 km');
expect(labels[1]).toContain('13 m');
expect(labels[2]).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.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu');
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('disables already-assigned days and refreshes availability when the picker is reopened', async () => {
const dayOne = { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never;
const dayTwo = { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null } as never;
const dayThree = { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null } as never;
useTripStore.setState({ assignments: { '7': [{ id: 1, day_id: 7, place_id: tour.place_id }] } as never });
const onAssignToDay = vi.fn((placeId: number, dayId: number) => {
useTripStore.setState((state) => ({
assignments: { ...state.assignments, [String(dayId)]: [{ id: 2, day_id: dayId, place_id: placeId }] } as never,
}));
return true;
});
render(
<ToursSidebar
canAssign
tripId={1}
days={[dayOne, dayTwo, dayThree]}
tours={[tour]}
onAssignToDay={onAssignToDay}
onSelectTour={vi.fn()}
/>
);
const openPicker = () => fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }));
openPicker();
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Day two/ })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: /Day two/ }));
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(tour.place_id, 8);
openPicker();
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: /Day three/ }));
expect(onAssignToDay).toHaveBeenCalledTimes(2);
expect(onAssignToDay).toHaveBeenLastCalledWith(tour.place_id, 9);
});
it('neither refreshes nor reports success when assignment fails', async () => {
toast.success.mockClear();
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 act(async () => {});
expect(toast.success).not.toHaveBeenCalled();
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,272 @@
import type { TourListItem } from '@trek/shared';
import { Check, ChevronDown, FileDown } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { toursApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import type { Day } from '../../types';
import { NEUTRAL_TINT, fs } from '../shared/DialogShell';
import EmptyState from '../shared/EmptyState';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import TourListRow from './TourListRow';
import { TourDayMenu } from './tourParts';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
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;
}
/**
* 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 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;
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 className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filtered.length === 0 ? (
<EmptyState
scene="tours"
size={92}
fill
surface="var(--bg-secondary)"
title={t('tours.empty.title')}
action={
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{t('tours.empty.body')}
</p>
}
/>
) : (
<ul role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map((tour) => (
<TourListRow
key={tour.place_id}
tour={tour}
selected={tour.place_id === selectedPlaceId}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
action={
<TourDayMenu
days={days}
placeId={tour.place_id}
label={t('tours.addToDay')}
disabled={!canAssign}
onPick={(day, index) => {
void handleAssign(tour, day, index);
}}
/>
}
/>
))}
</ul>
)}
</div>
</div>
);
}
@@ -0,0 +1,705 @@
import userEvent from '@testing-library/user-event';
import type { TourListItem } from '@trek/shared';
import { act, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import type { Day } from '../../../types';
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels';
import type { TourPlannerController } from './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: '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('exposes permanent deletion in the visible TOUR-PLANNER Tours rail', () => {
const onDeleteTour = vi.fn();
render(
<TourPlannerToursRail planner={planner()} tours={[tour]} {...railProps} canEdit onDeleteTour={onDeleteTour} />
);
fireEvent.click(screen.getByRole('button', { name: 'Delete Saved ridge walk' }));
expect(onDeleteTour).toHaveBeenCalledOnce();
expect(onDeleteTour).toHaveBeenCalledWith(tour.place_id);
});
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('keeps the post-save confirmation compact and assigns through the saved TOUR-PLANNER row action', 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');
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument();
expect(onAssignToDay).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }));
expect(controller.startNewTour).toHaveBeenCalledOnce();
expect(controller.discard).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' }));
const dayOption = screen.getByRole('button', { name: /Summit day/ });
expect(dayOption).toHaveTextContent(/Day 1/i);
expect(dayOption).toHaveTextContent(/May 15/i);
expect(dayOption).toHaveTextContent(/Fri/i);
fireEvent.click(dayOption);
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7));
});
it('keeps the save confirmation and row assignment action available when row 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: 'Add to day: Saved ridge walk' }));
fireEvent.click(screen.getByRole('button', { name: /Summit day/ }));
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7));
expect(screen.getByRole('status')).toHaveTextContent('Tour saved');
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled();
});
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-accent', 'text-accent-text', 'font-geist', 'tabular-nums', 'h-7', 'w-7');
expect(secondNumber).toHaveClass('bg-surface-card', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7');
});
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('Start a new tour and click the map to place its waypoints.')).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,738 @@
import type { TourListItem } from '@trek/shared';
import {
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowUp,
CheckCircle2,
Info,
MapPin,
Plus,
Redo2,
RotateCcw,
Save,
ShieldAlert,
Trash2,
Undo2,
} from 'lucide-react';
import { useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { Day } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BOX } from '../../Planner/bookings/bookingParts';
import { BarButton, SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { INPUT, LABEL } from '../../shared/dialogParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
import ElevationProfile from '../../shared/ElevationProfile';
import EmptyState from '../../shared/EmptyState';
import { useToast } from '../../shared/Toast';
import { Tooltip } from '../../shared/Tooltip';
import TourListRow from '../TourListRow';
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts';
import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation';
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions';
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner';
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',
};
/** The tinted head band both rails open with, like every panel head in the planner. */
const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5';
type Translate = (key: string, params?: Record<string, string | number | null>) => string;
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
return t(`tours.planner.${role}`);
}
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit),
});
}
/** The title of a rail's head band, with an optional line under it. */
function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
return (
<div className="min-w-0 flex-1 pt-[5px]">
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="m-0 min-w-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(14, 'body')}>
{title}
</h2>
{aside}
</div>
{sub && (
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>
{sub}
</p>
)}
</div>
);
}
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" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<button
type="button"
disabled={!canEdit}
onClick={() => {
if (canEdit) planner.startNewTour();
}}
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap 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')}
>
<Plus size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('tours.planner.newTour')}</span>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<EmptyState
scene="tours"
size={116}
fill
surface="var(--bg-secondary)"
title={t('tours.planner.neutralTitle')}
action={
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{t('tours.planner.neutralBody')}
</p>
}
/>
</div>
</section>
);
}
if (planner.mode.type === 'view-gpx') {
const { tour } = planner.readOnlyGpxTour;
const analysis = planner.readOnlyGpxAnalysis;
const extra =
tour.duration != null
? [
{
label: t('tours.planner.inspector.duration'),
value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }),
},
]
: [];
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<BarButton
label={t('tours.planner.backToTours')}
onClick={() => planner.returnToNeutral()}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle
title={t('tours.planner.gpxTitle')}
sub={tour.name}
aside={<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<TourNotice icon={<Info size={13} strokeWidth={2} className="text-content-faint" />}>
{t('tours.detail.gpxReadOnly')}
</TourNotice>
{analysis ? (
<>
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={analysis} unit={distanceUnit} extra={extra} columns={2} />
</TourSection>
<ElevationSection
id="tour-planner-gpx-elevation-profile"
samples={analysis.distanceIndexedProfileSamples}
gradientId={`planner-gpx-elevation-${tour.place_id}`}
planner={planner}
/>
</>
) : (
<TourNotice>{t('tours.planner.gpxNoGeometry')}</TourNotice>
)}
</div>
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
);
}
const isSavedEdit = planner.mode.type === 'edit-saved';
const lastIndex = planner.waypoints.length - 1;
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<BarButton
label={t('tours.planner.backToTours')}
onClick={() => requestClose()}
disabled={isSaving}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle
title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')}
sub={t('tours.planner.mapHint')}
/>
<BarButton
label={t('tours.planner.undo')}
onClick={() => {
if (canEdit && !isSaving) planner.undo();
}}
disabled={!canEdit || isSaving || !planner.canUndo}
>
<Undo2 size={15} strokeWidth={2} />
</BarButton>
<BarButton
label={t('tours.planner.redo')}
onClick={() => {
if (canEdit && !isSaving) planner.redo();
}}
disabled={!canEdit || isSaving || !planner.canRedo}
>
<Redo2 size={15} strokeWidth={2} />
</BarButton>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{planner.draftRestored && planner.hasUnsavedChanges && (
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>
{t('tours.planner.restored')}
</TourNotice>
)}
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} 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={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} 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}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.planner.difficulty.t3Warning')}
</TourNotice>
)}
</div>
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div
data-testid="tour-planner-empty-state"
className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted"
style={fs(12, 'body')}
>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">
{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}
</p>
</div>
) : (
<ol className="m-0 flex list-none flex-col gap-px p-0">
{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={`group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
aria-current={selected ? 'true' : undefined}
>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</span>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</button>
</li>
);
})}
</ol>
)}
</TourSection>
{(statusKey || planner.error) && (
<TourNotice
role="status"
tone={planner.error ? 'warning' : 'neutral'}
icon={
planner.error ? (
<AlertTriangle size={13} strokeWidth={2} />
) : (
<Info size={13} strokeWidth={2} className="text-content-faint" />
)
}
>
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
{canRetry && (
<button
type="button"
onClick={planner.retry}
className="ml-2 inline-flex items-center rounded-full bg-surface-card px-2 py-[1px] font-semibold text-content shadow-sm hover:bg-surface-secondary"
style={fs(11)}
>
{t('tours.planner.retry')}
</button>
)}
</TourNotice>
)}
</div>
<div className="flex flex-none flex-col gap-2.5 border-t border-edge-faint px-3 py-3">
<p className="m-0 flex items-start gap-1.5 leading-snug text-content-faint" style={fs(11)}>
<ShieldAlert size={13} strokeWidth={2} className="mt-px flex-none" />
<span>
{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}
</span>
</p>
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton
icon={<RotateCcw size={14} strokeWidth={2} />}
onClick={requestClose}
disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}
>
{t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')}
</DialogButton>
<DialogButton
variant="primary"
icon={<Save size={14} strokeWidth={2} />}
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>
</>
);
}
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
function WaypointAction({
label,
disabled,
danger = false,
onAct,
children,
}: {
label: string;
disabled: boolean;
danger?: boolean;
onAct: () => void;
children: ReactNode;
}) {
return (
<Tooltip label={label}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-disabled={disabled}
onClick={(event) => {
event.stopPropagation();
if (!disabled) onAct();
}}
onKeyDown={(event) => {
if (!disabled && event.key === 'Enter') {
event.stopPropagation();
onAct();
}
}}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</span>
</Tooltip>
);
}
/** The elevation profile under its label, folded away with the chevron beside it. */
function ElevationSection({
id,
samples,
gradientId,
planner,
}: {
id: string;
samples: DistanceIndexedProfileSample[];
gradientId: string;
planner: TourPlannerController;
}) {
const { t } = useTranslation();
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit);
if (samples.length < 2) {
return (
<TourSection label={t('tours.planner.inspector.elevation')}>
<TourNotice>{t('tours.planner.inspector.elevationPlaceholder')}</TourNotice>
</TourSection>
);
}
const expanded = planner.elevationProfileExpanded;
return (
<TourSection
label={t('tours.planner.inspector.elevation')}
action={
<FoldButton
expanded={expanded}
onToggle={planner.toggleElevationProfile}
controls={id}
label={t(expanded ? 'tours.planner.collapseElevation' : 'tours.planner.expandElevation')}
/>
}
>
<div id={id} hidden={!expanded} className={`${BOX} p-2`}>
{expanded && (
<ElevationProfile
samples={samples}
color="var(--text-secondary)"
gradientId={gradientId}
ariaLabel={t('tours.planner.inspector.elevation')}
focus={planner.routeProfileFocus}
onFocusChange={planner.setRouteProfileFocus}
formatFocus={(focus) => profileFocusLabel(focus, t, distanceUnit)}
/>
)}
</div>
</TourSection>
);
}
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;
onDeleteTour?: (placeId: number) => void;
}
export function TourPlannerToursRail({
planner,
tours,
days,
loading,
onAssignToDay,
onViewGpxTour,
onDeleteTour,
canEdit: editPermission,
canAssign: assignPermission,
}: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({
canEdit: editPermission ?? planner.canEdit,
canAssign: assignPermission ?? planner.canAssign,
});
const { t } = useTranslation();
const toast = useToast();
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit);
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null);
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 walkingTime = {
label: t('tours.planner.inspector.duration'),
value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }),
};
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} sub={t('tours.subtitle')} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{hasRoute && (
<>
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={[walkingTime]} columns={2} />
</TourSection>
{routeAnalysis && (
<ElevationSection
id="tour-planner-editable-elevation-profile"
samples={routeAnalysis.distanceIndexedProfileSamples}
gradientId={`planner-editable-elevation-${planner.editingPlaceId ?? 'draft'}`}
planner={planner}
/>
)}
</>
)}
{planner.saveOutcome && (
<div className={`${BOX} flex flex-col gap-3 p-3`} role="status">
<div className="flex items-center gap-2 font-semibold text-content" style={fs(13, 'body')}>
<CheckCircle2 size={16} strokeWidth={2} className="flex-none text-success" />
{t('tours.planner.saved')}
</div>
<DialogButton
onClick={() => {
if (canEdit && !isSaving) planner.startNewTour();
}}
disabled={!canEdit || isSaving}
>
{t('tours.planner.planAnother')}
</DialogButton>
</div>
)}
{!loading && tours.length === 0 ? (
<EmptyState
scene="tours"
size={116}
fill
surface="var(--bg-secondary)"
title={t('tours.empty.title')}
className="flex-1"
action={
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{t('tours.empty.body')}
</p>
}
/>
) : (
<TourSection label={t('tours.mode.tours')}>
{tours.length === 0 ? (
<p className="m-0 py-6 text-center text-content-faint" style={fs(12, 'body')}>
{t('common.loading')}
</p>
) : (
<ul role="listbox" aria-label={t('tours.planner.tripTours')} className="m-0 -mx-1 list-none p-0">
{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) || (canEdit && onDeleteTour) ? (
<>
{canAssign && days.length > 0 && (
<TourDayMenu
days={days}
placeId={tour.place_id}
label={`${t('tours.addToDay')}: ${tour.name}`}
onPick={(day) => {
if (canAssign) void onAssignToDay(tour.place_id, day.id);
}}
/>
)}
{canEdit && onDeleteTour && (
<Tooltip label={`${t('common.delete')}: ${tour.name}`}>
<button
type="button"
aria-label={`${t('common.delete')} ${tour.name}`}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
if (!isSaving) onDeleteTour(tour.place_id);
}}
className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-danger-soft enabled:hover:text-danger disabled:cursor-default disabled:opacity-40"
>
<Trash2 size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
)}
</>
) : undefined
}
/>
))}
</ul>
)}
</TourSection>
)}
</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,641 @@
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 [mapFocusKey, setMapFocusKey] = 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 deletedTourPlaceIdsRef = useRef(new Set<number>())
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)
setMapFocusKey(key => key + 1)
}, [invalidateDetailRequest, interruptRouting, storageKey])
const forgetDeletedTour = useCallback((placeId: number) => {
deletedTourPlaceIdsRef.current.add(placeId)
const editorReferencesTour = editingPlaceIdRef.current === placeId
|| readOnlyGpxTourRef.current?.tour.place_id === placeId
|| detailPlaceIdRef.current === placeId
if (!editorReferencesTour) return
if (saveInFlightRef.current) {
saveRequestIdRef.current += 1
saveInFlightRef.current = false
setIsSaving(false)
}
returnToNeutral()
}, [returnToNeutral])
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 || deletedTourPlaceIdsRef.current.has(tour.place_id)) 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
setMapFocusKey(key => key + 1)
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
if (editingPlaceId !== null && deletedTourPlaceIdsRef.current.has(editingPlaceId)) 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 && !deletedTourPlaceIdsRef.current.has(result.tour.place_id)) {
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, forgetDeletedTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
editingPlaceId, openingTourId, openTour,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral,
}
}
export type TourPlannerController = ReturnType<typeof useTourPlanner>
+298
View File
@@ -0,0 +1,298 @@
import type { TourListItem } from '@trek/shared';
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Mountain, Plus, Ruler } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Day, DistanceUnit } from '../../types';
import { formatDate } from '../../utils/formatters';
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry';
import { formatDistance, formatElevation } from '../../utils/units';
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts';
import { SoftPill } from '../Planner/planParts';
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
import { DialogButton, fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { hikeSourceBadgeLabel } from './tourPresentation';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The round tile a tour stands behind in a list, where a place has its avatar. */
export function TourTile({ size = 34, color, raised = false }: { size?: number; color?: string; raised?: boolean }) {
return (
<span
className={`grid flex-none place-items-center rounded-full ${raised ? 'bg-surface-card shadow-sm' : 'bg-surface-tertiary'}`}
style={{ width: size, height: size }}
>
<Mountain size={Math.round(size * 0.44)} strokeWidth={1.9} style={{ color: color ?? 'var(--text-muted)' }} />
</span>
);
}
/** The facts of a tour as white pills: how far, how much up and down, how hard, where it came from. */
export function TourFactPills({ tour, className = '' }: { tour: TourListItem; className?: string }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const difficulty = t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`);
return (
<div data-testid="tour-metrics" className={`flex min-w-0 flex-wrap items-center gap-1 ${className}`}>
<SoftPill
className="tabular-nums"
icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
<span aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, unit)}`}>
{formatDistance(tour.distance ?? 0, unit)}
</span>
</SoftPill>
{tour.elevation_gain != null && (
<SoftPill
className="tabular-nums"
icon={<ArrowUp size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
<span aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}>
{formatElevation(tour.elevation_gain, unit)}
</span>
</SoftPill>
)}
{tour.elevation_loss != null && (
<SoftPill
className="tabular-nums"
icon={<ArrowDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
<span aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, unit)}`}>
{formatElevation(tour.elevation_loss, unit)}
</span>
</SoftPill>
)}
<Tooltip label={difficulty}>
<span aria-label={difficulty} className="inline-flex">
<SoftPill>T{tour.max_hiking_difficulty}</SoftPill>
</span>
</Tooltip>
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
<span aria-label={t('tours.caution.badge')} className="inline-flex">
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>
{t('tours.caution.badge')}
</SoftPill>
</span>
</Tooltip>
)}
</div>
);
}
/** "Day 2, Tue, Jul 8: Lakes" as far as the day has a date and a title. */
function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): string {
const date = formatDate(day.date, locale);
const head = date ? `${t('dayplan.dayN', { n: index + 1 })}, ${date}` : t('dayplan.dayN', { n: index + 1 });
return day.title ? `${head}: ${day.title}` : head;
}
/**
* Putting a tour on a day: a round "+" on a row, or a footer button with a
* chevron, both opening the trip's days as a menu.
*/
export function TourDayMenu({
days,
placeId,
onPick,
disabled = false,
label,
variant = 'round',
primary = false,
}: {
days: Day[];
placeId?: number;
onPick: (day: Day, index: number) => void;
disabled?: boolean;
label: string;
variant?: 'round' | 'button';
primary?: boolean;
}) {
const { t, locale } = useTranslation();
const menu = useContextMenu();
const assignments = useTripStore((state) => state.assignments ?? {});
const assigned = (dayId: number) =>
placeId != null && (assignments[String(dayId)] ?? []).some((assignment) => assignment.place_id === placeId);
const items = days.map((day, index) => ({
label: dayMenuLabel(day, index, t, locale),
disabled: assigned(day.id),
onClick: () => onPick(day, index),
}));
const hasAvailableDay = items.some((item) => !item.disabled);
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || !hasAvailableDay) return;
if (menu.menu) {
event.stopPropagation();
menu.close();
return;
}
menu.open(event, items, true);
};
return (
<>
{variant === 'round' ? (
<Tooltip label={label} disabled={!!menu.menu}>
<button
type="button"
onClick={open}
disabled={disabled || !hasAvailableDay}
aria-label={label}
aria-haspopup="menu"
aria-expanded={!!menu.menu}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors disabled:cursor-default disabled:opacity-40 ${menu.menu ? 'bg-accent text-accent-text ring-transparent' : 'bg-surface-card text-content-muted ring-edge-faint enabled:hover:bg-accent enabled:hover:text-accent-text enabled:hover:ring-transparent'}`}
>
<Plus size={13} strokeWidth={2.4} />
</button>
</Tooltip>
) : (
<DialogButton
variant={primary ? 'primary' : 'secondary'}
onClick={open}
disabled={disabled || !hasAvailableDay}
aria-haspopup="menu"
aria-expanded={!!menu.menu}
icon={<Plus size={14} strokeWidth={2} />}
>
{label}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`transition-transform ${menu.menu ? 'rotate-180' : ''}`}
/>
</DialogButton>
)}
<ContextMenu menu={disabled ? null : menu.menu} onClose={menu.close} />
</>
);
}
/** A labelled block of a tour panel: the eyebrow, an optional control on its right, then the content. */
export function TourSection({
label,
action,
children,
className = '',
}: {
label: ReactNode;
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={className}>
<div className="mb-2 flex min-h-[18px] items-center gap-2">
<Eyebrow>{label}</Eyebrow>
{action && <span className="ml-auto flex items-center">{action}</span>}
</div>
{children}
</section>
);
}
/** One sentence the panel has to say (read-only, a warning, a status), in the card look. */
export function TourNotice({
tone = 'neutral',
icon,
children,
role,
}: {
tone?: 'neutral' | 'warning';
icon?: ReactNode;
children: ReactNode;
role?: 'status';
}) {
const look =
tone === 'warning'
? 'border-transparent bg-warning-soft text-warning'
: 'border-edge-faint bg-surface-card text-content-muted';
return (
<div
role={role}
className={`flex items-start gap-2 rounded-[10px] border px-3 py-2 leading-snug ${look}`}
style={fs(12, 'body')}
>
{icon && <span className="mt-[2px] flex-none">{icon}</span>}
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
/** The chevron that folds a section away, quiet like the rest of a panel's controls. */
export function FoldButton({
expanded,
onToggle,
controls,
label,
}: {
expanded: boolean;
onToggle: () => void;
controls: string;
label: string;
}) {
return (
<Tooltip label={label}>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={controls}
aria-label={label}
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${expanded ? '' : '-rotate-90'}`} />
</button>
</Tooltip>
);
}
/**
* A tour's numbers as labelled fields, the way a booking card shows its facts:
* distance, any extra the caller has (the walking time), then the altitudes.
*/
export function TourMetricFields({
analysis,
unit,
extra = [],
columns,
}: {
analysis: Pick<RouteGeometryAnalysis, 'distanceKm' | 'minEle' | 'maxEle' | 'gain' | 'loss'> | null;
unit?: DistanceUnit;
extra?: Array<{ label: string; value: string }>;
/** A fixed column count for a narrow rail; the inspector lets the fields flow. */
columns?: 2;
}) {
const { t } = useTranslation();
const fields: Array<{ label: string; value: string }> = [
{ label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' },
...extra,
];
if (analysis?.minEle != null)
fields.push({ label: t('tours.detail.minAltitude'), value: formatElevation(analysis.minEle, unit) });
if (analysis?.maxEle != null)
fields.push({ label: t('tours.detail.maxAltitude'), value: formatElevation(analysis.maxEle, unit) });
if (analysis?.gain != null)
fields.push({ label: t('tours.detail.ascent'), value: formatElevation(analysis.gain, unit) });
if (analysis?.loss != null)
fields.push({ label: t('tours.detail.descent'), value: formatElevation(analysis.loss, unit) });
const grid = columns === 2 ? 'grid-cols-2' : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]';
return (
<dl className={`m-0 grid gap-2 ${grid}`}>
{fields.map((field) => (
<div key={field.label} className="min-w-0">
<dt>
<Eyebrow className="mb-[3px] truncate">{field.label}</Eyebrow>
</dt>
<dd
className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
style={fs(12.5, 'body')}
>
{field.value}
</dd>
</div>
))}
</dl>
);
}
@@ -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,280 @@
import { within } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import de from '@trek/shared/i18n/de';
import en from '@trek/shared/i18n/en';
import fr from '@trek/shared/i18n/fr';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
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.getByLabelText(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument()
);
const onAssignToDay = vi.fn();
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={onAssignToDay}
onViewGpxTour={vi.fn()}
/>
);
const saveBanner = screen.getByText(toursText(de, 'tours.planner.saved')).closest('[role="status"]') as HTMLElement;
expect(
within(saveBanner).getByRole('button', { name: toursText(de, 'tours.planner.planAnother') })
).toBeInTheDocument();
expect(
within(saveBanner).queryByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: `${toursText(de, 'tours.addToDay')}: ${tour.name}` }));
const dayOption = screen.getByRole('button', { name: /Gipfeltag/ });
expect(dayOption).toHaveTextContent(/Tag 1/);
fireEvent.click(dayOption);
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7));
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,420 @@
import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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 MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList';
import { usePermissionsStore } from '../../store/permissionsStore';
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import { useTourPlanner } from './planner/useTourPlanner';
import TourDetailDialog from './TourDetailDialog';
import ToursSidebar from './ToursSidebar';
import { useTourPermissions } from './useTourPermissions';
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: 'inspector.trackColorAuto' }));
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 saveBanner = screen.getByText('tours.planner.saved').closest('[role="status"]') as HTMLElement;
expect(within(saveBanner).getByRole('button', { name: 'tours.planner.planAnother' })).toBeInTheDocument();
expect(within(saveBanner).queryByRole('button', { name: 'tours.planner.assignToDay' })).not.toBeInTheDocument();
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);
const picker = screen.queryByRole('button', { name: 'tours.addToDay: Ridge' });
expect(Boolean(picker)).toBe(canAssign);
if (picker) {
expect(picker).toBeEnabled();
fireEvent.click(picker);
fireEvent.click(screen.getByRole('button', { name: /Summit/ }));
}
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0);
if (canAssign) expect(assign).toHaveBeenCalledWith(tour.place_id, 3);
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)),
}
}
@@ -77,4 +77,18 @@ describe('ContextMenu', () => {
});
expect(onClose).toHaveBeenCalledOnce();
});
it('does not activate a disabled menu item', () => {
const onClick = vi.fn();
render(
<ContextMenu
menu={makeMenu(100, 200, [{ label: 'Already assigned', onClick, disabled: true }])}
onClose={onClose}
/>
);
const item = screen.getByRole('button', { name: 'Already assigned' });
expect(item).toBeDisabled();
fireEvent.click(item);
expect(onClick).not.toHaveBeenCalled();
});
});
+9 -4
View File
@@ -6,6 +6,7 @@ interface MenuItem {
label?: string;
icon?: LucideIcon;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
divider?: boolean;
}
@@ -98,7 +99,9 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
<button
type="button"
key={i}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick?.();
onClose();
}}
@@ -111,17 +114,19 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
borderRadius: 7,
border: 'none',
background: 'none',
cursor: 'pointer',
cursor: item.disabled ? 'default' : 'pointer',
fontFamily: 'inherit',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 500,
textAlign: 'left',
color: item.danger ? '#ef4444' : 'var(--text-primary)',
opacity: item.disabled ? 0.45 : 1,
transition: 'background 0.1s',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)')
}
onMouseEnter={(e) => {
if (!item.disabled)
e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)';
}}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
@@ -0,0 +1,191 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { TourListItem } from '@trek/shared';
import { useState } from 'react';
import { TranslationProvider } from '../../i18n/TranslationContext';
import type { Day } from '../../types';
import { TourPlannerRail, TourPlannerToursRail } from '../Tours/planner/TourPlannerPanels';
import type { TourPlannerController } from '../Tours/planner/useTourPlanner';
import ToursSidebar from '../Tours/ToursSidebar';
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('routes post-save assignment through the Tour-row menu and resolves 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>
);
const saveBanner = screen.getByText('Tour saved').closest('[role="status"]') as HTMLElement;
expect(within(saveBanner).getByRole('button', { name: 'Plan another' })).toBeInTheDocument();
expect(within(saveBanner).queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Add to day: Ridge walk' }));
const dayOption = screen.getByRole('button', { name: /Summit day/ });
expect(dayOption).toHaveTextContent(/Day 1/);
await user.click(dayOption);
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,5 +1,5 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from '../../../tests/helpers/render';
import CustomSelect from './CustomSelect';
const OPTIONS = [
@@ -211,4 +211,117 @@ 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();
});
});
+58 -4
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { scrollAnchorIntoView, useAnchoredPosition } 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;
@@ -138,13 +151,51 @@ 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);
@@ -223,6 +274,10 @@ export default function CustomSelect({
createPortal(
<div
ref={dropRef}
id={menuId}
role="group"
aria-label={ariaLabel || selected?.label || placeholder || undefined}
onKeyDown={handleKeyDown}
style={{
position: 'fixed',
...(anchored
@@ -333,8 +388,7 @@ export default function CustomSelect({
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
setSearch('');
closeMenu();
}}
style={{
width: '100%',
@@ -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,80 @@
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { NEUTRAL_TINT } from './DialogShell';
import { Tooltip } from './Tooltip';
interface DetailShellProps {
header: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
closeLabel: string;
leftWidth?: number;
rightWidth?: number;
/** The head band's background; the neutral accent wash when left out. */
tint?: string;
closeButtonClassName?: string;
testId?: string;
}
/**
* The card a selected item opens over the map, in the place inspector's frame:
* centred between the planner panels, a tinted head band with the close button,
* a body that scrolls and a footer that stays in reach.
*/
export default function DetailShell({
header,
children,
footer,
onClose,
closeLabel,
leftWidth = 0,
rightWidth = 0,
tint = NEUTRAL_TINT,
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="flex max-h-[60vh] flex-col overflow-hidden rounded-[20px] border border-edge-faint bg-surface-elevated text-content shadow-popover backdrop-blur-[40px] backdrop-saturate-[1.8]">
<header
className="flex flex-none items-start gap-3.5 border-b border-edge-faint px-4 pb-3 pt-3.5"
style={{ background: tint }}
>
{header}
<Tooltip label={closeLabel}>
<button
type="button"
onClick={onClose}
aria-label={closeLabel}
className={`grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content ${closeButtonClassName}`}
>
<X size={15} strokeWidth={2.2} />
</button>
</Tooltip>
</header>
<div
data-testid={testId}
className="flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto overscroll-contain px-4 py-3.5"
>
{children}
</div>
{footer && (
<footer className="flex flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5">
{footer}
</footer>
)}
</div>
</div>
);
}
@@ -0,0 +1,254 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { RouteProfileFocus } from '../../utils/routeGeometry';
import ElevationProfile from './ElevationProfile';
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,222 @@
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`,
}
}