mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest } from '@trek/shared'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import { postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
import {
|
||||
@@ -465,7 +465,8 @@ export const placesApi = {
|
||||
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string): Promise<{ success: boolean; tourPlaceIds?: number[] }> =>
|
||||
apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
uploadImage: (tripId: number | string, id: number | string, file: File) => {
|
||||
const fd = new FormData()
|
||||
@@ -513,6 +514,24 @@ export const placesApi = {
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const toursApi = {
|
||||
list: (tripId: number | string): Promise<{ tours: TourListItem[] }> =>
|
||||
apiClient.get(`/trips/${tripId}/tours`).then(r => r.data),
|
||||
detail: (tripId: number | string, placeId: number | string, signal?: AbortSignal): Promise<TourDetailResponse> =>
|
||||
(signal
|
||||
? apiClient.get(`/trips/${tripId}/tours/${placeId}`, { signal })
|
||||
: apiClient.get(`/trips/${tripId}/tours/${placeId}`)).then(r => r.data),
|
||||
create: (tripId: number | string, body: TourCreateRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
|
||||
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
|
||||
},
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
|
||||
@@ -16,6 +16,9 @@ import type { TrekWsEventName } from '@trek/shared'
|
||||
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
|
||||
*/
|
||||
export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||
'tours:changed',
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export const SATELLITE_TILE_ATTRIBUTION =
|
||||
'Imagery © <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics'
|
||||
export const SATELLITE_TILE_MAXZOOM = 19
|
||||
|
||||
// Key-free hiking basemap used only by the Tours planner's per-view switcher.
|
||||
export const OPENTOPOMAP_TILE_URL = 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'
|
||||
export const OPENTOPOMAP_TILE_ATTRIBUTION =
|
||||
'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
|
||||
export const OPENTOPOMAP_TILE_MAXZOOM = 17
|
||||
|
||||
/**
|
||||
* The basemap for a browser that will not give MapLibre a WebGL context (#2288).
|
||||
*
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
export const TRIP_TAB_IDS = [
|
||||
'plan',
|
||||
'tour-planner',
|
||||
'roadtrip',
|
||||
'transports',
|
||||
'buchungen',
|
||||
@@ -31,6 +32,7 @@ export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
||||
*/
|
||||
export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
|
||||
plan: 'trip.tabs.plan',
|
||||
'tour-planner': 'trip.tabs.tourPlanner',
|
||||
roadtrip: 'roadtrip.title',
|
||||
transports: 'trip.tabs.transports',
|
||||
buchungen: 'trip.tabs.reservations',
|
||||
|
||||
@@ -5,14 +5,16 @@ export interface UndoEntry {
|
||||
undo: () => Promise<void> | void
|
||||
/** The days the step acts on. Deleting one of them drops the step. */
|
||||
dayIds?: number[]
|
||||
/** Place-backed steps become invalid if one of their Tour places is permanently deleted. */
|
||||
placeIds?: number[]
|
||||
}
|
||||
|
||||
export function usePlannerHistory(maxEntries = 30) {
|
||||
const historyRef = useRef<UndoEntry[]>([])
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
|
||||
|
||||
const pushUndo = (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => {
|
||||
historyRef.current = [{ label, undo: undoFn, dayIds }, ...historyRef.current].slice(0, maxEntries)
|
||||
const pushUndo = (label: string, undoFn: () => Promise<void> | void, dayIds?: number[], placeIds?: number[]) => {
|
||||
historyRef.current = [{ label, undo: undoFn, dayIds, placeIds }, ...historyRef.current].slice(0, maxEntries)
|
||||
forceUpdate()
|
||||
}
|
||||
|
||||
@@ -45,8 +47,15 @@ export function usePlannerHistory(maxEntries = 30) {
|
||||
forceUpdate()
|
||||
}, [])
|
||||
|
||||
const forgetPlace = useCallback((placeId: number) => {
|
||||
const kept = historyRef.current.filter(entry => !entry.placeIds?.includes(placeId))
|
||||
if (kept.length === historyRef.current.length) return
|
||||
historyRef.current = kept
|
||||
forceUpdate()
|
||||
}, [])
|
||||
|
||||
const canUndo = historyRef.current.length > 0
|
||||
const lastActionLabel = historyRef.current[0]?.label ?? null
|
||||
|
||||
return { pushUndo, undo, forgetDay, canUndo, lastActionLabel }
|
||||
return { pushUndo, undo, forgetDay, forgetPlace, canUndo, lastActionLabel }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../tests/helpers/factories'
|
||||
import { resetAllStores } from '../../tests/helpers/store'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useRouteCalculation } from './useRouteCalculation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ route: vi.fn(), mirrorServiceStops: true }))
|
||||
vi.mock('../components/Map/RouteCalculator', async importActual => ({
|
||||
...await importActual<typeof import('../components/Map/RouteCalculator')>(),
|
||||
calculateRouteWithLegs: mocks.route,
|
||||
}))
|
||||
vi.mock('./useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (settings: { roadtrip_service_stops_in_days: boolean }) => unknown) =>
|
||||
select({ roadtrip_service_stops_in_days: mocks.mirrorServiceStops }),
|
||||
}))
|
||||
|
||||
function seed(enabled = true, excluded = false) {
|
||||
const places = [
|
||||
buildPlace({ id: 1, lat: 48.1, lng: 11.1 }),
|
||||
buildPlace({ id: 2, lat: 48.2, lng: 11.2, route_geometry: '[[48.2,11.2],[48.3,11.3]]' }),
|
||||
buildPlace({ id: 3, lat: 48.4, lng: 11.4 }),
|
||||
]
|
||||
const assignments = { '1': places.map((place, order) => buildAssignment({
|
||||
day_id: 1, order_index: order, place,
|
||||
...(order === 1 ? { tour_place_id: 2, tour_route_geometry: '[[49,12],[49.1,12.1]]', route_excluded: excluded } : {}),
|
||||
})) }
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled }] })
|
||||
useTripStore.setState({ days: [buildDay({ id: 1 })], assignments, places, reservations: [] })
|
||||
return places
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
mocks.mirrorServiceStops = true
|
||||
mocks.route.mockReset().mockImplementation(async (points: Array<{ lat: number; lng: number }>) => ({
|
||||
coordinates: points.map(point => [point.lat, point.lng]), legs: [],
|
||||
}))
|
||||
useSettingsStore.setState(state => ({ settings: { ...state.settings, optimize_from_accommodation: false } }))
|
||||
})
|
||||
|
||||
describe('B5 useRouteCalculation', () => {
|
||||
it.each([
|
||||
{ enabled: true, excluded: false, expected: [[48.1, 48.2], [48.3, 48.4]] },
|
||||
{ enabled: true, excluded: true, expected: [[48.1, 48.4]] },
|
||||
{ enabled: false, excluded: false, expected: [[48.1, 48.2, 48.4]] },
|
||||
])('routes enabled=$enabled excluded=$excluded using the shared day topology', async ({ enabled, excluded, expected }) => {
|
||||
seed(enabled, excluded)
|
||||
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
|
||||
await waitFor(() => expect(result.current.route?.map(run => run.map(point => point[0]))).toEqual(expected))
|
||||
expect(mocks.route.mock.calls.map(call => call[0].map((point: { lat: number }) => point.lat))).toEqual(expected)
|
||||
expect(useTripStore.getState().assignments['1']).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters hidden service stops before the Tour projection', async () => {
|
||||
mocks.mirrorServiceStops = false
|
||||
const places = seed()
|
||||
const service = buildAssignment({ day_id: 1, order_index: 0.5,
|
||||
place: buildPlace({ id: 4, lat: 48.15, lng: 11.15, stop_type: 'fuel' }) })
|
||||
useTripStore.setState(state => ({ assignments: { '1': [...state.assignments['1'], service] }, places }))
|
||||
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
|
||||
await waitFor(() => expect(result.current.route).toHaveLength(2))
|
||||
expect(mocks.route.mock.calls.map(call => call[0].map((point: { lat: number }) => point.lat)))
|
||||
.toEqual([[48.1, 48.2], [48.3, 48.4]])
|
||||
})
|
||||
|
||||
it('aborts an old calculation and prevents its late answer replacing current Place geometry', async () => {
|
||||
const places = seed()
|
||||
let release: (answer: { coordinates: number[][]; legs: never[] }) => void = () => {}
|
||||
mocks.route.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
|
||||
const { result } = renderHook(() => useRouteCalculation(useTripStore(), 1, true, 'walking'))
|
||||
const oldSignal = mocks.route.mock.calls[0][1].signal as AbortSignal
|
||||
act(() => useTripStore.setState({ places: places.map(place => place.id === 2
|
||||
? { ...place, route_geometry: '[[48.2,11.2],[48.35,11.35]]' } : place) }))
|
||||
await waitFor(() => expect(result.current.route?.[1]?.[0]).toEqual([48.35, 11.35]))
|
||||
expect(oldSignal.aborted).toBe(true)
|
||||
await act(async () => release({ coordinates: [[49, 12], [49.1, 12.1]], legs: [] }))
|
||||
expect(result.current.route?.[1]?.[0]).toEqual([48.35, 11.35])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
@@ -26,6 +27,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Charging stops / rest areas a plugin route places on the drawn line.
|
||||
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
|
||||
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
|
||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||
const places = useTripStore(s => s.places)
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
// Recompute when the selected day's whole-day default mode changes (#1281) —
|
||||
@@ -54,6 +57,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
reservations: state.reservations || [],
|
||||
accommodations,
|
||||
optimizeFromAccommodation,
|
||||
toursEnabled,
|
||||
places,
|
||||
})
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
|
||||
@@ -138,7 +143,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
|
||||
}
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
@@ -162,7 +167,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!selectedDayId) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
|
||||
|
||||
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
import { useTourPlaceIds } from './useTourPlaceIds'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
toursApi: { list: vi.fn() },
|
||||
}))
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 42,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function tourFor(placeId: number, name: string): TourListItem {
|
||||
return { ...tour, place_id: placeId, name }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(toursApi.list).mockResolvedValue({ tours: [tour] })
|
||||
})
|
||||
|
||||
describe('useTourPlaceIds', () => {
|
||||
it('loads one facet list for filtering and selected-tour lookup', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
expect(result.current.tours).toEqual([tour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
||||
expect(toursApi.list).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('hides facet identity while disabled without fetching or deleting its cached data', async () => {
|
||||
const { result, rerender } = renderHook(({ enabled }) => useTourPlaceIds(7, enabled), { initialProps: { enabled: true } })
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
||||
|
||||
rerender({ enabled: false })
|
||||
expect(result.current.tourDataReady).toBe(true)
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourPlaceIds.size).toBe(0)
|
||||
expect(toursApi.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('inserts a fresh save immediately and replaces it after an edit', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
||||
const saved = { ...tour, place_id: 43, name: 'Fresh tour' }
|
||||
|
||||
act(() => result.current.upsertTour(saved))
|
||||
expect(result.current.tours).toEqual([saved, tour])
|
||||
|
||||
const edited = { ...saved, name: 'Fresh tour edited' }
|
||||
act(() => result.current.upsertTour(edited))
|
||||
expect(result.current.tours).toEqual([edited, tour])
|
||||
})
|
||||
|
||||
it('removes a deleted Tour from the visible list before the refresh completes', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
||||
const refresh = deferred<{ tours: TourListItem[] }>()
|
||||
vi.mocked(toursApi.list).mockReturnValueOnce(refresh.promise)
|
||||
|
||||
act(() => result.current.invalidateTourPlaceIds({ removedPlaceIds: [tour.place_id] }))
|
||||
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourPlaceIds.size).toBe(0)
|
||||
await act(async () => refresh.resolve({ tours: [] }))
|
||||
await waitFor(() => expect(result.current.toursLoading).toBe(false))
|
||||
expect(result.current.tourLoadError).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only the new trip Tours after a successful trip switch', async () => {
|
||||
const tripTwoTour = tourFor(84, 'Trip two tour')
|
||||
vi.mocked(toursApi.list).mockImplementation(async tripId => ({
|
||||
tours: [tripId === 7 ? tour : tripTwoTour],
|
||||
}))
|
||||
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
||||
initialProps: { tripId: 7 },
|
||||
})
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
rerender({ tripId: 8 })
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourPlaceIds.size).toBe(0)
|
||||
expect(result.current.tourDataReady).toBe(false)
|
||||
expect(result.current.toursLoading).toBe(true)
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
|
||||
})
|
||||
|
||||
it('keeps previous-trip data hidden when the new trip refresh fails', async () => {
|
||||
vi.mocked(toursApi.list).mockImplementation(async tripId => {
|
||||
if (tripId === 8) throw new Error('Trip two unavailable')
|
||||
return { tours: [tour] }
|
||||
})
|
||||
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
||||
initialProps: { tripId: 7 },
|
||||
})
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
rerender({ tripId: 8 })
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourDataReady).toBe(false)
|
||||
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourPlaceIds.size).toBe(0)
|
||||
expect(result.current.tourDataReady).toBe(false)
|
||||
expect(result.current.toursLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a late response from the previous trip', async () => {
|
||||
const tripOne = deferred<{ tours: TourListItem[] }>()
|
||||
const tripTwoTour = tourFor(84, 'Trip two tour')
|
||||
const tripTwo = deferred<{ tours: TourListItem[] }>()
|
||||
vi.mocked(toursApi.list).mockImplementation(tripId => tripId === 7 ? tripOne.promise : tripTwo.promise)
|
||||
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
||||
initialProps: { tripId: 7 },
|
||||
})
|
||||
|
||||
rerender({ tripId: 8 })
|
||||
await act(async () => tripTwo.resolve({ tours: [tripTwoTour] }))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
|
||||
await act(async () => tripOne.resolve({ tours: [tour] }))
|
||||
|
||||
expect(result.current.tours).toEqual([tripTwoTour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
|
||||
})
|
||||
|
||||
it('lets only the latest request commit after rapid trip changes', async () => {
|
||||
const requests = new Map<string | number, ReturnType<typeof deferred<{ tours: TourListItem[] }>>>()
|
||||
vi.mocked(toursApi.list).mockImplementation(tripId => {
|
||||
const request = deferred<{ tours: TourListItem[] }>()
|
||||
requests.set(tripId, request)
|
||||
return request.promise
|
||||
})
|
||||
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
||||
initialProps: { tripId: 1 },
|
||||
})
|
||||
|
||||
rerender({ tripId: 2 })
|
||||
rerender({ tripId: 3 })
|
||||
const tripThreeTour = tourFor(93, 'Trip three tour')
|
||||
await act(async () => requests.get(3)!.resolve({ tours: [tripThreeTour] }))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tripThreeTour]))
|
||||
await act(async () => requests.get(2)!.resolve({ tours: [tourFor(92, 'Trip two tour')] }))
|
||||
await act(async () => requests.get(1)!.resolve({ tours: [tour] }))
|
||||
|
||||
expect(result.current.tours).toEqual([tripThreeTour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([93]))
|
||||
})
|
||||
|
||||
it('lets only the latest same-trip refresh commit after rapid assignment mutations', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
||||
const beforeMutation = deferred<{ tours: TourListItem[] }>()
|
||||
const afterMutation = deferred<{ tours: TourListItem[] }>()
|
||||
vi.mocked(toursApi.list).mockReset()
|
||||
.mockReturnValueOnce(beforeMutation.promise)
|
||||
.mockReturnValueOnce(afterMutation.promise)
|
||||
|
||||
let firstRefresh!: Promise<void>
|
||||
let secondRefresh!: Promise<void>
|
||||
act(() => {
|
||||
firstRefresh = result.current.reloadTourPlaceIds()
|
||||
secondRefresh = result.current.reloadTourPlaceIds()
|
||||
})
|
||||
const plannedTour = { ...tour, planned: true }
|
||||
await act(async () => { afterMutation.resolve({ tours: [plannedTour] }); await secondRefresh })
|
||||
await waitFor(() => expect(result.current.tours).toEqual([plannedTour]))
|
||||
await act(async () => { beforeMutation.resolve({ tours: [tour] }); await firstRefresh })
|
||||
|
||||
expect(result.current.tours).toEqual([plannedTour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
||||
expect(result.current.tourLoadError).toBe(false)
|
||||
})
|
||||
|
||||
it('retains same-trip last-good data and exposes a failed refresh', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
|
||||
|
||||
await act(async () => { await result.current.reloadTourPlaceIds() })
|
||||
|
||||
expect(result.current.tours).toEqual([tour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
||||
expect(result.current.tourDataReady).toBe(true)
|
||||
expect(result.current.tourLoadError).toBe(true)
|
||||
expect(result.current.toursLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a pending request update state after unmount', async () => {
|
||||
const request = deferred<{ tours: TourListItem[] }>()
|
||||
vi.mocked(toursApi.list).mockReturnValue(request.promise)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { unmount } = renderHook(() => useTourPlaceIds(7, true))
|
||||
unmount()
|
||||
|
||||
await act(async () => request.resolve({ tours: [tour] }))
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('distinguishes an empty successful list from a failed refresh', async () => {
|
||||
vi.mocked(toursApi.list).mockResolvedValueOnce({ tours: [] })
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourLoadError).toBe(false)
|
||||
|
||||
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
|
||||
await act(async () => { await result.current.reloadTourPlaceIds() })
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourDataReady).toBe(true)
|
||||
expect(result.current.tourLoadError).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let an older refresh overwrite a successful Tour upsert', async () => {
|
||||
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
const refresh = deferred<{ tours: TourListItem[] }>()
|
||||
vi.mocked(toursApi.list).mockReturnValueOnce(refresh.promise)
|
||||
act(() => { void result.current.reloadTourPlaceIds() })
|
||||
await waitFor(() => expect(result.current.toursLoading).toBe(true))
|
||||
|
||||
const savedTour = tourFor(43, 'Saved while refreshing')
|
||||
act(() => result.current.upsertTour(savedTour))
|
||||
await act(async () => refresh.resolve({ tours: [tour] }))
|
||||
|
||||
expect(result.current.tours).toEqual([savedTour, tour])
|
||||
expect(result.current.tourLoadError).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
|
||||
export interface ToursInvalidation {
|
||||
placeIds?: number[]
|
||||
removedPlaceIds?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Place ids that are tours (a `tours` facet row exists for them), used to keep
|
||||
* them out of the Places pool while the Tours addon is on.
|
||||
* The single discriminator stays the `tours`
|
||||
* facet row \u2014 this hook just mirrors it into a client-side Set for the
|
||||
* Places filter, it does not add anything to the `places` table.
|
||||
*
|
||||
* Only fetches while `enabled` is true: with the addon off there is nothing to
|
||||
* exclude, and the Places pool must stay byte-for-byte identical to today.
|
||||
*/
|
||||
export function useTourPlaceIds(tripId: number, enabled: boolean) {
|
||||
const [tourData, setTourData] = useState<{ tripId: number; tours: TourListItem[] } | null>(null)
|
||||
const [requestState, setRequestState] = useState<{
|
||||
tripId: number
|
||||
loading: boolean
|
||||
error: boolean
|
||||
} | null>(null)
|
||||
const [pendingPlaceIds, setPendingPlaceIds] = useState<{ tripId: number; ids: Set<number> } | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const mountedRef = useRef(false)
|
||||
const activeRequestRef = useRef({ tripId, enabled })
|
||||
activeRequestRef.current = { tripId, enabled }
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled || !mountedRef.current
|
||||
|| activeRequestRef.current.tripId !== tripId
|
||||
|| !activeRequestRef.current.enabled) return
|
||||
const requestId = ++requestIdRef.current
|
||||
setRequestState({ tripId, loading: true, error: false })
|
||||
try {
|
||||
const response = await toursApi.list(tripId)
|
||||
if (!mountedRef.current || requestId !== requestIdRef.current) return
|
||||
setTourData({ tripId, tours: response.tours })
|
||||
setPendingPlaceIds(current => current?.tripId === tripId ? null : current)
|
||||
setRequestState({ tripId, loading: false, error: false })
|
||||
} catch {
|
||||
if (!mountedRef.current || requestId !== requestIdRef.current) return
|
||||
// Data is keyed by trip, so this retains only a same-trip last-good list.
|
||||
setRequestState({ tripId, loading: false, error: true })
|
||||
}
|
||||
}, [tripId, enabled])
|
||||
|
||||
const invalidateTourPlaceIds = useCallback((change?: ToursInvalidation) => {
|
||||
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|
||||
|| !activeRequestRef.current.enabled) return
|
||||
const added = change?.placeIds ?? []
|
||||
const removed = change?.removedPlaceIds ?? []
|
||||
if (removed.length) {
|
||||
setTourData(current => current?.tripId === tripId
|
||||
? { tripId, tours: current.tours.filter(tour => !removed.includes(tour.place_id)) }
|
||||
: current)
|
||||
}
|
||||
if (added.length || removed.length) {
|
||||
setPendingPlaceIds(current => {
|
||||
const ids = new Set(current?.tripId === tripId ? current.ids : [])
|
||||
for (const id of removed) ids.delete(id)
|
||||
for (const id of added) ids.add(id)
|
||||
return ids.size ? { tripId, ids } : null
|
||||
})
|
||||
}
|
||||
void reload()
|
||||
}, [enabled, tripId, reload])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
void reload()
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
requestIdRef.current += 1
|
||||
}
|
||||
}, [reload])
|
||||
|
||||
const upsertTour = useCallback((tour: TourListItem) => {
|
||||
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|
||||
|| !activeRequestRef.current.enabled) return
|
||||
requestIdRef.current += 1
|
||||
setTourData(current => ({
|
||||
tripId,
|
||||
tours: [tour, ...(current?.tripId === tripId ? current.tours : [])
|
||||
.filter(item => item.place_id !== tour.place_id)],
|
||||
}))
|
||||
setRequestState({ tripId, loading: false, error: false })
|
||||
}, [enabled, tripId])
|
||||
|
||||
const tourDataReady = !enabled || tourData?.tripId === tripId
|
||||
const visibleTours = enabled && tourData?.tripId === tripId ? tourData.tours : []
|
||||
const pendingIds = enabled && pendingPlaceIds?.tripId === tripId ? pendingPlaceIds.ids : []
|
||||
const currentRequest = requestState?.tripId === tripId ? requestState : null
|
||||
|
||||
return {
|
||||
tours: visibleTours,
|
||||
toursLoading: enabled && (currentRequest?.loading ?? false),
|
||||
tourLoadError: enabled && (currentRequest?.error ?? false),
|
||||
tourDataReady,
|
||||
tourPlaceIds: new Set([...visibleTours.map(tour => tour.place_id), ...pendingIds]),
|
||||
reloadTourPlaceIds: reload,
|
||||
invalidateTourPlaceIds,
|
||||
upsertTour,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const listeners = new Set<(event: Record<string, unknown>) => void>()
|
||||
const toursApi = { list: vi.fn() }
|
||||
const handleRemoteEvent = vi.fn()
|
||||
const loadFiles = vi.fn()
|
||||
const useTripStore = Object.assign(vi.fn(() => ({ loadFiles })), {
|
||||
getState: () => ({ handleRemoteEvent }),
|
||||
})
|
||||
return {
|
||||
listeners,
|
||||
toursApi,
|
||||
handleRemoteEvent,
|
||||
loadFiles,
|
||||
useTripStore,
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.add(listener)),
|
||||
removeListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.delete(listener)),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../api/client', () => ({ toursApi: mocks.toursApi }))
|
||||
vi.mock('../api/websocket', () => ({
|
||||
joinTrip: mocks.joinTrip,
|
||||
leaveTrip: mocks.leaveTrip,
|
||||
addListener: mocks.addListener,
|
||||
removeListener: mocks.removeListener,
|
||||
}))
|
||||
vi.mock('../store/tripStore', () => ({ useTripStore: mocks.useTripStore }))
|
||||
|
||||
import { useTourPlaceIds } from './useTourPlaceIds'
|
||||
import { useTripWebSocket } from './useTripWebSocket'
|
||||
|
||||
const baseTour: TourListItem = {
|
||||
place_id: 42,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
}
|
||||
|
||||
function emit(event: Record<string, unknown>): void {
|
||||
for (const listener of [...mocks.listeners]) listener(event)
|
||||
}
|
||||
|
||||
function renderTrip(tripId: number, enabled = true) {
|
||||
return renderHook(() => {
|
||||
const tours = useTourPlaceIds(tripId, enabled)
|
||||
useTripWebSocket(tripId, tours.invalidateTourPlaceIds)
|
||||
return tours
|
||||
})
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listeners.clear()
|
||||
mocks.addListener.mockClear()
|
||||
mocks.removeListener.mockClear()
|
||||
mocks.joinTrip.mockClear()
|
||||
mocks.leaveTrip.mockClear()
|
||||
mocks.handleRemoteEvent.mockClear()
|
||||
mocks.loadFiles.mockClear()
|
||||
mocks.toursApi.list.mockReset().mockResolvedValue({ tours: [baseTour] })
|
||||
})
|
||||
|
||||
describe('Tours websocket invalidation', () => {
|
||||
it('refreshes the active trip read model and Places exclusion after tours:changed', async () => {
|
||||
const nextTour = { ...baseTour, place_id: 84, name: 'Trip 7 new tour' }
|
||||
const refresh = deferred<{ tours: TourListItem[] }>()
|
||||
mocks.toursApi.list.mockResolvedValueOnce({ tours: [baseTour] }).mockReturnValueOnce(refresh.promise)
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
||||
|
||||
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
|
||||
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
|
||||
await act(async () => refresh.resolve({ tours: [nextTour] }))
|
||||
await waitFor(() => expect(result.current.tourPlaceIds).toEqual(new Set([84])))
|
||||
expect(result.current.tours).toEqual([nextTour])
|
||||
expect(mocks.toursApi.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores events for another trip and events without an explicit trip id', async () => {
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => {
|
||||
emit({ type: 'tours:changed', tripId: 8, placeIds: [84] })
|
||||
emit({ type: 'tours:changed', placeIds: [84] })
|
||||
})
|
||||
|
||||
expect(mocks.toursApi.list).toHaveBeenCalledOnce()
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
||||
})
|
||||
|
||||
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
|
||||
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => emit({ type, tripId: 7, placeId: 42 }))
|
||||
|
||||
await waitFor(() => expect(mocks.toursApi.list).toHaveBeenCalledTimes(2))
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
|
||||
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => emit({ type, tripId: 7 }))
|
||||
|
||||
await waitFor(() => expect(mocks.toursApi.list).toHaveBeenCalledTimes(2))
|
||||
},
|
||||
)
|
||||
|
||||
it('lets the latest rapid invalidation refresh win without leaking prior results', async () => {
|
||||
const first = deferred<{ tours: TourListItem[] }>()
|
||||
const second = deferred<{ tours: TourListItem[] }>()
|
||||
const newest = { ...baseTour, place_id: 99, name: 'Newest' }
|
||||
mocks.toursApi.list.mockResolvedValueOnce({ tours: [baseTour] })
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => {
|
||||
emit({ type: 'tours:changed', tripId: 7, placeIds: [98] })
|
||||
emit({ type: 'tours:changed', tripId: 7, placeIds: [99] })
|
||||
})
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42, 98, 99]))
|
||||
await act(async () => second.resolve({ tours: [newest] }))
|
||||
await waitFor(() => expect(result.current.tours).toEqual([newest]))
|
||||
await act(async () => first.resolve({ tours: [baseTour] }))
|
||||
|
||||
expect(result.current.tours).toEqual([newest])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([99]))
|
||||
expect(mocks.toursApi.list).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('removes a pending Tour exclusion when its Place is deleted before refresh completes', async () => {
|
||||
const staleRefresh = deferred<{ tours: TourListItem[] }>()
|
||||
const deleteRefresh = deferred<{ tours: TourListItem[] }>()
|
||||
mocks.toursApi.list.mockResolvedValueOnce({ tours: [] })
|
||||
.mockReturnValueOnce(staleRefresh.promise)
|
||||
.mockReturnValueOnce(deleteRefresh.promise)
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
|
||||
act(() => emit({ type: 'place:deleted', tripId: 7, placeId: 84 }))
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set())
|
||||
|
||||
await act(async () => deleteRefresh.resolve({ tours: [] }))
|
||||
await act(async () => staleRefresh.resolve({ tours: [{ ...baseTour, place_id: 84 }] }))
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set())
|
||||
expect(mocks.toursApi.list).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('keeps same-trip last-good Tours and exposes error on refresh failure', async () => {
|
||||
const { result } = renderTrip(7)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
mocks.toursApi.list.mockRejectedValueOnce(new Error('refresh unavailable'))
|
||||
|
||||
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
|
||||
|
||||
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
|
||||
expect(result.current.tours).toEqual([baseTour])
|
||||
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
|
||||
})
|
||||
|
||||
it('leaves ordinary Place event handling intact and Tours-off refresh is a no-op', async () => {
|
||||
const { result } = renderTrip(7, false)
|
||||
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
||||
|
||||
act(() => emit({ type: 'place:created', tripId: 7, place: { id: 93, name: 'Ordinary place' } }))
|
||||
act(() => emit({ type: 'tours:changed', tripId: 7 }))
|
||||
|
||||
expect(mocks.handleRemoteEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'place:created', tripId: 7 }))
|
||||
expect(mocks.toursApi.list).not.toHaveBeenCalled()
|
||||
expect(result.current.tours).toEqual([])
|
||||
expect(result.current.tourPlaceIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,9 @@ import { useEffect } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||
import type { WebSocketEvent } from '../types'
|
||||
import type { ToursInvalidation } from './useTourPlaceIds'
|
||||
|
||||
export function useTripWebSocket(tripId: number | string | undefined) {
|
||||
export function useTripWebSocket(tripId: number | string | undefined, onToursChanged?: (change?: ToursInvalidation) => void) {
|
||||
const tripStore = useTripStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -17,13 +18,30 @@ export function useTripWebSocket(tripId: number | string | undefined) {
|
||||
}
|
||||
}
|
||||
addListener(collabFileSync)
|
||||
const toursInvalidation = (event: WebSocketEvent) => {
|
||||
if (String(event?.tripId ?? '') !== String(tripId)) return
|
||||
if (event?.type === 'tours:changed') {
|
||||
const placeIds = Array.isArray(event.placeIds)
|
||||
? event.placeIds.map(Number).filter(Number.isSafeInteger)
|
||||
: []
|
||||
onToursChanged?.(placeIds.length ? { placeIds } : undefined)
|
||||
} else if (event?.type === 'place:deleted') {
|
||||
const placeId = Number(event.placeId)
|
||||
onToursChanged?.(Number.isSafeInteger(placeId) ? { removedPlaceIds: [placeId] } : undefined)
|
||||
} else if (event?.type === 'assignment:created' || event?.type === 'assignment:deleted'
|
||||
|| event?.type === 'day:deleted') {
|
||||
onToursChanged?.()
|
||||
}
|
||||
}
|
||||
addListener(toursInvalidation)
|
||||
const localFileSync = () => tripStore.loadFiles?.(tripId)
|
||||
window.addEventListener('collab-files-changed', localFileSync)
|
||||
return () => {
|
||||
leaveTrip(tripId)
|
||||
removeListener(handler)
|
||||
removeListener(collabFileSync)
|
||||
removeListener(toursInvalidation)
|
||||
window.removeEventListener('collab-files-changed', localFileSync)
|
||||
}
|
||||
}, [tripId])
|
||||
}, [tripId, onToursChanged])
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ export type TrekScene =
|
||||
| 'notifications'
|
||||
| 'costs'
|
||||
| 'search'
|
||||
| 'tasks';
|
||||
| 'tasks'
|
||||
| 'tours';
|
||||
|
||||
export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error';
|
||||
|
||||
@@ -87,6 +88,7 @@ export default function MDancingTrek({
|
||||
<path fill="currentColor" d={MARK} />
|
||||
</g>
|
||||
<Eyes mood={face} />
|
||||
<SceneOnBody scene={scene} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
@@ -172,8 +174,84 @@ function Eyes({ mood }: { mood: TrekMood }) {
|
||||
`transform` attribute (a CSS-animation transform would override it) — any
|
||||
static rotation lives on a wrapping <g transform=…> instead. */
|
||||
|
||||
/**
|
||||
* Gear the mascot carries, drawn inside the body group so it walks, breathes and
|
||||
* hops with it. Each piece carries a surface-coloured outline: on a monochrome
|
||||
* mark that gap is the only thing telling the pack from the body behind it.
|
||||
*/
|
||||
function SceneOnBody({ scene }: { scene: TrekScene }) {
|
||||
if (scene !== 'tours') return null;
|
||||
const cut = {
|
||||
stroke: 'var(--m-bg)',
|
||||
strokeWidth: 1.6,
|
||||
paintOrder: 'stroke' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
return (
|
||||
<g>
|
||||
{/* a small smile under the eyes, glad to be out walking */}
|
||||
<path
|
||||
d="M38.2 38.9 Q40.4 41.2 42.6 38.9"
|
||||
fill="none"
|
||||
stroke="var(--m-bg)"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* the pack, slung over the left side */}
|
||||
<g className="trek-pack">
|
||||
<g transform="translate(2.6 4.2) rotate(-7 17 44)">
|
||||
{/* sleeping mat rolled on top, two straps around it */}
|
||||
<rect x="7.6" y="25.4" width="18.4" height="5.6" rx="2.8" fill="currentColor" {...cut} />
|
||||
<rect x="11.6" y="25.4" width="1.2" height="5.6" fill="var(--m-bg)" />
|
||||
<rect x="20.8" y="25.4" width="1.2" height="5.6" fill="var(--m-bg)" />
|
||||
{/* body of the pack */}
|
||||
<path
|
||||
d="M10 34 Q10 31.2 13 31.2 L21.6 31.2 Q24.6 31.2 24.6 34 L25 55.4 Q25 59 21.4 59 L12.6 59 Q9 59 9.2 55.4 Z"
|
||||
fill="currentColor"
|
||||
{...cut}
|
||||
/>
|
||||
{/* lid seam and buckle */}
|
||||
<path
|
||||
d="M10.2 38.4 Q17.4 40.6 24.7 38.4"
|
||||
fill="none"
|
||||
stroke="var(--m-bg)"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect x="16" y="38.8" width="2.8" height="3.4" rx="0.7" fill="var(--m-bg)" />
|
||||
{/* front pocket */}
|
||||
<rect x="12" y="46" width="10.6" height="9.4" rx="2.6" fill="none" stroke="var(--m-bg)" strokeWidth="1.1" />
|
||||
<path d="M12.2 49.2 H22.4" stroke="var(--m-bg)" strokeWidth="0.9" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneBack({ scene }: { scene: TrekScene }) {
|
||||
switch (scene) {
|
||||
case 'tours':
|
||||
// the hills it is heading for, faint behind it, and the trail running under its feet
|
||||
return (
|
||||
<g>
|
||||
<g className="trek-hills" fill="currentColor">
|
||||
<path d="M44 73 L62 41 L69.5 52 L74.5 46 L88 73 Z" opacity="0.14" />
|
||||
<path
|
||||
d="M58.6 47.2 L62 41 L65.6 46.4 L63.4 45.2 L61.6 47.6 Z M72.6 49 L74.5 46 L77 50.2 L75.4 49.4 Z"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
className="trek-trail"
|
||||
d="M10 77.5 H78"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="3.4 4.6"
|
||||
opacity="0.35"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
case 'transport':
|
||||
// skateboard under the body — deck + two wheels spinning in place
|
||||
return (
|
||||
@@ -303,6 +381,27 @@ function SceneBack({ scene }: { scene: TrekScene }) {
|
||||
|
||||
function SceneFront({ scene }: { scene: TrekScene }) {
|
||||
switch (scene) {
|
||||
case 'tours':
|
||||
// a trekking pole swinging forward and planting in step with the walk
|
||||
return (
|
||||
<g transform="rotate(12 70 37)">
|
||||
<g className="trek-pole">
|
||||
<g stroke="var(--m-bg)" strokeWidth="1.6" style={{ paintOrder: 'stroke' }} strokeLinejoin="round">
|
||||
<rect x="69.1" y="40.5" width="2.2" height="33.5" rx="1.1" fill="currentColor" />
|
||||
<rect x="67.9" y="34" width="4.6" height="9" rx="2" fill="currentColor" />
|
||||
</g>
|
||||
<path
|
||||
d="M68.2 35.6 Q63.8 37.6 66.6 42"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect x="68.6" y="38.2" width="3.2" height="0.9" rx="0.45" fill="var(--m-bg)" />
|
||||
<ellipse cx="70.2" cy="69" rx="3.4" ry="1.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
// Files reuse the booking ticket per design — a card getting written on.
|
||||
case 'bookings':
|
||||
case 'files':
|
||||
|
||||
@@ -604,6 +604,46 @@
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
/* tours: a walking step, the pole swinging forward and planting, the pack riding along */
|
||||
@keyframes trek-walk {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
25%,
|
||||
75% {
|
||||
transform: translateY(-1.6px);
|
||||
}
|
||||
}
|
||||
@keyframes trek-pole {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
45% {
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
55% {
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
}
|
||||
@keyframes trek-pack {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
25%,
|
||||
75% {
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
}
|
||||
@keyframes trek-trail {
|
||||
to {
|
||||
stroke-dashoffset: 8;
|
||||
}
|
||||
}
|
||||
|
||||
.trek {
|
||||
animation: trek-in 480ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||
@@ -654,6 +694,9 @@
|
||||
.trek-bounce--transport {
|
||||
animation: trek-sk-body 3.6s ease-in-out infinite;
|
||||
}
|
||||
.trek-bounce--tours {
|
||||
animation: trek-walk 1.6s ease-in-out infinite;
|
||||
}
|
||||
/* per-scene props */
|
||||
.trek-board {
|
||||
animation: trek-sk-board 3.6s ease-in-out infinite;
|
||||
@@ -760,6 +803,19 @@
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.trek-pole {
|
||||
animation: trek-pole 1.6s ease-in-out infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
.trek-pack {
|
||||
animation: trek-pack 1.6s ease-in-out infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: 90% 10%;
|
||||
}
|
||||
.trek-trail {
|
||||
animation: trek-trail 0.8s linear infinite;
|
||||
}
|
||||
/* staggered starts for written lines / poll bars / Zzz */
|
||||
.trek-d2,
|
||||
.trek-z2 {
|
||||
@@ -821,6 +877,10 @@
|
||||
.trek-bounce--idle,
|
||||
.trek-bounce--polls,
|
||||
.trek-bounce--transport,
|
||||
.trek-bounce--tours,
|
||||
.trek-pole,
|
||||
.trek-pack,
|
||||
.trek-trail,
|
||||
.trek-board,
|
||||
.trek-wheel,
|
||||
.trek-flag,
|
||||
|
||||
@@ -230,7 +230,10 @@ export default function MSettingsGeneral() {
|
||||
title={t('settings.startTripTab')}
|
||||
value={startTripTab}
|
||||
onSelect={(v) => save('start_trip_tab', v)}
|
||||
options={TRIP_TAB_IDS.map((id) => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
|
||||
options={TRIP_TAB_IDS.filter((id) => id !== 'tour-planner').map((id) => ({
|
||||
value: id,
|
||||
label: t(TRIP_TAB_LABEL_KEYS[id]),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -305,6 +305,10 @@ export default function MTripShell({
|
||||
|
||||
const trTab = planner.activeTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (trTab === 'tour-planner') planner.handleTabChange('plan');
|
||||
}, [trTab, planner.handleTabChange]);
|
||||
|
||||
const setTrTab = (tabId: string) => {
|
||||
planner.handleTabChange(tabId);
|
||||
if (mode === 'browse') setMode('go');
|
||||
@@ -443,9 +447,10 @@ export default function MTripShell({
|
||||
}
|
||||
if (!trip) return null;
|
||||
|
||||
const enabledTabIds = new Set(planner.TRIP_TABS.map((tab) => tab.id));
|
||||
const mobileTabs = planner.TRIP_TABS.filter((tab) => !tab.desktopOnly);
|
||||
const enabledTabIds = new Set(mobileTabs.map((tab) => tab.id));
|
||||
const dockTabs = pickDockTabs(enabledTabIds);
|
||||
const tabLabel = (id: string) => planner.TRIP_TABS.find((tab) => tab.id === id)?.label ?? id;
|
||||
const tabLabel = (id: string) => mobileTabs.find((tab) => tab.id === id)?.label ?? id;
|
||||
|
||||
// While the trip runs, today's chip carries a ring and a button jumps back to it
|
||||
// from wherever the rail was scrolled (#2392). Null outside the trip's dates.
|
||||
|
||||
@@ -21,6 +21,7 @@ import MarkdownText from '../../../../components/shared/MarkdownText';
|
||||
import PlaceAvatar from '../../../../components/shared/PlaceAvatar';
|
||||
import { useToast } from '../../../../components/shared/Toast';
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons';
|
||||
import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useAddonStore } from '../../../../store/addonStore';
|
||||
import { useTripStore } from '../../../../store/tripStore';
|
||||
@@ -32,26 +33,35 @@ import type { MPlacesBrowserProps } from '../MTripShell';
|
||||
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet';
|
||||
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls';
|
||||
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet';
|
||||
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch';
|
||||
import MToursSelectionList from './MToursSelectionList';
|
||||
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel';
|
||||
|
||||
/**
|
||||
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
|
||||
* chips, search, the rating + category filter panel, multi-select with the bulk
|
||||
* chips, search, the category filter panel, multi-select with the bulk
|
||||
* toolbar (delete / category / save to collection) and the place list with
|
||||
* DAY badge / quick-add. The pool filter, the category set and the rating floor
|
||||
* live in the trip store, so the map markers filter with the exact same values
|
||||
* (#1541).
|
||||
* DAY badge / quick-add. The pool filter and the category set live in the
|
||||
* trip store, so the map markers filter with the exact same values (#1541).
|
||||
*
|
||||
* Row taps and quick-add open the 'bract' place-actions sheet via
|
||||
* shell.openSheet('bract', { placeId, dayPicker }) — the sheet host renders
|
||||
* it. The header ellipsis opens the 'import' sheet (sheets/MImportSheet).
|
||||
*/
|
||||
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
|
||||
const { t, places, categories, assignments, days, trip } = planner;
|
||||
const { t, places, categories, assignments, days, trip, tripId } = planner;
|
||||
// The planner hook carries `t` but not the locale; day labels need both.
|
||||
const { locale } = useTranslation();
|
||||
const canEditPlaces = planner.can('place_edit', trip);
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'));
|
||||
const toursEnabled = useAddonStore((s) => s.isEnabled('tours'));
|
||||
// Places that are tours (a `tours` facet row exists) — kept out of the
|
||||
// Places pool entirely while the addon is on.
|
||||
const { tourPlaceIds } = useTourPlaceIds(tripId, toursEnabled);
|
||||
// Top level of the places browser, mirroring the desktop right add-panel's
|
||||
// Places <-> Tours switch placement.
|
||||
const [toursMode, setToursMode] = useState(false);
|
||||
const [toursFilter, setToursFilter] = useState<'all' | 'unplanned' | 'planned'>('all');
|
||||
|
||||
const filter = useTripStore((s) => s.placesFilter);
|
||||
const setFilter = useTripStore((s) => s.setPlacesFilter);
|
||||
@@ -73,9 +83,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
if (shell.browseFromEdit) setFilter('unplanned');
|
||||
}, [shell.browseFromEdit, setFilter]);
|
||||
|
||||
// Whether to offer the "Tracks" chip; useTripPlanner moves the filter back to
|
||||
// "all" once the last track is gone.
|
||||
const hasTracks = useMemo(() => places.some((p) => p.route_geometry), [places]);
|
||||
const hasTracks = useMemo(() => !toursEnabled && places.some((p) => p.route_geometry), [places, toursEnabled]);
|
||||
|
||||
// A hotel is linked through its stay and a venue through its booking; neither is
|
||||
// ever dragged onto a day, and the pool used to call both unplanned (#2072).
|
||||
@@ -84,10 +92,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
[assignments, planner.tripAccommodations, planner.reservations]
|
||||
);
|
||||
const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days]);
|
||||
const filtered = useMemo(
|
||||
() => filterPool(places, { filter, categoryFilters, ratingFilter, search, plannedIds }),
|
||||
[places, filter, categoryFilters, ratingFilter, search, plannedIds]
|
||||
const poolPlaces = useMemo(
|
||||
() => (toursEnabled ? places.filter((place) => !tourPlaceIds.has(place.id)) : places),
|
||||
[places, toursEnabled, tourPlaceIds]
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
return filterPool(poolPlaces, { filter, categoryFilters, ratingFilter, search, plannedIds });
|
||||
}, [poolPlaces, filter, categoryFilters, ratingFilter, search, plannedIds]);
|
||||
|
||||
// A bulk delete (or a remote edit) can remove selected places — drop the
|
||||
// stale ids so the toolbar count stays honest.
|
||||
@@ -131,10 +142,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
|
||||
// Compare the ids, not just the counts: a place removed remotely while another
|
||||
// one is selected keeps the sizes equal without the sets matching.
|
||||
const allSelected = filtered.length > 0 && filtered.every((p) => selectedIds.has(p.id));
|
||||
const selectablePlaces = filtered.filter((place) => !planner.isTourPlace(place.id));
|
||||
const allSelected = selectablePlaces.length > 0 && selectablePlaces.every((p) => selectedIds.has(p.id));
|
||||
const toggleAllVisible = () => {
|
||||
if (allSelected) setSelectedIds(new Set());
|
||||
else setSelectedIds(new Set(filtered.map((p) => p.id)));
|
||||
else setSelectedIds(new Set(selectablePlaces.map((p) => p.id)));
|
||||
};
|
||||
|
||||
const openAddPlace = () => {
|
||||
@@ -146,289 +158,324 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
|
||||
const openRow = (place: Place) => {
|
||||
if (selectMode) {
|
||||
if (planner.isTourPlace(place.id)) {
|
||||
planner.handlePlaceClick(place.id);
|
||||
return;
|
||||
}
|
||||
toggleSelected(place.id);
|
||||
return;
|
||||
}
|
||||
shell.openSheet('bract', { placeId: place.id, dayPicker: false });
|
||||
};
|
||||
|
||||
// The panel holds the categories and the rating floor; its badge counts them the
|
||||
// way the map's Filters badge does (one per kind of filter). The pool is left out:
|
||||
// its chips sit in plain sight above the panel.
|
||||
const selectedTours = [...selectedIds].filter(planner.isTourPlace);
|
||||
const panelFilterCount = countActivePlacesFilters({ filter: 'all', categoryFilters, ratingFilter });
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
|
||||
{/* ── Filter chips + Import (Add sits in the search row below to free space) ── */}
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<FilterChip
|
||||
active={filter === 'all'}
|
||||
label={t('places.all')}
|
||||
onClick={() => {
|
||||
setFilter('all');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'unplanned'}
|
||||
label={t('places.unplanned')}
|
||||
onClick={() => {
|
||||
setFilter('unplanned');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'planned'}
|
||||
label={t('places.planned')}
|
||||
onClick={() => {
|
||||
setFilter('planned');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
{hasTracks && (
|
||||
<FilterChip
|
||||
active={filter === 'tracks'}
|
||||
label={t('places.filterTracks')}
|
||||
onClick={() => {
|
||||
setFilter('tracks');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shell.openSheet('import')}
|
||||
aria-label={t('mobileTrip.importPlaces')}
|
||||
className="flex h-10 w-10 flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search + category filter + select toggle ── */}
|
||||
<div className="mt-[10px] flex items-stretch gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
if (selectMode) setSelectedIds(new Set());
|
||||
}}
|
||||
placeholder={t('places.search')}
|
||||
className="box-border min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-[0.8125rem] font-medium text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatOpen((v) => !v)}
|
||||
aria-expanded={catOpen}
|
||||
aria-label={t('places.filters')}
|
||||
className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
<SlidersHorizontal size={15} strokeWidth={2} />
|
||||
{panelFilterCount > 0 && (
|
||||
<span className="absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-m-act px-1 font-geist text-[0.5625rem] font-bold text-m-actfg">
|
||||
{panelFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectMode((v) => !v);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
aria-pressed={selectMode}
|
||||
aria-label={t('common.select')}
|
||||
className={`flex w-[42px] flex-none items-center justify-center rounded-full border ${
|
||||
selectMode
|
||||
? 'border-[color:var(--m-act)] bg-m-act text-m-actfg'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{selectMode ? <X size={15} strokeWidth={2} /> : <ListChecks size={15} strokeWidth={2} />}
|
||||
</button>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPlace}
|
||||
aria-label={t('common.add')}
|
||||
className="flex w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stays Dawarich recorded on these dates (#2279). Same component as the
|
||||
desktop rail — the rows are the same rows, and the panel renders
|
||||
nothing when there is nothing pending. */}
|
||||
<div className="mt-3">
|
||||
<DawarichSuggestionsPanel
|
||||
tripId={planner.tripId}
|
||||
trips={[{ id: planner.tripId, label: t('dawarich.accept.thisTrip') }]}
|
||||
daysForTrip={() =>
|
||||
days.map((day) => ({
|
||||
id: day.id,
|
||||
...formatDayOption(day.day_number, day.date, locale, t),
|
||||
}))
|
||||
}
|
||||
// The place it just created belongs on the map and in the list
|
||||
// now, not after a reload.
|
||||
onAccepted={() => {
|
||||
void refreshTripAfterAccept(planner.tripId);
|
||||
}}
|
||||
initiallyCollapsed
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Selection toolbar ── */}
|
||||
{selectMode && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] pl-[14px] pr-[6px] backdrop-blur-[20px]">
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-muted">
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-[5px]">
|
||||
<BulkBtn label={allSelected ? t('common.deselectAll') : t('common.selectAll')} onClick={toggleAllVisible}>
|
||||
<CheckCheck size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
<BulkBtn
|
||||
label={t('places.changeCategory')}
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setCategoryPickerOpen(true)}
|
||||
>
|
||||
<Tag size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('inspector.saveToCollection')}
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setSaveToListOpen(true)}
|
||||
>
|
||||
<Bookmark size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('collections.markVisitedSelection')}
|
||||
disabled={selectedIds.size === 0 || markVisitedBusy}
|
||||
onClick={markSelectionVisited}
|
||||
>
|
||||
{markVisitedBusy ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} strokeWidth={2} />
|
||||
)}
|
||||
</BulkBtn>
|
||||
)}
|
||||
<BulkBtn
|
||||
label={t('places.deleteSelected')}
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
{toursEnabled && <MPlacesToursModeSwitch active={toursMode} onChange={setToursMode} />}
|
||||
{toursEnabled && toursMode ? (
|
||||
<>
|
||||
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
|
||||
buttons: tours are attached here, never created or bulk-managed. */}
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<FilterChip
|
||||
active={toursFilter === 'all'}
|
||||
label={t('places.all')}
|
||||
onClick={() => setToursFilter('all')}
|
||||
/>
|
||||
<FilterChip
|
||||
active={toursFilter === 'unplanned'}
|
||||
label={t('places.unplanned')}
|
||||
onClick={() => setToursFilter('unplanned')}
|
||||
/>
|
||||
<FilterChip
|
||||
active={toursFilter === 'planned'}
|
||||
label={t('places.planned')}
|
||||
onClick={() => setToursFilter('planned')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Rating + category filter panel ── */}
|
||||
{catOpen && (
|
||||
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
|
||||
<div className="border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px]">
|
||||
<MRatingFloorChips onPick={() => setSelectedIds(new Set())} />
|
||||
</div>
|
||||
<MCategoryFilterList categories={categories} places={places} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Count divider ── */}
|
||||
<div className="mb-1 mt-[14px] flex items-center gap-[10px]">
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
</div>
|
||||
|
||||
{/* ── Place list ── */}
|
||||
{filtered.length === 0 ? (
|
||||
filter === 'unplanned' && !search && categoryFilters.size === 0 && ratingFilter === 'all' ? (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="idle" mood="happy" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.allPlanned')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="search" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.noneFound')}</p>
|
||||
</div>
|
||||
)
|
||||
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
|
||||
</>
|
||||
) : (
|
||||
filtered.map((place) => {
|
||||
const cat = place.category_id != null ? categories.find((c) => c.id === place.category_id) : undefined;
|
||||
const CatIcon = getCategoryIcon(cat?.icon);
|
||||
const dayNumber = dayNumberByPlace.get(place.id);
|
||||
const sub = place.address || place.description;
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
className="flex items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[2px] py-[9px]"
|
||||
>
|
||||
<>
|
||||
{/* ── Filter chips + Import (Add sits in the search row below to free space) ── */}
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<FilterChip
|
||||
active={filter === 'all'}
|
||||
label={t('places.all')}
|
||||
onClick={() => {
|
||||
setFilter('all');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'unplanned'}
|
||||
label={t('places.unplanned')}
|
||||
onClick={() => {
|
||||
setFilter('unplanned');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'planned'}
|
||||
label={t('places.planned')}
|
||||
onClick={() => {
|
||||
setFilter('planned');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
{hasTracks && (
|
||||
<FilterChip
|
||||
active={filter === 'tracks'}
|
||||
label={t('places.filterTracks')}
|
||||
onClick={() => {
|
||||
setFilter('tracks');
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openRow(place)}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] text-left"
|
||||
onClick={() => shell.openSheet('import')}
|
||||
aria-label={t('mobileTrip.importPlaces')}
|
||||
className="flex h-10 w-10 flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
{selectMode && <SquareCheck big checked={selectedIds.has(place.id)} />}
|
||||
<PlaceAvatar place={place} category={cat} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-[6px]">
|
||||
{/* Stroke in the track's colour — the mobile map is full-bleed
|
||||
with no sidebar, so this is the only thing tying a line to
|
||||
a row (#776). */}
|
||||
{place.route_geometry && (
|
||||
<span
|
||||
className="h-[3px] w-[14px] flex-none rounded-full"
|
||||
style={{ background: resolveTrackColor(place) }}
|
||||
/>
|
||||
)}
|
||||
<CatIcon
|
||||
size={12}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: cat?.color || 'var(--m-muted)' }}
|
||||
/>
|
||||
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{place.name}</span>
|
||||
</span>
|
||||
{sub && (
|
||||
<MarkdownText clamp className="mt-px font-geist text-[0.65625rem] text-m-muted">
|
||||
{sub}
|
||||
</MarkdownText>
|
||||
)}
|
||||
</div>
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</button>
|
||||
{!selectMode && dayNumber != null && (
|
||||
<span className="flex-none whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[9px] py-1 font-geist text-[0.59375rem] font-bold uppercase tracking-[.05em] text-m-muted">
|
||||
{t('planner.dayN', { n: dayNumber })}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search + category filter + select toggle ── */}
|
||||
<div className="mt-[10px] flex items-stretch gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
if (selectMode) setSelectedIds(new Set());
|
||||
}}
|
||||
placeholder={t('places.search')}
|
||||
className="box-border min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-[0.8125rem] font-medium text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatOpen((v) => !v)}
|
||||
aria-expanded={catOpen}
|
||||
aria-label={t('places.filters')}
|
||||
className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
<SlidersHorizontal size={15} strokeWidth={2} />
|
||||
{panelFilterCount > 0 && (
|
||||
<span className="absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-m-act px-1 font-geist text-[0.5625rem] font-bold text-m-actfg">
|
||||
{panelFilterCount}
|
||||
</span>
|
||||
)}
|
||||
{!selectMode && dayNumber == null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shell.openSheet('bract', { placeId: place.id, dayPicker: true })}
|
||||
aria-label={t('places.assignToDay')}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
</button>
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectMode((v) => !v);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
aria-pressed={selectMode}
|
||||
aria-label={t('common.select')}
|
||||
className={`flex w-[42px] flex-none items-center justify-center rounded-full border ${
|
||||
selectMode
|
||||
? 'border-[color:var(--m-act)] bg-m-act text-m-actfg'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{selectMode ? <X size={15} strokeWidth={2} /> : <ListChecks size={15} strokeWidth={2} />}
|
||||
</button>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPlace}
|
||||
aria-label={t('common.add')}
|
||||
className="flex w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stays Dawarich recorded on these dates (#2279). Same component as the
|
||||
desktop rail — the rows are the same rows, and the panel renders
|
||||
nothing when there is nothing pending. */}
|
||||
<div className="mt-3">
|
||||
<DawarichSuggestionsPanel
|
||||
tripId={planner.tripId}
|
||||
trips={[{ id: planner.tripId, label: t('dawarich.accept.thisTrip') }]}
|
||||
daysForTrip={() =>
|
||||
days.map((day) => ({
|
||||
id: day.id,
|
||||
...formatDayOption(day.day_number, day.date, locale, t),
|
||||
}))
|
||||
}
|
||||
// The place it just created belongs on the map and in the list
|
||||
// now, not after a reload.
|
||||
onAccepted={() => {
|
||||
void refreshTripAfterAccept(planner.tripId);
|
||||
}}
|
||||
initiallyCollapsed
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Selection toolbar ── */}
|
||||
{selectMode && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] pl-[14px] pr-[6px] backdrop-blur-[20px]">
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-muted">
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-[5px]">
|
||||
<BulkBtn
|
||||
label={allSelected ? t('common.deselectAll') : t('common.selectAll')}
|
||||
onClick={toggleAllVisible}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
<CheckCheck size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
<BulkBtn
|
||||
label={t('places.changeCategory')}
|
||||
disabled={selectedIds.size === 0 || selectedTours.length > 0}
|
||||
onClick={() => setCategoryPickerOpen(true)}
|
||||
>
|
||||
<Tag size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('inspector.saveToCollection')}
|
||||
disabled={selectedIds.size === 0 || selectedTours.length > 0}
|
||||
onClick={() => setSaveToListOpen(true)}
|
||||
>
|
||||
<Bookmark size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('collections.markVisitedSelection')}
|
||||
disabled={selectedIds.size === 0 || selectedTours.length > 0 || markVisitedBusy}
|
||||
onClick={markSelectionVisited}
|
||||
>
|
||||
{markVisitedBusy ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} strokeWidth={2} />
|
||||
)}
|
||||
</BulkBtn>
|
||||
)}
|
||||
<BulkBtn
|
||||
label={t('places.deleteSelected')}
|
||||
disabled={selectedIds.size === 0 || selectedTours.length > 0}
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* ── Rating + category filter panel ── */}
|
||||
{catOpen && (
|
||||
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
|
||||
<div className="border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px]">
|
||||
<MRatingFloorChips onPick={() => setSelectedIds(new Set())} />
|
||||
</div>
|
||||
<MCategoryFilterList categories={categories} places={poolPlaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Count divider ── */}
|
||||
<div className="mb-1 mt-[14px] flex items-center gap-[10px]">
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
</div>
|
||||
|
||||
{/* ── Place list ── */}
|
||||
{filtered.length === 0 ? (
|
||||
filter === 'unplanned' && !search && categoryFilters.size === 0 && ratingFilter === 'all' ? (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="idle" mood="happy" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.allPlanned')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="search" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.noneFound')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
filtered.map((place) => {
|
||||
const cat = place.category_id != null ? categories.find((c) => c.id === place.category_id) : undefined;
|
||||
const CatIcon = getCategoryIcon(cat?.icon);
|
||||
const dayNumber = dayNumberByPlace.get(place.id);
|
||||
const sub = place.address || place.description;
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
className="flex items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[2px] py-[9px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openRow(place)}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] text-left"
|
||||
>
|
||||
{selectMode && !planner.isTourPlace(place.id) && (
|
||||
<SquareCheck big checked={selectedIds.has(place.id)} />
|
||||
)}
|
||||
<PlaceAvatar place={place} category={cat} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-[6px]">
|
||||
{/* Stroke in the track's colour — the mobile map is full-bleed
|
||||
with no sidebar, so this is the only thing tying a line to
|
||||
a row (#776). */}
|
||||
{place.route_geometry && (
|
||||
<span
|
||||
className="h-[3px] w-[14px] flex-none rounded-full"
|
||||
style={{ background: resolveTrackColor(place) }}
|
||||
/>
|
||||
)}
|
||||
<CatIcon
|
||||
size={12}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: cat?.color || 'var(--m-muted)' }}
|
||||
/>
|
||||
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{place.name}</span>
|
||||
</span>
|
||||
{sub && (
|
||||
<MarkdownText clamp className="mt-px font-geist text-[0.65625rem] text-m-muted">
|
||||
{sub}
|
||||
</MarkdownText>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!selectMode && dayNumber != null && (
|
||||
<span className="flex-none whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[9px] py-1 font-geist text-[0.59375rem] font-bold uppercase tracking-[.05em] text-m-muted">
|
||||
{t('planner.dayN', { n: dayNumber })}
|
||||
</span>
|
||||
)}
|
||||
{!selectMode && dayNumber == null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shell.openSheet('bract', { placeId: place.id, dayPicker: true })}
|
||||
aria-label={t('places.assignToDay')}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -463,8 +510,12 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
open={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
title={t('places.deleteSelected')}
|
||||
message={t('trip.confirm.deletePlaces', { count: selectedIds.size })}
|
||||
confirmLabel={t('common.delete')}
|
||||
message={
|
||||
selectedTours.length > 0
|
||||
? t('tours.delete.bulkConfirmBody')
|
||||
: t('trip.confirm.deletePlaces', { count: selectedIds.size })
|
||||
}
|
||||
confirmLabel={selectedTours.length > 0 ? t('tours.delete.confirmAction') : t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MPlacesFilterSheetProps {
|
||||
onClose: () => void;
|
||||
places: Place[];
|
||||
categories: Category[];
|
||||
toursEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Uppercase eyebrow over each group of the sheet. */
|
||||
@@ -28,7 +29,13 @@ function SectionTitle({ children }: { children: string }) {
|
||||
* choice lands in the trip store at once, so the markers behind the sheet and the
|
||||
* places list change with it (#1541). Routed as shell sheet 'placesFilter'.
|
||||
*/
|
||||
export default function MPlacesFilterSheet({ open, onClose, places, categories }: MPlacesFilterSheetProps) {
|
||||
export default function MPlacesFilterSheet({
|
||||
open,
|
||||
onClose,
|
||||
places,
|
||||
categories,
|
||||
toursEnabled,
|
||||
}: MPlacesFilterSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const filter = useTripStore((s) => s.placesFilter);
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter);
|
||||
@@ -42,7 +49,8 @@ export default function MPlacesFilterSheet({ open, onClose, places, categories }
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
{ id: 'planned', label: t('places.planned') },
|
||||
];
|
||||
if (places.some((p) => p.route_geometry)) pools.push({ id: 'tracks', label: t('places.filterTracks') });
|
||||
if (!toursEnabled && places.some((p) => p.route_geometry))
|
||||
pools.push({ id: 'tracks', label: t('places.filterTracks') });
|
||||
const hasCategoryChoice = categories.length > 0 || places.some((p) => p.category_id == null);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MapPin, Mountain } from 'lucide-react';
|
||||
import { useTranslation } from '../../../../i18n/TranslationContext';
|
||||
|
||||
interface MPlacesToursModeSwitchProps {
|
||||
active: boolean;
|
||||
onChange: (tours: boolean) => void;
|
||||
}
|
||||
|
||||
/** Mobile counterpart of PlacesToursModeSwitch: top-level "Places <-> Tours"
|
||||
* switch of the places browser, with m-token styling to match the browser chrome. */
|
||||
export default function MPlacesToursModeSwitch({ active, onChange }: MPlacesToursModeSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const options: [boolean, string, typeof MapPin][] = [
|
||||
[false, t('tours.mode.places'), MapPin],
|
||||
[true, t('tours.mode.tours'), Mountain],
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('tours.mode.label')}
|
||||
className="mb-3 flex gap-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-1"
|
||||
>
|
||||
{options.map(([value, label, Icon]) => {
|
||||
const selected = active === value;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(value)}
|
||||
className={`flex h-[34px] flex-1 items-center justify-center gap-1.5 rounded-[10px] px-2 text-[0.78125rem] transition-colors ${
|
||||
selected ? 'bg-m-card font-semibold text-m-ink shadow-sm' : 'font-medium text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import de from '@trek/shared/i18n/de';
|
||||
import en from '@trek/shared/i18n/en';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { MTripShellApi, TripPlanner } from '../MTripShell';
|
||||
import MToursSelectionList from './MToursSelectionList';
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 42,
|
||||
name: 'Camping tour with a deliberately long readable name',
|
||||
tour_type: 'hike',
|
||||
distance: 12.5,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 390,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
has_waypoints: true,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
const reloadTourPlaceIds = vi.fn().mockResolvedValue(undefined);
|
||||
const handlePlaceClick = vi.fn();
|
||||
const openSheet = vi.fn();
|
||||
|
||||
function planner(): TripPlanner {
|
||||
return {
|
||||
t: (key: string) => {
|
||||
const value = en[key];
|
||||
return typeof value === 'string' ? value : key;
|
||||
},
|
||||
tripId: 7,
|
||||
tours: [tour],
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
selectedTour: null,
|
||||
selectedDayId: null,
|
||||
selectedAssignmentId: null,
|
||||
assignments: {},
|
||||
days: [],
|
||||
files: [],
|
||||
canUploadFiles: false,
|
||||
reloadTourPlaceIds,
|
||||
handlePlaceClick,
|
||||
} as unknown as TripPlanner;
|
||||
}
|
||||
|
||||
function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi {
|
||||
return {
|
||||
sheet,
|
||||
openSheet,
|
||||
} as unknown as MTripShellApi;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
describe('MToursSelectionList', () => {
|
||||
it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => {
|
||||
const germanPlanner = {
|
||||
...planner(),
|
||||
t: (key: string) => {
|
||||
const value = de[key] ?? en[key];
|
||||
return typeof value === 'string' ? value : key;
|
||||
},
|
||||
};
|
||||
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument();
|
||||
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern');
|
||||
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument();
|
||||
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />);
|
||||
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument();
|
||||
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/);
|
||||
});
|
||||
|
||||
it('gives a tour name the full row and up to two lines', () => {
|
||||
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />);
|
||||
|
||||
const name = screen.getByText(tour.name);
|
||||
expect(name).toHaveClass('line-clamp-2', 'break-words');
|
||||
expect(name).toHaveAttribute('title', tour.name);
|
||||
expect(name).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
|
||||
const { container } = render(
|
||||
<MToursSelectionList
|
||||
planner={{ ...planner(), selectedPlaceId: tour.place_id }}
|
||||
shell={shell()}
|
||||
filter="all"
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true');
|
||||
const metrics = screen.getByTestId('mobile-tour-metrics');
|
||||
expect(metrics).toHaveTextContent('12.5 km');
|
||||
expect(metrics).toHaveTextContent('420 m');
|
||||
expect(metrics).toHaveTextContent('390 m');
|
||||
for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap');
|
||||
expect(metrics.children[0].getAttribute('aria-label')).toContain('12.5 km');
|
||||
expect(metrics.children[1].getAttribute('aria-label')).toContain('420 m');
|
||||
expect(metrics.children[2].getAttribute('aria-label')).toContain('390 m');
|
||||
expect(screen.getByText('TREK')).toBeInTheDocument();
|
||||
expect(screen.getByTitle(en['tours.planner.difficulty.t2'] as string)).toHaveAttribute(
|
||||
'aria-label',
|
||||
en['tours.planner.difficulty.t2']
|
||||
);
|
||||
const add = screen.getByRole('button', { name: 'Add to day' });
|
||||
expect(add).toHaveClass('w-full');
|
||||
add.click();
|
||||
expect(openSheet).toHaveBeenCalledWith('bract', { placeId: tour.place_id, dayPicker: true });
|
||||
expect(container.querySelector('[data-testid="mobile-tour-metrics"]')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a long German name and source/difficulty labels intact', () => {
|
||||
const germanPlanner = {
|
||||
...planner(),
|
||||
tours: [
|
||||
{ ...tour, name: 'Eine sehr lange deutsche Wanderung durch das abgelegene Gebirge', has_waypoints: false },
|
||||
],
|
||||
t: (key: string) => {
|
||||
const value = de[key] ?? en[key];
|
||||
return typeof value === 'string' ? value : key;
|
||||
},
|
||||
};
|
||||
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />);
|
||||
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('GPX-Import')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
|
||||
const { rerender } = render(
|
||||
<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />
|
||||
);
|
||||
|
||||
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />);
|
||||
|
||||
expect(reloadTourPlaceIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, CalendarDays, Mountain } from 'lucide-react';
|
||||
import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation';
|
||||
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import type { MTripShellApi, TripPlanner } from '../MTripShell';
|
||||
|
||||
interface MToursSelectionListProps extends TourPermissionProps {
|
||||
planner: TripPlanner;
|
||||
shell: MTripShellApi;
|
||||
/** All / Unplanned / Planned — same three states as the Places pool. */
|
||||
filter: 'all' | 'unplanned' | 'planned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours mode of the mobile places browser: a selection list that mirrors
|
||||
* the desktop ToursSidebar's data, with viewing and day assignment only.
|
||||
* This list does not provide GPX import.
|
||||
*
|
||||
* "Add to day" reuses the existing 'bract' place-actions sheet's day picker
|
||||
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
|
||||
* Place under the hood, so no new linking code is needed here either.
|
||||
*/
|
||||
export default function MToursSelectionList({
|
||||
planner,
|
||||
shell,
|
||||
filter,
|
||||
canAssign: assignPermission,
|
||||
}: MToursSelectionListProps) {
|
||||
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission });
|
||||
const { t } = planner;
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
const tours: TourListItem[] = planner.tours;
|
||||
|
||||
const filtered = tours.filter((tr) => {
|
||||
if (filter === 'unplanned') return !tr.planned;
|
||||
if (filter === 'planned') return tr.planned;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
|
||||
{t('tours.planner.mobileHint')}
|
||||
</p>
|
||||
<div className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
|
||||
<Mountain className="mx-auto mb-2 h-6 w-6 text-m-faint" strokeWidth={1.6} />
|
||||
<p className="font-semibold text-m-ink">{t('tours.empty.title')}</p>
|
||||
<p className="mt-1 font-geist text-[0.78125rem] text-m-muted">{t('tours.empty.body')}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
|
||||
{t('tours.planner.mobileHint')}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{filtered.map((tour) => {
|
||||
const isSelected = planner.selectedPlaceId === tour.place_id;
|
||||
return (
|
||||
<li
|
||||
key={tour.place_id}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => planner.handlePlaceClick(tour.place_id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
planner.handlePlaceClick(tour.place_id);
|
||||
}
|
||||
}}
|
||||
className="rounded-2xl border border-[color:var(--m-rowbr)] p-3"
|
||||
style={{ background: isSelected ? 'var(--border-faint)' : 'var(--m-ic)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="line-clamp-2 block min-w-0 break-words font-semibold text-m-ink" title={tour.name}>
|
||||
{tour.name}
|
||||
</span>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-geist text-[0.75rem] text-m-muted">
|
||||
<span className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-medium text-m-faint">
|
||||
{hikeSourceBadgeLabel(tour, t)}
|
||||
</span>
|
||||
<span
|
||||
title={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
|
||||
aria-label={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
|
||||
className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-semibold text-m-muted"
|
||||
>
|
||||
T{tour.max_hiking_difficulty}
|
||||
</span>
|
||||
{tour.caution && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-[color:var(--warning)] px-1.5 py-0.5 text-[10px] text-[color:var(--warning)]"
|
||||
aria-label={t('tours.caution.badge')}
|
||||
title={t('tours.caution.tooltip')}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
||||
{t('tours.caution.badge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
data-testid="mobile-tour-metrics"
|
||||
className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 font-geist text-[0.75rem] text-m-muted"
|
||||
>
|
||||
<span
|
||||
className="whitespace-nowrap"
|
||||
aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, distanceUnit)}`}
|
||||
>
|
||||
{formatDistance(tour.distance ?? 0, distanceUnit)}
|
||||
</span>
|
||||
{tour.elevation_gain != null && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 whitespace-nowrap"
|
||||
aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, distanceUnit)}`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
||||
{formatElevation(tour.elevation_gain, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{tour.elevation_loss != null && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 whitespace-nowrap"
|
||||
aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, distanceUnit)}`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
||||
{formatElevation(tour.elevation_loss, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAssign}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canAssign) shell.openSheet('bract', { placeId: tour.place_id, dayPicker: true });
|
||||
}}
|
||||
className="mt-2 inline-flex min-h-9 w-full items-center justify-center gap-1 rounded-lg border border-[color:var(--m-rowbr)] px-3 py-1.5 text-[0.8125rem] font-medium text-m-ink"
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{t('tours.addToDay')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -205,6 +205,11 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
className="absolute bottom-[calc(env(safe-area-inset-bottom,0px)+90px)] left-4 right-4 overflow-y-auto overflow-x-hidden overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6]"
|
||||
style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }}
|
||||
>
|
||||
{planner.toursEnabled && tl.rows.some((row) => row.kind === 'place' && row.invalidTour) && (
|
||||
<div role="status" className="border-b border-[color:var(--warning)] p-2 text-sm text-[color:var(--warning)]">
|
||||
{t('tours.dayRoute.endpointUnknown')}
|
||||
</div>
|
||||
)}
|
||||
{day && (
|
||||
<TimelineHeader
|
||||
tl={tl}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPlanRows } from './planTimelineModel'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import type { Reservation, RouteSegment } from '../../../../types'
|
||||
|
||||
// FE-MOBILE-PLANROWS-001 to FE-MOBILE-PLANROWS-003
|
||||
|
||||
@@ -47,4 +47,22 @@ describe('buildPlanRows', () => {
|
||||
if (place.kind !== 'place') throw new Error('expected a place row')
|
||||
expect(place.linkedReservations).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the incoming and outgoing connectors at opposite Tour endpoints', () => {
|
||||
const start: [number, number] = [48.103, 11.103]
|
||||
const end: [number, number] = [48.12, 11.12]
|
||||
const before: [number, number] = [48.1, 11.1]
|
||||
const after: [number, number] = [48.13, 11.13]
|
||||
const merged = [
|
||||
{ type: 'place', sortKey: 0, data: { id: 11, place: { id: 1, lat: before[0], lng: before[1] } } },
|
||||
{ type: 'place', sortKey: 1, data: { id: 12, tour_place_id: 2,
|
||||
tour_route_geometry: JSON.stringify([start, end]), place: { id: 2, lat: start[0], lng: start[1] } } },
|
||||
{ type: 'place', sortKey: 2, data: { id: 13, place: { id: 3, lat: after[0], lng: after[1] } } },
|
||||
] as unknown as MergedItem[]
|
||||
const segment = (from: [number, number], to: [number, number]) => ({ from, to }) as RouteSegment
|
||||
const segments = [segment(before, start), segment(end, after)]
|
||||
const rows = buildPlanRows({ merged, reservations: [], routeSegments: segments, dayId: 5, toursEnabled: true })
|
||||
expect(rows.filter(row => row.kind === 'conn').map(row => row.seg)).toEqual(segments)
|
||||
expect(buildPlanRows({ merged, reservations: [], routeSegments: segments, dayId: 5 }).some(row => row.kind === 'conn' && row.seg.from[0] === end[0])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn } from '../../../../types'
|
||||
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
|
||||
|
||||
/**
|
||||
* Pure derivations for the mobile plan timeline: merged-item → row mapping,
|
||||
@@ -27,7 +28,7 @@ export interface TransitMeta {
|
||||
}
|
||||
|
||||
export type PlanRow =
|
||||
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedReservations: Reservation[] }
|
||||
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedReservations: Reservation[]; invalidTour?: boolean }
|
||||
| { key: string; kind: 'transport'; item: MergedItem; res: TransportEntry }
|
||||
| { key: string; kind: 'transit'; item: MergedItem; res: TransportEntry; transit: TransitMeta }
|
||||
| { key: string; kind: 'note'; item: MergedItem; note: DayNote }
|
||||
@@ -87,9 +88,16 @@ export function buildPlanRows(opts: {
|
||||
reservations: Reservation[]
|
||||
routeSegments: RouteSegment[]
|
||||
dayId: number
|
||||
toursEnabled?: boolean
|
||||
places?: Place[]
|
||||
}): PlanRow[] {
|
||||
const { merged, reservations, routeSegments, dayId } = opts
|
||||
const pool = routeSegments.filter(s => !s.hotelBookend)
|
||||
const projected = new Map(projectDayItinerary(
|
||||
merged.flatMap(item => item.type === 'place' ? [item.data as Assignment] : []),
|
||||
opts.toursEnabled ?? false,
|
||||
opts.places,
|
||||
).map(item => [item.assignment.id, item]))
|
||||
const takeSegment = (from: [number, number], to: [number, number]): RouteSegment | null => {
|
||||
const idx = pool.findIndex(s => sameCoord(s.from, from) && sameCoord(s.to, to))
|
||||
return idx >= 0 ? pool.splice(idx, 1)[0] : null
|
||||
@@ -99,6 +107,7 @@ export function buildPlanRows(opts: {
|
||||
for (const item of merged) {
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data as Assignment
|
||||
const itineraryItem = projected.get(assignment.id)
|
||||
base.push({
|
||||
key: `pl-${assignment.id}`,
|
||||
kind: 'place',
|
||||
@@ -108,6 +117,7 @@ export function buildPlanRows(opts: {
|
||||
// attraction, and getTransportForDay keeps every linked booking out of the
|
||||
// timeline, so anything dropped here is gone from the plan tab (#2201).
|
||||
linkedReservations: getAssignmentReservations(reservations, assignment.id),
|
||||
invalidTour: itineraryItem?.kind === 'tour' && !itineraryItem.valid,
|
||||
})
|
||||
} else if (item.type === 'note') {
|
||||
const note = item.data as DayNote
|
||||
@@ -156,9 +166,17 @@ export function buildPlanRows(opts: {
|
||||
const row = base[i]
|
||||
if (row.kind === 'place') {
|
||||
const place = row.assignment.place
|
||||
if (place?.lat == null || place?.lng == null) continue
|
||||
// Out of the route (#2532): the drive passes it by, so no leg starts or ends here.
|
||||
if (row.assignment.route_excluded) continue
|
||||
const itineraryItem = projected.get(row.assignment.id)
|
||||
if (itineraryItem?.kind === 'tour') {
|
||||
if (itineraryItem.start) connect(prev, [itineraryItem.start.lat, itineraryItem.start.lng])
|
||||
prev = itineraryItem.end
|
||||
? { at: [itineraryItem.end.lat, itineraryItem.end.lng], row: i, assignmentId: row.assignment.id }
|
||||
: null
|
||||
continue
|
||||
}
|
||||
if (place?.lat == null || place?.lng == null) continue
|
||||
const at: [number, number] = [place.lat, place.lng]
|
||||
connect(prev, at)
|
||||
// The leg's mode is stored on its ORIGIN place assignment (#1281), so carry
|
||||
|
||||
@@ -82,8 +82,8 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
|
||||
const rows = useMemo<PlanRow[]>(() => {
|
||||
if (!day) return []
|
||||
return buildPlanRows({ merged, reservations, routeSegments: connSegments, dayId: day.id })
|
||||
}, [day, merged, reservations, connSegments])
|
||||
return buildPlanRows({ merged, reservations, routeSegments: connSegments, dayId: day.id, toursEnabled: planner.toursEnabled, places: planner.places })
|
||||
}, [day, merged, reservations, connSegments, planner.toursEnabled, planner.places])
|
||||
|
||||
// Accommodation bookend legs (hotel → first stop, last stop → hotel). The
|
||||
// segments already sit in connSegments; hotelLegsForDay picks the two out.
|
||||
|
||||
@@ -26,10 +26,12 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
|
||||
const canEditPlaces = planner.can('place_edit', planner.trip);
|
||||
const canEditDays = planner.can('day_edit', planner.trip);
|
||||
const isTourPlace = (placeId: number) => planner.isTourPlace(placeId);
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'));
|
||||
const openSavePicker = useSaveToCollectionStore((s) => s.open);
|
||||
|
||||
const [daysOpen, setDaysOpen] = useState(false);
|
||||
const pendingTourDayIds = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
setDaysOpen(open && Boolean(payload.dayPicker));
|
||||
}, [open, payload.dayPicker]);
|
||||
@@ -58,9 +60,21 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
openSavePicker(collectionTargetFromPlace(place));
|
||||
};
|
||||
|
||||
const assignToDay = (dayId: number) => {
|
||||
shell.closeSheet();
|
||||
planner.handleAssignToDay(place.id, dayId);
|
||||
const tourAlreadyAssignedToDay = (dayId: number) =>
|
||||
isTourPlace(place.id) &&
|
||||
(planner.assignments[String(dayId)] ?? []).some((assignment) => assignment.place_id === place.id);
|
||||
|
||||
const assignToDay = async (dayId: number) => {
|
||||
const isTour = isTourPlace(place.id);
|
||||
const pendingKey = `${place.id}:${dayId}`;
|
||||
if (isTour && (tourAlreadyAssignedToDay(dayId) || pendingTourDayIds.current.has(pendingKey))) return;
|
||||
if (isTour) pendingTourDayIds.current.add(pendingKey);
|
||||
try {
|
||||
const assigned = await planner.handleAssignToDay(place.id, dayId);
|
||||
if (assigned !== false) shell.closeSheet();
|
||||
} finally {
|
||||
if (isTour) pendingTourDayIds.current.delete(pendingKey);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlace = () => {
|
||||
@@ -92,13 +106,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
<Eye size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
{t('mobileTrip.viewDetails')}
|
||||
</button>
|
||||
{canEditPlaces && (
|
||||
{canEditPlaces && !isTourPlace(place.id) && (
|
||||
<button type="button" onClick={editPlace} className={rowCls}>
|
||||
<Pencil size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
{collectionsEnabled && !isTourPlace(place.id) && (
|
||||
<button type="button" onClick={saveToCollection} className={rowCls}>
|
||||
<Bookmark size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
{t('inspector.saveToCollection')}
|
||||
@@ -121,8 +135,14 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => assignToDay(d.id)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-[10px] text-left ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
|
||||
disabled={
|
||||
tourAlreadyAssignedToDay(d.id) ||
|
||||
(isTourPlace(place.id) && pendingTourDayIds.current.has(`${place.id}:${d.id}`))
|
||||
}
|
||||
onClick={() => {
|
||||
void assignToDay(d.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-[10px] text-left disabled:cursor-default disabled:opacity-40 ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">
|
||||
{/* A day_number of 0 is as unusable as a missing one — both take the row position. */}
|
||||
@@ -144,7 +164,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
{canEditPlaces && !isTourPlace(place.id) && (
|
||||
<button type="button" onClick={deletePlace} className={`${rowCls} text-[color:var(--m-st-danger)]`}>
|
||||
<Trash2 size={16} strokeWidth={2} className="flex-none" />
|
||||
{t('common.delete')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Download, FileDown, MapPin } from 'lucide-react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useAddonStore } from '../../../../store/addonStore';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import type { TripPlanner } from '../MTripShell';
|
||||
@@ -24,6 +25,9 @@ type ImportStep = 'menu' | 'file' | 'list';
|
||||
export default function MImportSheet({ planner, open, onClose }: MImportSheetProps) {
|
||||
const { t } = planner;
|
||||
const [step, setStep] = useState<ImportStep>('menu');
|
||||
// File import (GPX/KML/KMZ) belongs to Tours mode while the addon is on;
|
||||
// list import stays here in both states.
|
||||
const toursEnabled = useAddonStore((s) => s.isEnabled('tours'));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setStep('menu');
|
||||
@@ -50,12 +54,14 @@ export default function MImportSheet({ planner, open, onClose }: MImportSheetPro
|
||||
|
||||
{step === 'menu' && (
|
||||
<div className="px-[14px] pb-[14px] pt-1">
|
||||
<ImpMenuRow
|
||||
icon={FileDown}
|
||||
title={t('places.importFile')}
|
||||
sub="GPX · KML · KMZ"
|
||||
onClick={() => setStep('file')}
|
||||
/>
|
||||
{!toursEnabled && (
|
||||
<ImpMenuRow
|
||||
icon={FileDown}
|
||||
title={t('places.importFile')}
|
||||
sub="GPX · KML · KMZ"
|
||||
onClick={() => setStep('file')}
|
||||
/>
|
||||
)}
|
||||
<ImpMenuRow
|
||||
icon={MapPin}
|
||||
title={t('places.importList')}
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
// Derived from the same priority list the dock seats itself from, so a section
|
||||
// can never show up in both places at once.
|
||||
const seated = dockTabIds(new Set(planner.TRIP_TABS.map((tab) => tab.id)));
|
||||
const gridTabs = planner.TRIP_TABS.filter((tab) => !seated.has(tab.id));
|
||||
const mobileTabs = planner.TRIP_TABS.filter((tab) => !tab.desktopOnly);
|
||||
const seated = dockTabIds(new Set(mobileTabs.map((tab) => tab.id)));
|
||||
const gridTabs = mobileTabs.filter((tab) => !seated.has(tab.id));
|
||||
|
||||
const tileStat = (id: string): string | null => {
|
||||
if (id === 'dateien') {
|
||||
|
||||
@@ -357,7 +357,9 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
// Two-tap confirm: arming also stages the id the planner's confirm reads.
|
||||
setDeletePlaceId(sheetPlace.id);
|
||||
setDeleteArmed(true);
|
||||
toast.warning(t('mobileTrip.tapAgainToDelete'));
|
||||
toast.warning(
|
||||
planner.isTourPlace(sheetPlace.id) ? t('tours.delete.confirmBody') : t('mobileTrip.tapAgainToDelete')
|
||||
);
|
||||
return;
|
||||
}
|
||||
await confirmDeletePlace();
|
||||
@@ -533,7 +535,11 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
|
||||
<FormSheetFooter
|
||||
onDelete={sheetPlace ? handleDelete : undefined}
|
||||
deleteLabel={t('common.delete')}
|
||||
deleteLabel={
|
||||
deleteArmed && sheetPlace && planner.isTourPlace(sheetPlace.id)
|
||||
? t('tours.delete.confirmAction')
|
||||
: t('common.delete')
|
||||
}
|
||||
deleteArmed={deleteArmed}
|
||||
onCancel={handleClose}
|
||||
cancelLabel={t('common.cancel')}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildPlace } from '../../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../../tests/helpers/render';
|
||||
import type { MTripShellApi, TripPlanner } from '../MTripShell';
|
||||
import MPlaceSheet from './MPlaceSheet';
|
||||
|
||||
vi.mock('../../../components/MSheet', () => ({
|
||||
default: ({ open, children, ariaLabel }: { open: boolean; children?: ReactNode; ariaLabel?: string }) => (
|
||||
<div data-testid="mobile-place-sheet" data-open={String(open)}>
|
||||
{open && (
|
||||
<div role="dialog" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const place = buildPlace({ id: 42, name: 'Ridge walk' });
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return {
|
||||
selectedPlace: place,
|
||||
selectedTour: null,
|
||||
toursEnabled: false,
|
||||
tourDataReady: false,
|
||||
can: () => false,
|
||||
trip: {},
|
||||
categories: [],
|
||||
days: [],
|
||||
assignments: {},
|
||||
selectedDayId: null,
|
||||
selectedAssignmentId: null,
|
||||
reservations: [],
|
||||
files: [],
|
||||
tripMembers: [],
|
||||
language: 'en',
|
||||
isTourPlace: () => false,
|
||||
canUploadFiles: false,
|
||||
...overrides,
|
||||
} as unknown as TripPlanner;
|
||||
}
|
||||
|
||||
const shell = {} as MTripShellApi;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MPlaceSheet tour exclusion', () => {
|
||||
it('keeps ordinary place details open when Tours is off', () => {
|
||||
render(<MPlaceSheet planner={planner()} shell={shell} />);
|
||||
|
||||
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('waits for the Tours facet before choosing a detail surface', () => {
|
||||
render(<MPlaceSheet planner={planner({ toursEnabled: true, tourDataReady: false })} shell={shell} />);
|
||||
|
||||
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'false');
|
||||
});
|
||||
|
||||
it('does not open underneath the purpose-built dialog for a selected tour', () => {
|
||||
render(
|
||||
<MPlaceSheet
|
||||
planner={planner({
|
||||
toursEnabled: true,
|
||||
tourDataReady: true,
|
||||
selectedTour: { place_id: place.id } as TripPlanner['selectedTour'],
|
||||
})}
|
||||
shell={shell}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'false');
|
||||
});
|
||||
|
||||
it('opens for an ordinary place after the Tours facet is ready', () => {
|
||||
render(<MPlaceSheet planner={planner({ toursEnabled: true, tourDataReady: true })} shell={shell} />);
|
||||
|
||||
expect(screen.getByTestId('mobile-place-sheet')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('keeps a dormant facet-backed Tour view-only while retaining day assignment controls', () => {
|
||||
const tourPlace = buildPlace({
|
||||
id: 42,
|
||||
name: 'Ridge walk',
|
||||
route_geometry: '[[48,11,500],[48.01,11.01,510]]',
|
||||
image_url: '/uploads/cover.jpg',
|
||||
});
|
||||
const plannerWithTour = planner({
|
||||
selectedPlace: tourPlace,
|
||||
toursEnabled: false,
|
||||
can: (permission: string) => permission === 'place_edit' || permission === 'day_edit',
|
||||
isTourPlace: (placeId: number) => placeId === tourPlace.id,
|
||||
days: [{ id: 7, day_number: 1, title: 'Day 1' } as never],
|
||||
selectedDayId: 7,
|
||||
selectedAssignmentId: 8,
|
||||
assignments: { '7': [{ id: 8, day_id: 7, place: tourPlace, route_excluded: false }] as never },
|
||||
tripActions: {
|
||||
uploadPlaceImage: vi.fn(),
|
||||
updatePlace: vi.fn(),
|
||||
addFile: vi.fn(),
|
||||
setAssignmentRouteExcluded: vi.fn(),
|
||||
} as never,
|
||||
canUploadFiles: true,
|
||||
});
|
||||
render(<MPlaceSheet planner={plannerWithTour} shell={shell} />);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Ridge walk' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'common.upload' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radiogroup', { name: 'places.yourRating' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.excludeFromRoute' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove from Day' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'inspector.addToDay' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -56,10 +56,15 @@ import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi';
|
||||
export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t } = useTranslation();
|
||||
const place = planner.selectedPlace ?? null;
|
||||
const open = !!place;
|
||||
// Match the desktop inspector gate: while the Tours facet is loading, wait
|
||||
// before choosing a surface; once identified as a tour, its purpose-built
|
||||
// dialog is the only detail surface that should open.
|
||||
const open = !!place && (!planner.toursEnabled || planner.tourDataReady) && !planner.selectedTour;
|
||||
|
||||
const canEditPlaces = planner.can('place_edit', planner.trip);
|
||||
const canEditDays = planner.can('day_edit', planner.trip);
|
||||
const isTourPlace = place != null && planner.isTourPlace(place.id);
|
||||
const canManagePlace = canEditPlaces && !isTourPlace;
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'));
|
||||
const openSavePicker = useSaveToCollectionStore((s) => s.open);
|
||||
|
||||
@@ -182,7 +187,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !place) return;
|
||||
if (!file || !place || isTourPlace) return;
|
||||
setImgBusy(true);
|
||||
try {
|
||||
await planner.tripActions.uploadPlaceImage(planner.tripId, place.id, await normalizeImageFile(file));
|
||||
@@ -194,7 +199,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
};
|
||||
|
||||
const handleTrackColor = async (color: string | null) => {
|
||||
if (!place) return;
|
||||
if (!place || isTourPlace) return;
|
||||
try {
|
||||
await planner.tripActions.updatePlace(planner.tripId, place.id, { route_color: color });
|
||||
} catch (err: unknown) {
|
||||
@@ -203,7 +208,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
};
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
if (!place) return;
|
||||
if (!place || isTourPlace) return;
|
||||
setImgBusy(true);
|
||||
try {
|
||||
await planner.tripActions.updatePlace(planner.tripId, place.id, { image_url: null });
|
||||
@@ -216,7 +221,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (!selected.length || !place) return;
|
||||
if (!selected.length || !place || isTourPlace) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of selected) {
|
||||
@@ -283,7 +288,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<CatIcon size={20} strokeWidth={1.8} className="text-m-muted" />
|
||||
</div>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
{canManagePlace && (
|
||||
<>
|
||||
{/* Tap the thumbnail to set a custom image (#1136). */}
|
||||
<button
|
||||
@@ -374,7 +379,12 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
{/* Collaborative rating (#1435) — tap a star to cast/clear your vote. */}
|
||||
<div className={`mt-[10px] rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={handleRate} size={18} />
|
||||
<PlaceRating
|
||||
ratings={place.ratings ?? []}
|
||||
ratingAvg={place.rating_avg}
|
||||
onRate={isTourPlace ? undefined : handleRate}
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{place.description && (
|
||||
@@ -401,33 +411,46 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('inspector.trackColor')}</Eyebrow>
|
||||
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerOpen((v) => !v)}
|
||||
aria-expanded={colorPickerOpen}
|
||||
className="flex w-full items-center justify-between gap-3"
|
||||
>
|
||||
{/* The eyebrow above already names the section — this row says
|
||||
which colour is in effect, not the same word again. */}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Route size={14} className="shrink-0 text-m-muted" />
|
||||
<span className="truncate font-geist text-[0.75rem] font-medium">
|
||||
{place.route_color ?? t('inspector.trackColorAuto')}
|
||||
</span>
|
||||
</span>
|
||||
{isTourPlace ? (
|
||||
<span
|
||||
className="h-6 w-6 shrink-0 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15"
|
||||
style={{ background: resolveTrackColor(place) }}
|
||||
aria-label={t('inspector.trackColor')}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
background: resolveTrackColor(place),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{colorPickerOpen && (
|
||||
<div className="mt-[10px] border-t border-[color:var(--m-faint)] pt-[10px]">
|
||||
<TrackColorPicker
|
||||
value={place.route_color ?? null}
|
||||
inheritedColor={inheritedTrackColor(place)}
|
||||
onChange={handleTrackColor}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerOpen((v) => !v)}
|
||||
aria-expanded={colorPickerOpen}
|
||||
className="flex w-full items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Route size={14} className="shrink-0 text-m-muted" />
|
||||
<span className="truncate font-geist text-[0.75rem] font-medium">
|
||||
{place.route_color ?? t('inspector.trackColorAuto')}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="h-6 w-6 shrink-0 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15"
|
||||
style={{ background: resolveTrackColor(place) }}
|
||||
/>
|
||||
</button>
|
||||
{colorPickerOpen && (
|
||||
<div className="mt-[10px] border-t border-[color:var(--m-faint)] pt-[10px]">
|
||||
<TrackColorPicker
|
||||
value={place.route_color ?? null}
|
||||
inheritedColor={inheritedTrackColor(place)}
|
||||
onChange={handleTrackColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -442,7 +465,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
className={`flex items-center gap-1 rounded-full py-1 pl-[10px] text-[0.75rem] font-semibold ${INNER_CLS} ${canEditDays ? 'pr-1' : 'pr-[10px]'}`}
|
||||
>
|
||||
{day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })}
|
||||
{canEditDays && place.lat != null && place.lng != null && (
|
||||
{canEditDays && !isTourPlace && place.lat != null && place.lng != null && (
|
||||
// In or out of that day's route (#2532); the icon says which it is.
|
||||
<button
|
||||
type="button"
|
||||
@@ -572,7 +595,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
)}
|
||||
|
||||
{/* ── Participants of the selected day's assignment ── */}
|
||||
{assignmentInDay && members.length > 1 && (
|
||||
{!isTourPlace && assignmentInDay && members.length > 1 && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('inspector.participants')}</Eyebrow>
|
||||
<div className="flex flex-wrap items-center gap-[6px]">
|
||||
@@ -664,7 +687,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
? t('inspector.filesCount', { count: placeFiles.length })
|
||||
: t('inspector.files')}
|
||||
</span>
|
||||
{planner.canUploadFiles && (
|
||||
{planner.canUploadFiles && !isTourPlace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -699,7 +722,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
{/* ── Action row ── */}
|
||||
<div className="mt-[14px] flex items-center gap-[7px]">
|
||||
{collectionsEnabled && (
|
||||
{collectionsEnabled && !isTourPlace && (
|
||||
<ActionCircle onClick={saveToCollection} label={t('inspector.saveToCollection')}>
|
||||
<Bookmark size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
@@ -738,7 +761,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<ExternalLink size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
{canManagePlace && (
|
||||
<ActionCircle
|
||||
onClick={() => {
|
||||
planner.openPlaceEditor(place, assignmentInDay?.id ?? null);
|
||||
@@ -751,7 +774,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<Pencil size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
{canManagePlace && (
|
||||
<ActionCircle
|
||||
onClick={() => {
|
||||
planner.handleDeletePlace(place.id);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildPlace } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { TripPlanner } from '../MTripShell';
|
||||
import { MSelectedTourDetail } from './MTripSheets';
|
||||
|
||||
const place = buildPlace({
|
||||
id: 42,
|
||||
name: 'Ridge walk',
|
||||
route_geometry: JSON.stringify([
|
||||
[48, 11, 500],
|
||||
[48, 11.01, 550],
|
||||
[48, 11.03, 520],
|
||||
]),
|
||||
});
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'hike',
|
||||
distance: 999,
|
||||
elevation_gain: 999,
|
||||
elevation_loss: 999,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return {
|
||||
selectedPlace: place,
|
||||
selectedTour: tour,
|
||||
selectedDayId: null,
|
||||
selectedAssignmentId: null,
|
||||
assignments: {},
|
||||
days: [],
|
||||
files: [],
|
||||
tripId: 7,
|
||||
can: vi.fn<TripPlanner['can']>((actionKey) => actionKey === 'day_edit'),
|
||||
canUploadFiles: false,
|
||||
setSelectedPlaceId: vi.fn(),
|
||||
reloadTourPlaceIds: vi.fn().mockResolvedValue(undefined),
|
||||
tripActions: {
|
||||
updatePlace: vi.fn().mockResolvedValue(undefined),
|
||||
addFile: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
handleAssignToDay: vi.fn(),
|
||||
handleRemoveAssignment: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TripPlanner;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
describe('MSelectedTourDetail', () => {
|
||||
it('is the single mobile modal owner for Escape, focus containment, scroll lock and focus return', () => {
|
||||
const opener = document.createElement('button');
|
||||
opener.textContent = 'Open mobile Tour';
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
const setSelectedPlaceId = vi.fn();
|
||||
const view = render(<MSelectedTourDetail planner={planner({ setSelectedPlaceId })} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Ridge walk' });
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
expect(fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })).toBe(false);
|
||||
|
||||
const nestedSheet = document.createElement('div');
|
||||
nestedSheet.dataset.mSheet = 'open';
|
||||
document.body.appendChild(nestedSheet);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(setSelectedPlaceId).not.toHaveBeenCalled();
|
||||
document.body.removeChild(nestedSheet);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(setSelectedPlaceId).toHaveBeenCalledWith(null);
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
document.body.removeChild(opener);
|
||||
});
|
||||
|
||||
it('renders the selected tour through the shared detail dialog and elevation profile', () => {
|
||||
render(<MSelectedTourDetail planner={planner()} />);
|
||||
|
||||
expect(screen.getAllByText('Track Stats')).toHaveLength(1);
|
||||
expect(document.querySelector('#tour-elevation-42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for an ordinary selected place', () => {
|
||||
render(<MSelectedTourDetail planner={planner({ selectedTour: null })} />);
|
||||
|
||||
expect(screen.queryByText('Track Stats')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel';
|
||||
import { expenseEditorFor } from '../../../../components/Budget/CostsPanel.helpers';
|
||||
import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal';
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types';
|
||||
import BookingImportModal from '../../../../components/Planner/BookingImportModal';
|
||||
import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal';
|
||||
import { focusDialog, trapTab } from '../../../../components/shared/dialogFocus';
|
||||
import TourDetailDialog from '../../../../components/Tours/TourDetailDialog';
|
||||
import TripFormModal from '../../../../components/Trips/TripFormModal';
|
||||
import TripMembersModal from '../../../../components/Trips/TripMembersModal';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { useTripStore } from '../../../../store/tripStore';
|
||||
import type { BudgetItem } from '../../../../types';
|
||||
import { lockBodyScroll } from '../../../../utils/bodyScrollLock';
|
||||
import MDayImpactList from '../../../components/MDayImpactList';
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet';
|
||||
import type { MTripSheetsProps } from '../MTripShell';
|
||||
@@ -36,6 +40,81 @@ import MReservationSheet from './MReservationSheet';
|
||||
import MTransportFormSheet from './MTransportFormSheet';
|
||||
import MTransportSheet from './MTransportSheet';
|
||||
|
||||
/** The one global mobile tour-detail owner, independent of which surface selected it. */
|
||||
export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner'>) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTourId = planner.selectedTour?.place_id ?? null;
|
||||
const selectedPlaceId = planner.selectedPlace?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (selectedTourId == null || selectedPlaceId == null) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const release = lockBodyScroll();
|
||||
const panel = panelRef.current;
|
||||
if (panel) focusDialog(panel);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
const openPanels = document.querySelectorAll('[data-m-sheet="open"]');
|
||||
if (openPanels.length && openPanels[openPanels.length - 1] !== panel) return;
|
||||
event.preventDefault();
|
||||
planner.setSelectedPlaceId(null);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
release();
|
||||
if (previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, [planner.setSelectedPlaceId, selectedPlaceId, selectedTourId]);
|
||||
|
||||
if (!planner.selectedTour || !planner.selectedPlace) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.3)]"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
role="presentation"
|
||||
onClick={() => planner.setSelectedPlaceId(null)}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ width: '100%', maxHeight: '85vh' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={planner.selectedPlace.name}
|
||||
data-m-sheet="open"
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => trapTab(event, panelRef.current!)}
|
||||
>
|
||||
<TourDetailDialog
|
||||
tour={planner.selectedTour}
|
||||
place={planner.selectedPlace}
|
||||
days={planner.days}
|
||||
selectedDayId={planner.selectedDayId}
|
||||
selectedAssignmentId={planner.selectedAssignmentId}
|
||||
assignments={planner.assignments}
|
||||
files={planner.files}
|
||||
readOnly
|
||||
canEdit={false}
|
||||
canAssign={planner.can('day_edit', planner.trip)}
|
||||
onClose={() => planner.setSelectedPlaceId(null)}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onRemoveAssignment={planner.handleRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet host of the mobile trip screen — always mounted below the shell. Two
|
||||
* families live here: the mobile sheets routed via shell.sheet (day, days,
|
||||
@@ -74,6 +153,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
<>
|
||||
{/* ── Mobile sheets (shell.sheet routing + the place selection) ── */}
|
||||
<MPlaceSheet planner={planner} shell={shell} />
|
||||
<MSelectedTourDetail planner={planner} />
|
||||
<MDaySheet planner={planner} shell={shell} />
|
||||
<MDaysSheet planner={planner} shell={shell} />
|
||||
<MAccommodationSheet planner={planner} shell={shell} />
|
||||
@@ -104,6 +184,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
onClose={shell.closeSheet}
|
||||
places={planner.places}
|
||||
categories={planner.categories}
|
||||
toursEnabled={planner.toursEnabled}
|
||||
/>
|
||||
|
||||
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
|
||||
@@ -222,7 +303,12 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
onClose={() => planner.setDeletePlaceId(null)}
|
||||
title={t('common.delete')}
|
||||
message={
|
||||
planner.deletePlaceNote ? (
|
||||
planner.deletePlaceId != null && planner.isTourPlace(planner.deletePlaceId) ? (
|
||||
<>
|
||||
<span className="block">{t('tours.delete.confirmBody')}</span>
|
||||
{planner.deletePlaceNote && <span className="mt-1 block">{planner.deletePlaceNote}</span>}
|
||||
</>
|
||||
) : planner.deletePlaceNote ? (
|
||||
<>
|
||||
<span className="block">{t('trip.confirm.deletePlace')}</span>
|
||||
<span className="mt-1 block">{planner.deletePlaceNote}</span>
|
||||
@@ -231,7 +317,11 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
t('trip.confirm.deletePlace')
|
||||
)
|
||||
}
|
||||
confirmLabel={t('common.delete')}
|
||||
confirmLabel={
|
||||
planner.deletePlaceId != null && planner.isTourPlace(planner.deletePlaceId)
|
||||
? t('tours.delete.confirmAction')
|
||||
: t('common.delete')
|
||||
}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -424,7 +424,7 @@ describe('TripPlannerPage', () => {
|
||||
renderPlannerPage(15);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15);
|
||||
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15, expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ import { ReservationModal } from '../components/Planner/ReservationModal';
|
||||
import TransitJourneyModal from '../components/Planner/TransitJourneyModal';
|
||||
import { BookingDetailPopup } from '../components/Planner/bookings/BookingDetailHost';
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch';
|
||||
import PlacesToursModeSwitch from '../components/Tours/PlacesToursModeSwitch';
|
||||
import TourDetailDialog from '../components/Tours/TourDetailDialog';
|
||||
import ToursSidebar from '../components/Tours/ToursSidebar';
|
||||
import TripFormModal from '../components/Trips/TripFormModal';
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal';
|
||||
import SlidingTabs from '../components/shared/SlidingTabs';
|
||||
@@ -43,7 +46,6 @@ import HelpAnchor from '../components/Help/HelpAnchor';
|
||||
import Navbar from '../components/Layout/Navbar';
|
||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill';
|
||||
import { useMergedMapPois } from '../components/Map/useMergedMapPois';
|
||||
import { usePoiExplore } from '../components/Map/usePoiExplore';
|
||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton';
|
||||
import PackingExportMenu from '../components/Packing/PackingExportMenu';
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types';
|
||||
@@ -60,7 +62,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import type { BudgetItem, PackingItem, Reservation, TodoItem } from '../types';
|
||||
import { getDayBookendHotels } from '../utils/dayOrder';
|
||||
import { lazyWithRetry } from '../utils/lazyWithRetry';
|
||||
import { useTripPlanner } from './tripPlanner/useTripPlanner';
|
||||
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage';
|
||||
|
||||
// The tab panels are the planner's dead weight: each one mounts only while its
|
||||
// own tab is active, so the page chunk carried code most sessions never run. They
|
||||
@@ -86,6 +88,12 @@ const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/Roa
|
||||
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'));
|
||||
const RoadtripTrackModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripTrackModal'));
|
||||
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'));
|
||||
const TourPlannerRail = lazyWithRetry(() =>
|
||||
import('../components/Tours/planner/TourPlannerPanels').then((module) => ({ default: module.TourPlannerRail }))
|
||||
);
|
||||
const TourPlannerToursRail = lazyWithRetry(() =>
|
||||
import('../components/Tours/planner/TourPlannerPanels').then((module) => ({ default: module.TourPlannerToursRail }))
|
||||
);
|
||||
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
|
||||
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
|
||||
// — about 200 kB of packed zone geometry that every trip used to load.
|
||||
@@ -381,6 +389,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
// Page = wiring container: the entire planner state machine (store, tabs,
|
||||
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
|
||||
const pageState = useTripPlannerPage();
|
||||
const {
|
||||
tripId,
|
||||
navigate,
|
||||
@@ -580,6 +589,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
setDeletePlaceIds,
|
||||
deletePlaceNote,
|
||||
deletePlacesNote,
|
||||
deletePlaceIsTour,
|
||||
deletePlacesIncludeTours,
|
||||
stayRelease,
|
||||
setStayRelease,
|
||||
confirmStayRelease,
|
||||
@@ -616,6 +627,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
confirmDeletePlace,
|
||||
confirmDeletePlaces,
|
||||
confirmChangeCategory,
|
||||
handleDeleteTour,
|
||||
handleAssignToDay,
|
||||
handleMoveToDay,
|
||||
handleRemoveAssignment,
|
||||
@@ -637,11 +649,27 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
selectedPlace,
|
||||
dayOrderMap,
|
||||
dayPlaces,
|
||||
toursEnabled,
|
||||
toursMode,
|
||||
setToursMode,
|
||||
tours,
|
||||
toursLoading,
|
||||
tourDataReady,
|
||||
tourPlaceIds,
|
||||
reloadTourPlaceIds,
|
||||
invalidateTourPlaceIds,
|
||||
selectedTour,
|
||||
mapTileUrl,
|
||||
fontStyle,
|
||||
splashDone,
|
||||
} = useTripPlanner();
|
||||
|
||||
} = pageState.planner;
|
||||
const {
|
||||
poi,
|
||||
tourPlanner,
|
||||
permissions: { canPlaceEdit, canDayEdit },
|
||||
tourDetails,
|
||||
tourMap,
|
||||
} = pageState;
|
||||
// Tablets run this very layout but cannot start an HTML5 drag with a finger,
|
||||
// so a long press stands in for one (#1616). Only where the pointer is
|
||||
// coarse — a hybrid laptop loads drag-drop-touch instead.
|
||||
@@ -667,7 +695,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const poi = usePoiExplore();
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null);
|
||||
// The corridor search draws into the same map channel and answers the same question for
|
||||
// a drive, so the explore pill stands down while road trip mode is on.
|
||||
@@ -882,7 +909,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onHighlightAlternative={setHighlightedAlternative}
|
||||
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
|
||||
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onViewportChange={tourMap.captureViewport}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
@@ -1144,6 +1171,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
tourPlaceIds={tourPlaceIds}
|
||||
categories={categories}
|
||||
assignments={storedAssignments}
|
||||
selectedDayId={selectedDayId}
|
||||
@@ -1401,39 +1429,62 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
accommodations={tripAccommodations}
|
||||
selectedDayId={selectedDayId}
|
||||
onClearSelectedDay={() => handleSelectDay(null)}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => {
|
||||
setEditingPlace(null);
|
||||
setPlaceFormDayId(null);
|
||||
setShowPlaceForm(true);
|
||||
}}
|
||||
onAddPlaceToSelectedDay={
|
||||
selectedDayId != null
|
||||
? () => {
|
||||
setEditingPlace(null);
|
||||
setPlaceFormDayId(selectedDayId);
|
||||
setShowPlaceForm(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
|
||||
pushUndo={pushUndo}
|
||||
days={days}
|
||||
isMobile={false}
|
||||
/>
|
||||
<>
|
||||
{/* Top level of the right add-panel, mirroring the always-visible
|
||||
Days <-> Roadtrip switch on the left rail. Hidden entirely
|
||||
when the tours addon is off. */}
|
||||
{toursEnabled && <PlacesToursModeSwitch active={toursMode} onChange={setToursMode} />}
|
||||
{toursEnabled && toursMode ? (
|
||||
<ToursSidebar
|
||||
tripId={tripId}
|
||||
days={days}
|
||||
tours={tours}
|
||||
loading={toursLoading}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onToursChanged={(placeIds) => invalidateTourPlaceIds(placeIds ? { placeIds } : undefined)}
|
||||
onSelectTour={tourDetails.onSelectTour}
|
||||
/>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
toursEnabled={toursEnabled}
|
||||
excludePlaceIds={toursEnabled ? tourPlaceIds : undefined}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
accommodations={tripAccommodations}
|
||||
selectedDayId={selectedDayId}
|
||||
onClearSelectedDay={() => handleSelectDay(null)}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => {
|
||||
setEditingPlace(null);
|
||||
setPlaceFormDayId(null);
|
||||
setShowPlaceForm(true);
|
||||
}}
|
||||
onAddPlaceToSelectedDay={
|
||||
selectedDayId != null
|
||||
? () => {
|
||||
setEditingPlace(null);
|
||||
setPlaceFormDayId(selectedDayId);
|
||||
setShowPlaceForm(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
|
||||
pushUndo={pushUndo}
|
||||
days={days}
|
||||
isMobile={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,7 +1598,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
);
|
||||
})()}
|
||||
|
||||
{selectedPlace && !isMobile && (
|
||||
{selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && !isMobile && (
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay}
|
||||
@@ -1611,6 +1662,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{selectedPlace &&
|
||||
(!toursEnabled || tourDataReady) &&
|
||||
!selectedTour &&
|
||||
isMobile &&
|
||||
createPortal(
|
||||
<div
|
||||
@@ -1702,6 +1755,83 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
document.body
|
||||
)}
|
||||
|
||||
{selectedTour && selectedPlace && !isMobile && (
|
||||
<TourDetailDialog
|
||||
tour={selectedTour}
|
||||
desktopNonModal
|
||||
desktopFocusReturnTarget={
|
||||
tourDetails.openerRef.current?.placeId === selectedTour.place_id
|
||||
? tourDetails.openerRef.current.element
|
||||
: null
|
||||
}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
place={selectedPlace}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onUpdatePlace={async (placeId, data) => {
|
||||
await tripActions.updatePlace(tripId, placeId, data);
|
||||
await reloadTourPlaceIds();
|
||||
}}
|
||||
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTour &&
|
||||
selectedPlace &&
|
||||
isMobile &&
|
||||
createPortal(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.3)]"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
role="presentation"
|
||||
onClick={() => setSelectedPlaceId(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxHeight: '85vh' }}
|
||||
role="presentation"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<TourDetailDialog
|
||||
tour={selectedTour}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
place={selectedPlace}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onUpdatePlace={async (placeId, data) => {
|
||||
await tripActions.updatePlace(tripId, placeId, data);
|
||||
await reloadTourPlaceIds();
|
||||
}}
|
||||
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{mobileSidebarOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
@@ -1765,6 +1895,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
tourPlaceIds={tourPlaceIds}
|
||||
categories={categories}
|
||||
assignments={storedAssignments}
|
||||
selectedDayId={selectedDayId}
|
||||
@@ -1921,6 +2052,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
toursEnabled={toursEnabled}
|
||||
excludePlaceIds={toursEnabled ? tourPlaceIds : undefined}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
accommodations={tripAccommodations}
|
||||
@@ -1963,6 +2096,170 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={[]}
|
||||
route={tourMap.route}
|
||||
followSelection={false}
|
||||
onMapClick={
|
||||
canPlaceEdit &&
|
||||
!tourPlanner.isSaving &&
|
||||
(tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
|
||||
? ({ latlng }: { latlng: { lat: number; lng: number } }) =>
|
||||
tourPlanner.addWaypoint(latlng.lat, latlng.lng)
|
||||
: undefined
|
||||
}
|
||||
tileUrl={mapTileUrl}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
focusPoints={tourMap.focusPoints}
|
||||
focusKey={tourMap.focusKey}
|
||||
plannerWaypoints={
|
||||
tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
? tourPlanner.waypoints
|
||||
: []
|
||||
}
|
||||
selectedPlannerWaypointId={
|
||||
tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
? tourPlanner.selectedWaypointId
|
||||
: null
|
||||
}
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={tourPlanner.mapBaseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
/>
|
||||
<div
|
||||
className="hidden md:block"
|
||||
style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}
|
||||
>
|
||||
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLeft}
|
||||
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
aria-expanded={!leftHidden}
|
||||
className={`${leftHidden ? 'bg-accent text-accent-text shadow-md hover:opacity-90' : 'text-content-faint hover:text-content'} focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent`}
|
||||
style={{
|
||||
position: leftHidden ? 'fixed' : 'absolute',
|
||||
top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14,
|
||||
left: leftHidden ? 10 : undefined,
|
||||
right: leftHidden ? undefined : -28,
|
||||
zIndex: -1,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: leftHidden ? 10 : '0 10px 10px 0',
|
||||
background: leftHidden ? undefined : 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div
|
||||
style={{
|
||||
width: leftHidden ? 0 : leftPanelPx,
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftHidden ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{!leftHidden && (
|
||||
<PanelResizeHandle
|
||||
side="left"
|
||||
width={leftWidth}
|
||||
min={resizeMin}
|
||||
max={resizeMax}
|
||||
onStart={startResizeLeft}
|
||||
onNudge={nudgeLeft}
|
||||
/>
|
||||
)}
|
||||
<LazyPanel id="tour-planner-rail">
|
||||
<TourPlannerRail planner={tourPlanner} canEdit={canPlaceEdit} canAssign={canDayEdit} />
|
||||
</LazyPanel>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-[10px] right-[10px] top-[10px] z-20 hidden md:block">
|
||||
<Tooltip label={rightHidden ? t('tours.planner.tripTours') : t('common.collapse')} placement="left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRight}
|
||||
aria-label={rightHidden ? t('tours.planner.tripTours') : t('common.collapse')}
|
||||
aria-expanded={!rightHidden}
|
||||
className={`absolute flex h-9 w-9 items-center justify-center border-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${rightHidden ? 'right-0 top-1 rounded-xl bg-accent text-accent-text shadow-md hover:opacity-90' : '-left-7 top-3.5 rounded-l-xl text-content-faint hover:text-content'}`}
|
||||
style={{
|
||||
zIndex: -1,
|
||||
background: rightHidden ? undefined : 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
{rightHidden ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="h-full overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
width: rightHidden ? 0 : rightWidth,
|
||||
opacity: rightHidden ? 0 : 1,
|
||||
transition: 'width 0.25s ease, opacity 0.25s ease',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: rightHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
}}
|
||||
>
|
||||
{!rightHidden && (
|
||||
<PanelResizeHandle
|
||||
side="right"
|
||||
width={rightWidth}
|
||||
min={resizeMin}
|
||||
max={resizeMax}
|
||||
onStart={startResizeRight}
|
||||
onNudge={nudgeRight}
|
||||
/>
|
||||
)}
|
||||
{!rightHidden && (
|
||||
<LazyPanel id="tour-planner-tours">
|
||||
<TourPlannerToursRail
|
||||
planner={tourPlanner}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
tours={tours}
|
||||
days={days}
|
||||
loading={toursLoading}
|
||||
onAssignToDay={(placeId, dayId) => handleAssignToDay(placeId, dayId)}
|
||||
onDeleteTour={handleDeleteTour}
|
||||
onViewGpxTour={(tour) => {
|
||||
const place = places.find((item) => item.id === tour.place_id);
|
||||
tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null);
|
||||
}}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transports' && (
|
||||
<div
|
||||
style={{
|
||||
@@ -2377,11 +2674,19 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
onConfirm={confirmDeletePlace}
|
||||
onConfirm={async () => {
|
||||
const deletedTourId = await confirmDeletePlace();
|
||||
if (deletedTourId != null) tourPlanner.forgetDeletedTour(deletedTourId);
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
message={
|
||||
deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')
|
||||
deletePlaceIsTour
|
||||
? [t('tours.delete.confirmBody'), deletePlaceNote].filter(Boolean).join(' ')
|
||||
: deletePlaceNote
|
||||
? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}`
|
||||
: t('trip.confirm.deletePlace')
|
||||
}
|
||||
confirmLabel={deletePlaceIsTour ? t('tours.delete.confirmAction') : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceIds?.length}
|
||||
@@ -2389,10 +2694,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onConfirm={confirmDeletePlaces}
|
||||
title={t('common.delete')}
|
||||
message={
|
||||
deletePlacesNote
|
||||
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
|
||||
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })
|
||||
deletePlacesIncludeTours
|
||||
? [t('tours.delete.bulkConfirmBody'), deletePlacesNote].filter(Boolean).join(' ')
|
||||
: deletePlacesNote
|
||||
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
|
||||
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })
|
||||
}
|
||||
confirmLabel={deletePlacesIncludeTours ? t('tours.delete.confirmAction') : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={clearDayId != null}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import type { Day, Place, Reservation, Settings } from '../types';
|
||||
import { analyzeRouteGeometry } from '../utils/routeGeometry';
|
||||
import TripPlannerPage from './TripPlannerPage';
|
||||
|
||||
// ── Component stubs ───────────────────────────────────────────────────────────
|
||||
@@ -30,6 +31,22 @@ import TripPlannerPage from './TripPlannerPage';
|
||||
// callbacks can be invoked from the test.
|
||||
type Props = Record<string, unknown>;
|
||||
const captured: Record<string, Props> = {};
|
||||
const plannerActions = vi.hoisted(() => ({
|
||||
viewGpxTour: vi.fn(),
|
||||
addWaypoint: vi.fn(),
|
||||
openTour: vi.fn(),
|
||||
startNewTour: vi.fn(),
|
||||
forgetDeletedTour: vi.fn((_placeId: number) => undefined),
|
||||
saveOutcome: null as unknown,
|
||||
mapFocusKey: 0,
|
||||
newDraftGeneration: 0,
|
||||
mode: { type: 'neutral' } as unknown,
|
||||
isSaving: false,
|
||||
waypoints: [] as unknown,
|
||||
routeProfileFocus: null as unknown,
|
||||
readOnlyGpxTour: null as unknown,
|
||||
readOnlyGpxAnalysis: null as unknown,
|
||||
}));
|
||||
|
||||
function stub(name: string, testId?: string) {
|
||||
return (props: Props) => {
|
||||
@@ -108,6 +125,39 @@ vi.mock('../components/Planner/ReservationsPanel', () => ({
|
||||
default: stub('reservationsPanel', 'reservations-panel'),
|
||||
}));
|
||||
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }));
|
||||
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }));
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', () => ({
|
||||
useTourPlanner: () => ({
|
||||
route: null,
|
||||
waypoints: plannerActions.waypoints,
|
||||
routeProfileFocus: plannerActions.routeProfileFocus,
|
||||
name: '',
|
||||
startNewTour: plannerActions.startNewTour,
|
||||
newTourConfirmationOpen: false,
|
||||
cancelNewTour: vi.fn(),
|
||||
elevationProfileExpanded: true,
|
||||
toggleElevationProfile: vi.fn(),
|
||||
newDraftGeneration: plannerActions.newDraftGeneration,
|
||||
mapFocusKey: plannerActions.mapFocusKey,
|
||||
mode: plannerActions.mode,
|
||||
isSaving: plannerActions.isSaving,
|
||||
selectedWaypointId: null,
|
||||
editingPlaceId: null,
|
||||
saveOutcome: plannerActions.saveOutcome,
|
||||
hasUnsavedChanges: false,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
mapBaseLayer: 'map',
|
||||
setMapBaseLayer: vi.fn(),
|
||||
openTour: plannerActions.openTour,
|
||||
viewGpxTour: plannerActions.viewGpxTour,
|
||||
addWaypoint: plannerActions.addWaypoint,
|
||||
closeGpxTour: vi.fn(),
|
||||
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
|
||||
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
|
||||
discard: vi.fn(),
|
||||
forgetDeletedTour: plannerActions.forgetDeletedTour,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }));
|
||||
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }));
|
||||
@@ -388,6 +438,7 @@ function baseState(): HookState {
|
||||
handleSavePlace: vi.fn(async () => undefined),
|
||||
openPlaceEditor: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
handleDeleteTour: vi.fn(),
|
||||
confirmDeletePlace: vi.fn(async () => undefined),
|
||||
confirmDeletePlaces: vi.fn(async () => undefined),
|
||||
confirmChangeCategory: vi.fn(async () => undefined),
|
||||
@@ -403,6 +454,16 @@ function baseState(): HookState {
|
||||
selectedPlace: null,
|
||||
dayOrderMap: {},
|
||||
dayPlaces: [],
|
||||
toursEnabled: false,
|
||||
toursMode: true,
|
||||
setToursMode: vi.fn(),
|
||||
tours: [],
|
||||
toursLoading: false,
|
||||
tourDataReady: true,
|
||||
tourPlaceIds: new Set<number>(),
|
||||
reloadTourPlaceIds: vi.fn(async () => undefined),
|
||||
upsertTour: vi.fn(),
|
||||
selectedTour: null,
|
||||
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
|
||||
fontStyle: { fontFamily: 'var(--font-system)' },
|
||||
splashDone: true,
|
||||
@@ -422,17 +483,549 @@ function renderPage(overrides: HookState = {}) {
|
||||
return render(<TripPlannerPage />);
|
||||
}
|
||||
|
||||
function mockStartNewTourTransition() {
|
||||
plannerActions.startNewTour.mockImplementation(() => {
|
||||
plannerActions.mode = { type: 'new-draft' };
|
||||
plannerActions.waypoints = [];
|
||||
plannerActions.saveOutcome = null;
|
||||
plannerActions.mapFocusKey += 1;
|
||||
plannerActions.newDraftGeneration += 1;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
for (const key of Object.keys(captured)) delete captured[key];
|
||||
confirmDialogs.length = 0;
|
||||
plannerActions.viewGpxTour.mockReset();
|
||||
plannerActions.addWaypoint.mockReset();
|
||||
plannerActions.openTour.mockReset();
|
||||
plannerActions.startNewTour.mockReset();
|
||||
plannerActions.forgetDeletedTour.mockReset();
|
||||
plannerActions.saveOutcome = null;
|
||||
plannerActions.mapFocusKey = 0;
|
||||
plannerActions.newDraftGeneration = 0;
|
||||
plannerActions.mode = { type: 'neutral' };
|
||||
plannerActions.isSaving = false;
|
||||
plannerActions.waypoints = [];
|
||||
plannerActions.routeProfileFocus = null;
|
||||
plannerActions.readOnlyGpxTour = null;
|
||||
plannerActions.readOnlyGpxAnalysis = null;
|
||||
hookState = baseState();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) });
|
||||
useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never);
|
||||
});
|
||||
|
||||
describe('TripPlannerPage — shell', () => {
|
||||
it('UI-R2: Tours Planner rails use shared resize bounds, touch handles and keyboard nudges', () => {
|
||||
const { container } = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
toursEnabled: true,
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
});
|
||||
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]');
|
||||
expect(handles).toHaveLength(2);
|
||||
expect(Array.from(handles, (handle) => handle.getAttribute('data-resize'))).toEqual(['left', 'right']);
|
||||
for (const handle of handles) {
|
||||
expect(handle).toHaveAttribute('aria-valuemin', '200');
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '520');
|
||||
expect(handle.style.touchAction).toBe('none');
|
||||
expect(handle).toHaveAttribute('aria-label');
|
||||
}
|
||||
fireEvent.touchStart(handles[0]);
|
||||
expect(hookState.startResizeLeft).toHaveBeenCalled();
|
||||
fireEvent.keyDown(handles[1], { key: 'ArrowLeft' });
|
||||
expect(hookState.nudgeRight).toHaveBeenCalledWith(16);
|
||||
expect(props('map').leftWidth).toBe(320);
|
||||
expect(props('map').rightWidth).toBe(320);
|
||||
});
|
||||
|
||||
it('UI-R2: collapsed Tours rails retain semantic restore controls and report zero map insets', () => {
|
||||
renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
toursEnabled: true,
|
||||
leftHidden: true,
|
||||
rightHidden: true,
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
});
|
||||
expect(props('map').leftWidth).toBe(0);
|
||||
expect(props('map').rightWidth).toBe(0);
|
||||
expect(screen.getByRole('button', { name: 'trip.mobilePlan' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'tours.planner.tripTours' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'trip.mobilePlan' })[0]).toHaveClass('focus-visible:outline');
|
||||
expect(document.querySelectorAll('div[role="separator"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ leftHidden: false, rightHidden: true, visibleSide: 'left', leftInset: 300, rightInset: 0 },
|
||||
{ leftHidden: true, rightHidden: false, visibleSide: 'right', leftInset: 0, rightInset: 300 },
|
||||
])(
|
||||
'UI-R2: the 768-1023px panel state shows only its selected rail ($visibleSide)',
|
||||
({ leftHidden, rightHidden, visibleSide, leftInset, rightInset }) => {
|
||||
const { container } = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
toursEnabled: true,
|
||||
narrowPanels: true,
|
||||
leftHidden,
|
||||
rightHidden,
|
||||
leftWidth: 300,
|
||||
rightWidth: 300,
|
||||
resizeMax: 300,
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
});
|
||||
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]');
|
||||
expect(handles).toHaveLength(1);
|
||||
expect(handles[0]).toHaveAttribute('data-resize', visibleSide);
|
||||
expect(handles[0]).toHaveAttribute('aria-valuemax', '300');
|
||||
expect(props('map').leftWidth).toBe(leftInset);
|
||||
expect(props('map').rightWidth).toBe(rightInset);
|
||||
}
|
||||
);
|
||||
|
||||
it('UI-R2: rail collapse changes only map insets, not the fit target or routed draft', () => {
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
toursEnabled: true,
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
});
|
||||
const focusPoints = props('map').focusPoints;
|
||||
const route = props('map').route;
|
||||
const fitKey = props('map').fitKey;
|
||||
|
||||
hookState = { ...hookState, leftHidden: true, rightHidden: true };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(props('map').leftWidth).toBe(0);
|
||||
expect(props('map').rightWidth).toBe(0);
|
||||
expect(props('map').focusPoints).toBe(focusPoints);
|
||||
expect(props('map').route).toBe(route);
|
||||
expect(props('map').fitKey).toBe(fitKey);
|
||||
expect(plannerActions.mode).toEqual({ type: 'neutral' });
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-066: Tours entry stays neutral when saved Tours exist and does not auto-select one', async () => {
|
||||
const savedTour = {
|
||||
place_id: 92,
|
||||
name: 'Editable ridge tour',
|
||||
tour_type: 'hike',
|
||||
distance: 6,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 290,
|
||||
duration: 120,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
};
|
||||
renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
toursEnabled: true,
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
tours: [savedTour],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Plan a tour')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Plan new tour' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Editable ridge tour/i })).not.toHaveAttribute('aria-selected', 'true');
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the visible TOUR-PLANNER camera when the Plan a Tour button starts an empty draft', async () => {
|
||||
const place = buildPlace({ id: 81, lat: 35.01, lng: 135.76 });
|
||||
mockStartNewTourTransition();
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [place],
|
||||
});
|
||||
const mapElement = screen.getByTestId('map-view');
|
||||
|
||||
expect(props('map').focusPoints).toEqual([[place.lat, place.lng]]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Plan new tour' }));
|
||||
expect(plannerActions.startNewTour).toHaveBeenCalledOnce();
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument();
|
||||
expect(props('map').focusPoints).toEqual([]);
|
||||
expect(props('map').route).toBeNull();
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(props('map').focusKey).toBe(1);
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(props('map').onMapClick).toBeTypeOf('function');
|
||||
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }));
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
|
||||
});
|
||||
|
||||
it('preserves the visible TOUR-PLANNER camera when Plan another starts a new Tour', async () => {
|
||||
const savedTour = {
|
||||
place_id: 82,
|
||||
name: 'Saved Kyoto tour',
|
||||
tour_type: 'hike',
|
||||
distance: 6,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 290,
|
||||
duration: 120,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
};
|
||||
mockStartNewTourTransition();
|
||||
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id };
|
||||
plannerActions.waypoints = [
|
||||
{ id: 'start', lat: 35.01, lng: 135.76, role: 'start' },
|
||||
{ id: 'end', lat: 35.02, lng: 135.77, role: 'end' },
|
||||
];
|
||||
plannerActions.mapFocusKey = 8;
|
||||
plannerActions.saveOutcome = savedTour;
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
tours: [savedTour],
|
||||
places: [buildPlace({ id: savedTour.place_id, lat: 35.01, lng: 135.76 })],
|
||||
});
|
||||
const mapElement = screen.getByTestId('map-view');
|
||||
|
||||
expect(props('map').focusPoints).toEqual([
|
||||
[35.01, 135.76],
|
||||
[35.02, 135.77],
|
||||
]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Plan another' }));
|
||||
expect(plannerActions.startNewTour).toHaveBeenCalledOnce();
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument();
|
||||
expect(props('map').focusPoints).toEqual([]);
|
||||
expect(props('map').route).toBeNull();
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(props('map').focusKey).toBe(9);
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(props('map').onMapClick).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-063: uses the last TRIP-PLAN viewport for neutral TOUR-PLANNER fallback', () => {
|
||||
const first = buildPlace({ id: 81, lat: 47.1, lng: 11.2 });
|
||||
const second = buildPlace({ id: 82, lat: 47.3, lng: 11.6 });
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [first, second],
|
||||
});
|
||||
|
||||
expect(props('map').focusPoints).toEqual([
|
||||
[47.1, 11.2],
|
||||
[47.3, 11.6],
|
||||
]);
|
||||
expect(props('map').route).toBeNull();
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(props('map').onMapClick).toBeUndefined();
|
||||
plannerActions.mode = { type: 'new-draft' };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
plannerActions.isSaving = true;
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').onMapClick).toBeUndefined();
|
||||
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
||||
plannerActions.isSaving = false;
|
||||
view.rerender(<TripPlannerPage />);
|
||||
act(() => props('map').onMapClick({ latlng: { lat: 47.11, lng: 11.21 } }));
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(47.11, 11.21);
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
|
||||
hookState = { ...hookState, activeTab: 'plan' };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
act(() => props('map').onViewportChange({ south: 46.9, west: 10.8, north: 47.5, east: 12.1 }));
|
||||
plannerActions.mode = { type: 'neutral' };
|
||||
plannerActions.waypoints = [];
|
||||
hookState = { ...hookState, activeTab: 'tour-planner' };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(props('map').focusPoints).toEqual([
|
||||
[46.9, 10.8],
|
||||
[47.5, 12.1],
|
||||
]);
|
||||
expect(props('map').route).toBeNull();
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps editable fit targets stable while transient profile focus changes', () => {
|
||||
const waypoints = [
|
||||
{ id: 'start', lat: 47.1, lng: 11.2, role: 'start' },
|
||||
{ id: 'end', lat: 47.3, lng: 11.6, role: 'end' },
|
||||
];
|
||||
plannerActions.mode = { type: 'edit-saved', placeId: 92 };
|
||||
plannerActions.waypoints = waypoints;
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
});
|
||||
const fitTargets = props('map').focusPoints;
|
||||
const route = props('map').route;
|
||||
const mapWaypoints = props('map').plannerWaypoints;
|
||||
|
||||
plannerActions.routeProfileFocus = {
|
||||
distanceMeters: 10,
|
||||
elevationMeters: 100,
|
||||
lat: 47.11,
|
||||
lng: 11.21,
|
||||
sampleIndex: 1,
|
||||
};
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').routeProfileFocus).toBe(plannerActions.routeProfileFocus);
|
||||
expect(props('map').focusPoints).toBe(fitTargets);
|
||||
expect(props('map').route).toBe(route);
|
||||
expect(props('map').plannerWaypoints).toBe(mapWaypoints);
|
||||
|
||||
plannerActions.routeProfileFocus = {
|
||||
distanceMeters: 20,
|
||||
elevationMeters: 110,
|
||||
lat: 47.12,
|
||||
lng: 11.22,
|
||||
sampleIndex: 1,
|
||||
};
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').focusPoints).toBe(fitTargets);
|
||||
expect(props('map').route).toBe(route);
|
||||
expect(props('map').plannerWaypoints).toBe(mapWaypoints);
|
||||
|
||||
plannerActions.waypoints = [...waypoints, { id: 'via', lat: 47.2, lng: 11.4, role: 'via' }];
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').focusPoints).not.toBe(fitTargets);
|
||||
expect(props('map').focusPoints).toEqual([
|
||||
[47.1, 11.2],
|
||||
[47.3, 11.6],
|
||||
[47.2, 11.4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-064: falls back to the normal map default when there is no viewport or geo Place', () => {
|
||||
renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [],
|
||||
});
|
||||
|
||||
expect(props('map').focusPoints).toEqual([]);
|
||||
expect(props('map').center).toBeUndefined();
|
||||
expect(props('map').zoom).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-065: Tours OFF stays on native Plan and does not create the Tours Planner hook', () => {
|
||||
const view = renderPage({ activeTab: 'plan', toursEnabled: false });
|
||||
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument();
|
||||
|
||||
hookState = { ...hookState, activeTab: 'plan' };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer permanent Tour Delete from the desktop TRIP-PLAN detail', () => {
|
||||
const tourPlace = buildPlace({ id: 91, name: 'Ridge walk', tour_place_id: 91 });
|
||||
const selectedTour = {
|
||||
place_id: 91,
|
||||
name: tourPlace.name,
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 390,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
};
|
||||
renderPage({
|
||||
toursEnabled: true,
|
||||
selectedPlace: tourPlace,
|
||||
selectedPlaceId: tourPlace.id,
|
||||
selectedTour,
|
||||
});
|
||||
expect(props('tourDetail').onDelete).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes visible TOUR-PLANNER Tours-rail deletion through the existing confirmation', async () => {
|
||||
const savedTour = {
|
||||
place_id: 91,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 390,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
};
|
||||
const handleDeleteTour = vi.fn((placeId: number) => {
|
||||
hookState = { ...hookState, deletePlaceId: placeId, deletePlaceIsTour: true };
|
||||
});
|
||||
const setDeletePlaceId = vi.fn((placeId: number | null) => {
|
||||
hookState = { ...hookState, deletePlaceId: placeId };
|
||||
});
|
||||
const confirmDeletePlace = vi.fn(async (): Promise<number | null> => savedTour.place_id);
|
||||
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id };
|
||||
plannerActions.forgetDeletedTour.mockImplementation((placeId) => {
|
||||
const mode = plannerActions.mode as { placeId?: number };
|
||||
if (mode.placeId === placeId) plannerActions.mode = { type: 'neutral' };
|
||||
});
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
tours: [savedTour],
|
||||
handleDeleteTour,
|
||||
setDeletePlaceId,
|
||||
confirmDeletePlace,
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Ridge walk' }));
|
||||
expect(handleDeleteTour).toHaveBeenCalledWith(savedTour.place_id);
|
||||
view.rerender(<TripPlannerPage />);
|
||||
let confirmation = [...confirmDialogs].reverse().find((dialog) => dialog.isOpen);
|
||||
expect(confirmation).toMatchObject({ title: 'common.delete', confirmLabel: 'tours.delete.confirmAction' });
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument();
|
||||
|
||||
const onClose = confirmation?.onClose as (() => void) | undefined;
|
||||
onClose?.();
|
||||
expect(setDeletePlaceId).toHaveBeenCalledWith(null);
|
||||
expect(confirmDeletePlace).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }));
|
||||
view.rerender(<TripPlannerPage />);
|
||||
confirmation = [...confirmDialogs].reverse().find((dialog) => dialog.isOpen);
|
||||
const onConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined;
|
||||
await act(async () => {
|
||||
await onConfirm?.();
|
||||
});
|
||||
expect(confirmDeletePlace).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.forgetDeletedTour).toHaveBeenCalledWith(savedTour.place_id);
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(screen.getByText('Plan a tour')).toBeInTheDocument();
|
||||
|
||||
confirmDeletePlace.mockResolvedValue(null);
|
||||
plannerActions.forgetDeletedTour.mockClear();
|
||||
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }));
|
||||
view.rerender(<TripPlannerPage />);
|
||||
confirmation = [...confirmDialogs].reverse().find((dialog) => dialog.isOpen);
|
||||
const failedConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined;
|
||||
await act(async () => {
|
||||
await failedConfirm?.();
|
||||
});
|
||||
expect(plannerActions.forgetDeletedTour).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument();
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes Tours from the mobile TRIP-PLAN Places pool when the addon is enabled', () => {
|
||||
const tourPlace = buildPlace({ id: 92, name: 'Mobile ridge', tour_place_id: 92 });
|
||||
const tourPlaceIds = new Set([tourPlace.id]);
|
||||
renderPage({
|
||||
isMobile: true,
|
||||
mobileSidebarOpen: 'right',
|
||||
toursEnabled: true,
|
||||
tourPlaceIds,
|
||||
places: [tourPlace],
|
||||
});
|
||||
|
||||
expect(props('places').toursEnabled).toBe(true);
|
||||
expect(props('places').excludePlaceIds).toBe(tourPlaceIds);
|
||||
});
|
||||
|
||||
it('passes Tour facet identity to desktop and mobile TRIP-PLAN day cards', () => {
|
||||
const tourPlaceIds = new Set([92]);
|
||||
const view = renderPage({ activeTab: 'plan', toursEnabled: true, tourPlaceIds });
|
||||
|
||||
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds);
|
||||
|
||||
hookState = { ...hookState, isMobile: true, mobileSidebarOpen: 'left' };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds);
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-062: opens GPX geometry in the read-only Planner path without mounting the general detail dialog', async () => {
|
||||
const gpxPlace = buildPlace({
|
||||
id: 91,
|
||||
name: 'Imported ridge GPX',
|
||||
lat: 47.1,
|
||||
lng: 11.2,
|
||||
route_geometry: '[[47.1,11.2,500],[47.2,11.3,550]]',
|
||||
});
|
||||
const gpxTour = {
|
||||
place_id: 91,
|
||||
name: 'Imported ridge GPX',
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 420,
|
||||
duration: 180,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: false,
|
||||
};
|
||||
const trekTour = { ...gpxTour, place_id: 92, name: 'Editable ridge tour', has_waypoints: true };
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [gpxPlace],
|
||||
tours: [gpxTour, trekTour],
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Imported ridge GPX/i }));
|
||||
|
||||
expect(plannerActions.viewGpxTour).toHaveBeenCalledWith(gpxTour, gpxPlace.route_geometry);
|
||||
expect(screen.queryByTestId('tour-detail-dialog')).not.toBeInTheDocument();
|
||||
|
||||
const analysis = analyzeRouteGeometry(gpxPlace.route_geometry);
|
||||
plannerActions.readOnlyGpxTour = { tour: gpxTour, routeGeometry: gpxPlace.route_geometry };
|
||||
plannerActions.readOnlyGpxAnalysis = analysis;
|
||||
plannerActions.mode = { type: 'view-gpx', placeId: gpxTour.place_id, tour: gpxTour };
|
||||
view.rerender(<TripPlannerPage />);
|
||||
|
||||
expect(props('map').route).toEqual([analysis?.routeCoordinates]);
|
||||
expect(props('map').focusPoints).toEqual(analysis?.routeCoordinates);
|
||||
expect(props('map').onMapClick).toBeUndefined();
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(screen.getByRole('heading', { name: 'GPX tour' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Imported ridge GPX')).toHaveLength(2);
|
||||
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument();
|
||||
|
||||
expect(plannerActions.viewGpxTour).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-PAGE-TPW-002: the splash holds the page until the trip finished loading', () => {
|
||||
renderPage({ isLoading: true, splashDone: false });
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import * as ts from 'typescript'
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in static tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in static tests') })
|
||||
vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in static tests') })
|
||||
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in static tests') })
|
||||
|
||||
const pagePath = 'client/src/pages/TripPlannerPage.tsx'
|
||||
const hookPath = 'client/src/pages/tripPlanner/useTripPlanner.ts'
|
||||
const pageControllerPath = 'client/src/pages/tripPlanner/useTripPlannerPage.ts'
|
||||
const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8')
|
||||
const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8')
|
||||
const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8')
|
||||
const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
||||
const page = parse(pagePath, pageText)
|
||||
const hook = parse(hookPath, hookText)
|
||||
const pageController = parse(pageControllerPath, pageControllerText)
|
||||
|
||||
function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] {
|
||||
const matches: ts.Node[] = []
|
||||
function visit(node: ts.Node) {
|
||||
if (predicate(node)) matches.push(node)
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
return matches
|
||||
}
|
||||
|
||||
function initializer(source: ts.SourceFile, name: string): ts.Expression {
|
||||
const declaration = collect(source, node => ts.isVariableDeclaration(node)
|
||||
&& ts.isIdentifier(node.name) && node.name.text === name)[0]
|
||||
assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name)
|
||||
return declaration.initializer
|
||||
}
|
||||
|
||||
function callNamed(source: ts.SourceFile, name: string): ts.CallExpression | undefined {
|
||||
const call = collect(source, node => ts.isCallExpression(node)
|
||||
&& ts.isIdentifier(node.expression) && node.expression.text === name)[0]
|
||||
return call && ts.isCallExpression(call) ? call : undefined
|
||||
}
|
||||
|
||||
function propertyValue(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined {
|
||||
const property = object.properties.find(item => ts.isPropertyAssignment(item)
|
||||
&& (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === name)
|
||||
return property && ts.isPropertyAssignment(property) ? property.initializer : undefined
|
||||
}
|
||||
|
||||
function mobileSourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) return mobileSourceFiles(path)
|
||||
return /\.tsx?$/.test(entry.name) ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
function jsxTags(source: ts.SourceFile, name: string): (ts.JsxOpeningElement | ts.JsxSelfClosingElement)[] {
|
||||
return collect(source, node => (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
|
||||
&& node.tagName.getText(source) === name)
|
||||
.filter((node): node is ts.JsxOpeningElement | ts.JsxSelfClosingElement =>
|
||||
ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
|
||||
}
|
||||
|
||||
test('B4A Core testability debt: verify source without importing the page, hook, config, or database', () => {
|
||||
for (const source of [page, hook, pageController]) {
|
||||
const diagnostics = ts.transpileModule(source.text, {
|
||||
fileName: source.fileName,
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2022 },
|
||||
}).diagnostics ?? []
|
||||
assert.equal(diagnostics.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error).length, 0, source.fileName)
|
||||
assert.doesNotMatch(source.text, /^(<<<<<<<|=======|>>>>>>>)/m)
|
||||
}
|
||||
assert.doesNotMatch(pageText, /\bnarrowPanels\b/)
|
||||
const self = parse('b4a-wiring.test.ts', readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/b4a-wiring.test.ts'), 'utf8'))
|
||||
const imports = collect(self, ts.isImportDeclaration).map(node => {
|
||||
assert.ok(ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier))
|
||||
return node.moduleSpecifier.text
|
||||
})
|
||||
assert.deepEqual(imports, ['node:assert/strict', 'node:fs', 'node:path', 'typescript', 'vitest'])
|
||||
assert.equal(collect(self, node => ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword).length, 0)
|
||||
})
|
||||
|
||||
test('B4A Tours tab is readable, desktop-only, and absent with addon OFF', () => {
|
||||
const tabs = initializer(hook, 'TRIP_TABS')
|
||||
const branches = collect(tabs, node => ts.isConditionalExpression(node)
|
||||
&& node.whenTrue.getText(hook).includes("id: 'tour-planner'"))
|
||||
assert.equal(branches.length, 1)
|
||||
const branch = branches[0]
|
||||
assert.ok(ts.isConditionalExpression(branch))
|
||||
assert.equal(branch.condition.getText(hook), 'enabledAddons.tours && !isMobile')
|
||||
assert.equal(branch.whenFalse.getText(hook), '[]')
|
||||
assert.match(branch.whenTrue.getText(hook), /desktopOnly: true/)
|
||||
assert.doesNotMatch(tabs.getText(hook), /\bcan\b|canPlaceEdit|canDayEdit/)
|
||||
assert.match(hookText, /!validTabIds\.includes\(activeTab\)[\s\S]*?setActiveTab\('plan'\)/)
|
||||
assert.match(pageText, /activeTab === 'tour-planner' && enabledAddons\.tours && !isMobile/)
|
||||
})
|
||||
|
||||
test('B4A selectedTour is derived only from explicit place selection and addon state', () => {
|
||||
const selection = initializer(hook, 'selectedTour')
|
||||
assert.ok(ts.isConditionalExpression(selection))
|
||||
assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId')
|
||||
assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/)
|
||||
assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword)
|
||||
})
|
||||
|
||||
test('B4A desktop page delegates Tours state and permissions to its page hook', () => {
|
||||
const pageHookImport = collect(page, node => ts.isImportDeclaration(node)
|
||||
&& ts.isStringLiteral(node.moduleSpecifier)
|
||||
&& node.moduleSpecifier.text === './tripPlanner/useTripPlannerPage')[0]
|
||||
assert.ok(pageHookImport && ts.isImportDeclaration(pageHookImport))
|
||||
const importedBindings = pageHookImport.importClause?.namedBindings
|
||||
assert.ok(importedBindings && ts.isNamedImports(importedBindings)
|
||||
&& importedBindings.elements.some(element => element.name.text === 'useTripPlannerPage'))
|
||||
|
||||
const desktop = collect(page, node => ts.isFunctionDeclaration(node)
|
||||
&& node.name?.text === 'TripPlannerPageDesktop')[0]
|
||||
assert.ok(desktop)
|
||||
const pageHookCall = collect(desktop, node => ts.isCallExpression(node)
|
||||
&& ts.isIdentifier(node.expression) && node.expression.text === 'useTripPlannerPage')[0]
|
||||
assert.ok(pageHookCall)
|
||||
|
||||
assert.ok(callNamed(pageController, 'useTourPlanner'))
|
||||
const mobileFiles = mobileSourceFiles(resolve(process.cwd(), 'src/mobile'))
|
||||
for (const file of mobileFiles) {
|
||||
const mobileSource = parse(file, readFileSync(file, 'utf8'))
|
||||
const importsDesktopHook = collect(mobileSource, ts.isImportDeclaration).some(node => {
|
||||
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return false
|
||||
const modulePath = node.moduleSpecifier.text
|
||||
const namedImport = node.importClause?.namedBindings
|
||||
return /(?:^|\/)useTripPlannerPage(?:\.tsx?)?$/.test(modulePath)
|
||||
|| (namedImport && ts.isNamedImports(namedImport)
|
||||
&& namedImport.elements.some(element => element.name.text === 'useTripPlannerPage'))
|
||||
})
|
||||
assert.equal(importsDesktopHook, false, file)
|
||||
}
|
||||
|
||||
const permissions = initializer(pageController, 'canPlaceEdit')
|
||||
const permissionCalls = collect(pageController, node => ts.isCallExpression(node)
|
||||
&& ts.isIdentifier(node.expression) && node.expression.text === 'can')
|
||||
for (const action of ['place_edit', 'day_edit']) {
|
||||
assert.ok(permissionCalls.some(node => ts.isCallExpression(node)
|
||||
&& ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === action
|
||||
&& ts.isIdentifier(node.arguments[1]) && node.arguments[1].text === 'trip'))
|
||||
}
|
||||
assert.ok(ts.isCallExpression(permissions) && ts.isIdentifier(permissions.expression)
|
||||
&& permissions.expression.text === 'can')
|
||||
|
||||
const tourPlannerCall = callNamed(pageController, 'useTourPlanner')
|
||||
assert.ok(tourPlannerCall && ts.isObjectLiteralExpression(tourPlannerCall.arguments[0]))
|
||||
const tourPlannerOptions = tourPlannerCall.arguments[0]
|
||||
assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(pageController), 'canPlaceEdit')
|
||||
assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(pageController), 'canDayEdit')
|
||||
|
||||
const returnedViewModel = collect(pageController, node => ts.isReturnStatement(node)
|
||||
&& !!node.expression && ts.isObjectLiteralExpression(node.expression))
|
||||
.find((node): node is ts.ReturnStatement => ts.isReturnStatement(node))?.expression
|
||||
assert.ok(returnedViewModel && ts.isObjectLiteralExpression(returnedViewModel))
|
||||
const returnedPermissions = propertyValue(returnedViewModel, 'permissions')
|
||||
assert.ok(returnedPermissions && ts.isObjectLiteralExpression(returnedPermissions))
|
||||
assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property)
|
||||
&& property.name.text === 'canPlaceEdit'))
|
||||
assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property)
|
||||
&& property.name.text === 'canDayEdit'))
|
||||
|
||||
for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) {
|
||||
const tags = jsxTags(page, name)
|
||||
assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1)
|
||||
for (const tag of tags) {
|
||||
const canEdit = tag.attributes.properties.find(property => ts.isJsxAttribute(property)
|
||||
&& property.name.getText(page) === 'canEdit')
|
||||
const canAssign = tag.attributes.properties.find(property => ts.isJsxAttribute(property)
|
||||
&& property.name.getText(page) === 'canAssign')
|
||||
assert.ok(canEdit && ts.isJsxAttribute(canEdit) && canEdit.initializer
|
||||
&& ts.isJsxExpression(canEdit.initializer) && canEdit.initializer.expression?.getText(page) === 'canPlaceEdit')
|
||||
assert.ok(canAssign && ts.isJsxAttribute(canAssign) && canAssign.initializer
|
||||
&& ts.isJsxExpression(canAssign.initializer) && canAssign.initializer.expression?.getText(page) === 'canDayEdit')
|
||||
}
|
||||
}
|
||||
|
||||
const mapClickGate = collect(page, node => ts.isJsxAttribute(node)
|
||||
&& node.name.getText(page) === 'onMapClick'
|
||||
&& node.initializer && ts.isJsxExpression(node.initializer)
|
||||
&& !!node.initializer.expression && node.initializer.expression.getText(page).includes('canPlaceEdit'))
|
||||
assert.ok(mapClickGate.length > 0)
|
||||
})
|
||||
|
||||
test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
|
||||
assert.ok(callNamed(pageController, 'useTourPlanner'))
|
||||
assert.ok(pageText.includes('const pageState = useTripPlannerPage()'))
|
||||
assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'"))
|
||||
for (const mode of ['new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`))
|
||||
assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
|
||||
assert.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
|
||||
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
|
||||
assert.match(pageControllerText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/)
|
||||
assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/)
|
||||
})
|
||||
|
||||
test('B4A retains non-Tours panels and protected page integrations', () => {
|
||||
for (const name of ['HelpAnchor', 'RoadtripSidebar', 'RoadtripCorridorPanel', 'DayDetailPanel', 'PlaceInspector', 'BookingDetailPopup']) {
|
||||
assert.ok(jsxTags(page, name).length > 0, name)
|
||||
}
|
||||
assert.ok(jsxTags(page, 'PanelResizeHandle').length >= 2)
|
||||
for (const tab of ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab']) {
|
||||
const panels = collect(page, node => ts.isJsxExpression(node)
|
||||
&& !!node.expression && ts.isBinaryExpression(node.expression)
|
||||
&& node.expression.left.getText(page) === `activeTab === '${tab}'`)
|
||||
assert.equal(panels.length, 1, tab)
|
||||
}
|
||||
assert.match(hookText, /dayDetail && days\.some\(d => d\.id === dayDetail\.id\) \? dayDetail : null/)
|
||||
assert.match(hookText, /!stop\.automaticNight && !stop\.bookend/)
|
||||
})
|
||||
@@ -15,7 +15,8 @@ interface DayClearOptions {
|
||||
toast: { error: (message: string) => unknown }
|
||||
roadtripVias: Pick<RoadtripVias, 'byDay' | 'reanchor'>
|
||||
updateRouteForDay: (dayId: number) => void
|
||||
pushUndo: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => void
|
||||
pushUndo: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[], placeIds?: number[]) => void
|
||||
onToursChanged?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface DayClear {
|
||||
@@ -37,7 +38,7 @@ const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number
|
||||
* places back in their old order.
|
||||
*/
|
||||
export function useDayClear(options: DayClearOptions): DayClear {
|
||||
const { tripId, days, canEditDays, t, locale, toast, roadtripVias, updateRouteForDay, pushUndo } = options
|
||||
const { tripId, days, canEditDays, t, locale, toast, roadtripVias, updateRouteForDay, pushUndo, onToursChanged } = options
|
||||
const [clearDayId, setClearDayId] = useState<number | null>(null)
|
||||
|
||||
const ordered = useMemo(() => [...days].sort(byNumber), [days])
|
||||
@@ -68,14 +69,16 @@ export function useDayClear(options: DayClearOptions): DayClear {
|
||||
return
|
||||
}
|
||||
updateRouteForDay(dayId)
|
||||
await onToursChanged?.()
|
||||
const placeIds = cleared.map(a => a.place?.id).filter((id): id is number => id != null)
|
||||
pushUndo(t('undo.clearDay'), async () => {
|
||||
// One after the other: each lands at the position the one before it left free.
|
||||
for (const [position, placeId] of placeIds.entries()) {
|
||||
await useTripStore.getState().assignPlaceToDay(tripId, dayId, placeId, position)
|
||||
}
|
||||
}, [dayId])
|
||||
}, [clearDayId, tripId, roadtripVias, toast, t, updateRouteForDay, pushUndo])
|
||||
await onToursChanged?.()
|
||||
}, [dayId], placeIds)
|
||||
}, [clearDayId, tripId, roadtripVias, toast, t, updateRouteForDay, pushUndo, onToursChanged])
|
||||
|
||||
return { clearDayId, clearDayTitle, handleClearDay, cancelClearDay, confirmClearDay }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-177
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import React from 'react';
|
||||
import {
|
||||
buildAssignment,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
authApi,
|
||||
healthApi,
|
||||
mapsApi,
|
||||
toursApi,
|
||||
tripsApi,
|
||||
} from '../../api/client';
|
||||
import { getImportFiles, offlineDb, saveImportFiles } from '../../db/offlineDb';
|
||||
@@ -26,6 +28,7 @@ import { useTranslation } from '../../i18n';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo';
|
||||
import { fetchPhoto, getCached } from '../../services/photoService';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
@@ -723,6 +726,41 @@ describe('useTripPlanner — map derivations', () => {
|
||||
expect(result.current.mapPlaces.map((p) => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('Tours enabled clears a stale Tracks filter even while track geometry remains', async () => {
|
||||
const tour: TourListItem = {
|
||||
place_id: 7,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'tours' }] });
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [tour] });
|
||||
seedTrip({
|
||||
places: [
|
||||
buildPlace({ id: 7, lat: 1, lng: 2, tour_place_id: 7, route_geometry: '[[1,2],[3,4]]' }),
|
||||
buildPlace({ id: 8, lat: 3, lng: 4, route_geometry: '[[5,6],[7,8]]' }),
|
||||
],
|
||||
placesFilter: 'tracks',
|
||||
});
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
|
||||
|
||||
expect(result.current.enabledAddons.tours).toBe(true);
|
||||
expect(result.current.mapPlaces.map((place) => place.id)).toEqual([7, 8]);
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-027: the category filter honours the uncategorized bucket', async () => {
|
||||
seedTrip({
|
||||
places: [geo(1, { category_id: 3 }), geo(2, { category_id: null })],
|
||||
@@ -1485,6 +1523,116 @@ describe('useTripPlanner — place CRUD', () => {
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 900, 2);
|
||||
});
|
||||
|
||||
it('preserves legacy deletion behavior for a dormant Tour and forgets only its stale assignment undo', async () => {
|
||||
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 });
|
||||
const unrelatedUndo = vi.fn();
|
||||
const tourAssignmentUndo = vi.fn();
|
||||
seedTrip({ places: [tourPlace] });
|
||||
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] });
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
act(() => {
|
||||
result.current.pushUndo('Unrelated place edit', unrelatedUndo);
|
||||
result.current.pushUndo('Remove Tour from day', tourAssignmentUndo, [7], [1]);
|
||||
result.current.handleDeletePlace(1);
|
||||
});
|
||||
expect(result.current.deletePlaceIsTour).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.confirmDeletePlace();
|
||||
});
|
||||
|
||||
expect(actions.deletePlace).toHaveBeenCalledWith(42, 1);
|
||||
expect(actions.addPlace).not.toHaveBeenCalled();
|
||||
expect(result.current.lastActionLabel).toBe('Unrelated place edit');
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.undo();
|
||||
});
|
||||
expect(unrelatedUndo).toHaveBeenCalledTimes(1);
|
||||
expect(tourAssignmentUndo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows permanent Tour deletion only in TOUR-PLANNER and leaves cancellation inert', async () => {
|
||||
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 });
|
||||
seedTrip({ places: [tourPlace] });
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [] });
|
||||
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] });
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.toursEnabled).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeletePlace(1);
|
||||
});
|
||||
expect(result.current.deletePlaceId).toBeNull();
|
||||
expect(actions.deletePlace).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeleteTour(1);
|
||||
});
|
||||
expect(result.current.deletePlaceId).toBe(1);
|
||||
act(() => {
|
||||
result.current.setDeletePlaceId(null);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.confirmDeletePlace();
|
||||
});
|
||||
expect(actions.deletePlace).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeleteTour(1);
|
||||
});
|
||||
let deletedTourId: number | null = null;
|
||||
await act(async () => {
|
||||
deletedTourId = await result.current.confirmDeletePlace();
|
||||
});
|
||||
expect(actions.deletePlace).toHaveBeenCalledWith(42, 1);
|
||||
expect(actions.deletePlace).toHaveBeenCalledTimes(1);
|
||||
expect(deletedTourId).toBe(1);
|
||||
expect(actions.addPlace).not.toHaveBeenCalled();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the server facet result to suppress Undo for a dormant Tour when Tours is off', async () => {
|
||||
const dormantPlace = buildPlace({ id: 1, name: 'Dormant route' });
|
||||
seedTrip({ places: [dormantPlace] });
|
||||
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] });
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
act(() => {
|
||||
result.current.handleDeletePlace(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.confirmDeletePlace();
|
||||
});
|
||||
|
||||
expect(result.current.enabledAddons.tours).toBe(false);
|
||||
expect(actions.addPlace).not.toHaveBeenCalled();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('does not offer a partial bulk Undo when the deleted selection includes a Tour', async () => {
|
||||
const ordinary = buildPlace({ id: 1, name: 'Ordinary place' });
|
||||
const tourPlace = buildPlace({ id: 2, name: 'Tour', tour_place_id: 2 });
|
||||
seedTrip({ places: [ordinary, tourPlace] });
|
||||
actions.deletePlacesMany.mockResolvedValue({ deleted: [1, 2], count: 2, tourPlaceIds: [2] });
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
act(() => {
|
||||
result.current.setDeletePlaceIds([1, 2]);
|
||||
});
|
||||
expect(result.current.deletePlacesIncludeTours).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.confirmDeletePlaces();
|
||||
});
|
||||
|
||||
expect(actions.deletePlacesMany).toHaveBeenCalledWith(42, [1, 2]);
|
||||
expect(actions.addPlace).not.toHaveBeenCalled();
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-056: confirmDeletePlace is a no-op until a place is queued', async () => {
|
||||
seedTrip();
|
||||
|
||||
@@ -1497,18 +1645,43 @@ describe('useTripPlanner — place CRUD', () => {
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-057: a failing delete surfaces the server message', async () => {
|
||||
seedTrip({ places: [buildPlace({ id: 1, lat: 1, lng: 2 })] });
|
||||
actions.deletePlace.mockRejectedValue(new Error('place is locked'));
|
||||
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1 });
|
||||
const listedTour: TourListItem = {
|
||||
place_id: 1,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
seedTrip({ places: [place] });
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [listedTour] });
|
||||
actions.deletePlace.mockRejectedValue(new Error('Tour is locked'));
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.tours).toEqual([listedTour]));
|
||||
act(() => {
|
||||
result.current.handleDeletePlace(1);
|
||||
result.current.handleDeleteTour(1);
|
||||
});
|
||||
let failedTourId: number | null = 1;
|
||||
await act(async () => {
|
||||
await result.current.confirmDeletePlace();
|
||||
failedTourId = await result.current.confirmDeletePlace();
|
||||
});
|
||||
|
||||
expect(toasts.some((t) => t.message === 'place is locked' && t.type === 'error')).toBe(true);
|
||||
expect(failedTourId).toBeNull();
|
||||
expect(result.current.tours).toEqual([listedTour]);
|
||||
expect(useTripStore.getState().places).toContain(place);
|
||||
expect(toasts.some((t) => t.message === 'Tour is locked' && t.type === 'error')).toBe(true);
|
||||
expect(toasts.some((t) => t.type === 'success')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-058: a bulk delete restores every place with its assignments on undo', async () => {
|
||||
@@ -1731,6 +1904,207 @@ describe('useTripPlanner — place CRUD', () => {
|
||||
});
|
||||
|
||||
describe('useTripPlanner — day plan CRUD', () => {
|
||||
it('does not write a Tour assignment twice to one day, but allows another day and preserves Place writes', async () => {
|
||||
const tour: TourListItem = {
|
||||
place_id: 1,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 });
|
||||
const ordinaryPlace = buildPlace({ id: 2, name: 'Ordinary place' });
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'tours' }] });
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [tour] });
|
||||
seedTrip({
|
||||
places: [tourPlace, ordinaryPlace],
|
||||
assignments: { '7': [buildAssignment({ id: 70, day_id: 7, place: tourPlace })] },
|
||||
});
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.tours).toEqual([tour]));
|
||||
actions.assignPlaceToDay.mockClear();
|
||||
|
||||
let repeated!: boolean;
|
||||
await act(async () => {
|
||||
repeated = await result.current.handleAssignToDay(tourPlace.id, 7);
|
||||
});
|
||||
expect(repeated).toBe(false);
|
||||
expect(actions.assignPlaceToDay).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAssignToDay(tourPlace.id, 8);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleAssignToDay(ordinaryPlace.id, 7);
|
||||
});
|
||||
expect(actions.assignPlaceToDay).toHaveBeenNthCalledWith(1, 42, 8, tourPlace.id, undefined);
|
||||
expect(actions.assignPlaceToDay).toHaveBeenNthCalledWith(2, 42, 7, ordinaryPlace.id, undefined);
|
||||
});
|
||||
|
||||
it('refreshes Tours only after a successful Tour assignment write', async () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
const planned = { ...unplanned, planned: true };
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] });
|
||||
const place = buildPlace({ id: 1, tour_place_id: 1 });
|
||||
seedTrip({ places: [place] });
|
||||
let resolveAssignment!: (value: { id: number }) => void;
|
||||
const assignment = new Promise<{ id: number }>((resolve) => {
|
||||
resolveAssignment = resolve;
|
||||
});
|
||||
actions.assignPlaceToDay.mockReturnValue(assignment);
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.tours).toEqual([unplanned]));
|
||||
listTours.mockClear().mockResolvedValue({ tours: [planned] });
|
||||
let assignPromise!: Promise<boolean>;
|
||||
act(() => {
|
||||
assignPromise = result.current.handleAssignToDay(1, 7);
|
||||
});
|
||||
expect(listTours).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveAssignment({ id: 555 });
|
||||
await assignPromise;
|
||||
});
|
||||
await waitFor(() => expect(result.current.tours).toEqual([planned]));
|
||||
expect(actions.assignPlaceToDay.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0]);
|
||||
|
||||
listTours.mockClear().mockResolvedValue({ tours: [unplanned] });
|
||||
let resolveUndoRemoval!: () => void;
|
||||
const undoRemoval = new Promise<void>((resolve) => {
|
||||
resolveUndoRemoval = resolve;
|
||||
});
|
||||
actions.removeAssignment.mockReturnValueOnce(undoRemoval);
|
||||
let undoPromise!: Promise<unknown>;
|
||||
act(() => {
|
||||
undoPromise = result.current.undo();
|
||||
});
|
||||
expect(listTours).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
resolveUndoRemoval();
|
||||
await undoPromise;
|
||||
});
|
||||
await waitFor(() => expect(result.current.tours).toEqual([unplanned]));
|
||||
expect(actions.removeAssignment.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('does not refresh or report a successful planned state when Tour assignment fails', async () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] });
|
||||
const place = buildPlace({ id: 1, tour_place_id: 1 });
|
||||
seedTrip({ places: [place] });
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.tours).toEqual([unplanned]));
|
||||
listTours.mockClear();
|
||||
actions.assignPlaceToDay.mockRejectedValueOnce(new Error('assignment failed'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAssignToDay(1, 7);
|
||||
});
|
||||
|
||||
expect(listTours).not.toHaveBeenCalled();
|
||||
expect(result.current.tours).toEqual([unplanned]);
|
||||
expect(toasts.some((toast) => toast.message === 'assignment failed' && toast.type === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes after Tour removal and each successful assignment Undo direction', async () => {
|
||||
const unplanned: TourListItem = {
|
||||
place_id: 1,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
const planned: TourListItem = { ...unplanned, planned: true };
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true });
|
||||
const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [planned] });
|
||||
const place = buildPlace({ id: 1, tour_place_id: 1 });
|
||||
seedTrip({
|
||||
places: [place],
|
||||
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 1 })] },
|
||||
});
|
||||
const { result } = await renderPlanner();
|
||||
await waitFor(() => expect(result.current.tours).toEqual([planned]));
|
||||
listTours.mockClear().mockResolvedValue({ tours: [unplanned] });
|
||||
let resolveRemoval!: () => void;
|
||||
const removal = new Promise<void>((resolve) => {
|
||||
resolveRemoval = resolve;
|
||||
});
|
||||
actions.removeAssignment.mockReturnValueOnce(removal);
|
||||
let removePromise!: Promise<void>;
|
||||
act(() => {
|
||||
removePromise = result.current.handleRemoveAssignment(7, 10);
|
||||
});
|
||||
expect(listTours).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
resolveRemoval();
|
||||
await removePromise;
|
||||
});
|
||||
await waitFor(() => expect(result.current.tours).toEqual([unplanned]));
|
||||
expect(actions.removeAssignment.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0]);
|
||||
|
||||
listTours.mockClear().mockResolvedValue({ tours: [planned] });
|
||||
actions.assignPlaceToDay.mockResolvedValueOnce({ id: 777 });
|
||||
await act(async () => {
|
||||
await result.current.undo();
|
||||
});
|
||||
await waitFor(() => expect(result.current.tours).toEqual([planned]));
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 1);
|
||||
expect(actions.assignPlaceToDay.mock.invocationCallOrder[0]).toBeLessThan(listTours.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-063: assigning to the selected day registers an undo that removes it again', async () => {
|
||||
seedTrip({ selectedDayId: 7 });
|
||||
|
||||
@@ -1800,11 +2174,14 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
expect(actions.assignPlaceToDay).toHaveBeenNthCalledWith(2, 42, 7, 1, 1);
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-066: removing an assignment can be undone back to its old position', async () => {
|
||||
const place = buildPlace({ id: 1, lat: 1, lng: 2 });
|
||||
it('FE-TP-HOOK-066: removing a Tour assignment can be undone without deleting the Tour', async () => {
|
||||
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1, route_geometry: '[[1,2],[3,4]]' });
|
||||
seedTrip({
|
||||
places: [place],
|
||||
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })] },
|
||||
assignments: {
|
||||
'7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })],
|
||||
'8': [buildAssignment({ id: 11, day_id: 8, place, order_index: 2 })],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderPlanner();
|
||||
@@ -1813,6 +2190,13 @@ describe('useTripPlanner — day plan CRUD', () => {
|
||||
});
|
||||
|
||||
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 7, 10);
|
||||
expect(actions.deletePlace).not.toHaveBeenCalled();
|
||||
expect(actions.addPlace).not.toHaveBeenCalled();
|
||||
expect(useTripStore.getState().places).toContainEqual(
|
||||
expect.objectContaining({ id: place.id, route_geometry: place.route_geometry })
|
||||
);
|
||||
expect(useTripStore.getState().assignments['8']).toHaveLength(1);
|
||||
expect(useTripStore.getState().assignments['8'][0].id).toBe(11);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.undo();
|
||||
@@ -3039,6 +3423,24 @@ describe('useTripPlanner — misc state', () => {
|
||||
expect(result.current.deletePlaceId).toBeNull();
|
||||
});
|
||||
|
||||
it('routes mobile Place-editor entry for a dormant Tour to read-only Tour detail', async () => {
|
||||
const originalWidth = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 390 });
|
||||
try {
|
||||
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 });
|
||||
seedTrip({ places: [tourPlace] });
|
||||
const { result } = await renderPlanner();
|
||||
|
||||
act(() => result.current.openPlaceEditor(tourPlace));
|
||||
|
||||
expect(result.current.isMobile).toBe(true);
|
||||
expect(result.current.selectedPlaceId).toBe(1);
|
||||
expect(result.current.showPlaceForm).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
|
||||
seedTrip();
|
||||
const { result } = await renderPlanner();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { dayColor } from '../../components/Roadtrip/dayColors'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Route } from 'lucide-react'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, Route } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
@@ -62,6 +62,8 @@ import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
|
||||
import { inspectorStay } from '../../components/Roadtrip/stayReading'
|
||||
import { MAX_TRIP_DAYS, normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
@@ -130,7 +132,7 @@ export function useTripPlanner() {
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canUploadFiles = can('file_upload', trip)
|
||||
const { pushUndo, undo, forgetDay, canUndo, lastActionLabel } = usePlannerHistory()
|
||||
const { pushUndo, undo, forgetDay, forgetPlace, canUndo, lastActionLabel } = usePlannerHistory()
|
||||
|
||||
// A step that could not be taken back says so instead of claiming it was.
|
||||
const handleUndo = useCallback(async () => {
|
||||
@@ -140,7 +142,7 @@ export function useTripPlanner() {
|
||||
else if (undone) toast.info(t('undo.done', { action: label ?? '' }))
|
||||
}, [undo, lastActionLabel, toast])
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, tours: false, dawarich: false })
|
||||
// The values above are an optimistic guess until the addon feed answers. The
|
||||
// tab guard below waits for this before evicting anything, so a tab we were
|
||||
// asked to open ('collab' in particular, guessed off) survives the gap.
|
||||
@@ -226,7 +228,7 @@ export function useTripPlanner() {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map: Record<string, boolean> = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, tours: !!map.tours, dawarich: !!map.dawarich })
|
||||
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
|
||||
}).catch(() => {}).finally(() => setAddonsLoaded(true))
|
||||
authApi.getAppConfig().then(config => {
|
||||
@@ -243,6 +245,7 @@ export function useTripPlanner() {
|
||||
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
|
||||
...(enabledAddons.tours && !isMobile ? [{ id: 'tour-planner', label: t(TRIP_TAB_LABEL_KEYS['tour-planner']), icon: Mountain, desktopOnly: true }] : []),
|
||||
{ id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train },
|
||||
{ id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
||||
// Phone only: the desktop reaches the drive through the mode switch beside the
|
||||
@@ -283,7 +286,7 @@ export function useTripPlanner() {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded])
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
|
||||
|
||||
const handleTabChange = (rawTabId: string): void => {
|
||||
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
|
||||
@@ -314,6 +317,21 @@ export function useTripPlanner() {
|
||||
startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax,
|
||||
} = useResizablePanels()
|
||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||
const toursEnabled = useAddonStore(state => state.isEnabled('tours'))
|
||||
const [toursMode, setToursMode] = useState(false)
|
||||
const previousToursModeRef = useRef(false)
|
||||
const { tours, toursLoading, tourDataReady, tourPlaceIds, reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour } = useTourPlaceIds(tripId, toursEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!toursEnabled) {
|
||||
setToursMode(false)
|
||||
previousToursModeRef.current = false
|
||||
return
|
||||
}
|
||||
const enteredToursMode = toursMode && !previousToursModeRef.current
|
||||
previousToursModeRef.current = toursMode
|
||||
if (enteredToursMode) setSelectedPlaceId(null)
|
||||
}, [toursEnabled, toursMode, setSelectedPlaceId])
|
||||
const [dayDetail, setShowDayDetail] = useState<Day | null>(null)
|
||||
// A day deleted while its panel is open, here or by a fellow traveller, takes
|
||||
// the panel along instead of leaving it on a day that is gone.
|
||||
@@ -576,6 +594,10 @@ export function useTripPlanner() {
|
||||
const mobilePlacesScrollTopRef = useRef<number>(0)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||
const isTourPlace = useCallback((placeId: number) => tourPlaceIds.has(placeId)
|
||||
|| places.some(place => place.id === placeId && place.tour_place_id === placeId), [places, tourPlaceIds])
|
||||
const deletePlaceIsTour = deletePlaceId != null && isTourPlace(deletePlaceId)
|
||||
const deletePlacesIncludeTours = !!deletePlaceIds?.some(isTourPlace)
|
||||
/**
|
||||
* The sentence the delete question adds when a night is booked at one of the places.
|
||||
*
|
||||
@@ -706,7 +728,7 @@ export function useTripPlanner() {
|
||||
return () => window.removeEventListener('accommodations:refresh', onRefresh)
|
||||
}, [loadAccommodations])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
useTripWebSocket(tripId, invalidateTourPlaceIds)
|
||||
|
||||
// Same filter the places sidebar renders — shared via the store so tab
|
||||
// switches can't desync the marker set from the filter UI (#1541).
|
||||
@@ -774,7 +796,7 @@ export function useTripPlanner() {
|
||||
// mounted while the phone map is in front, and the map would sit empty under a
|
||||
// "Tracks" filter no control offers any more.
|
||||
const setPlacesFilter = useTripStore((s) => s.setPlacesFilter)
|
||||
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
||||
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
|
||||
useEffect(() => {
|
||||
if (placesFilter === 'tracks' && !hasTracks) setPlacesFilter('all')
|
||||
}, [placesFilter, hasTracks, setPlacesFilter])
|
||||
@@ -783,7 +805,7 @@ export function useTripPlanner() {
|
||||
// Road trip mode already draws the whole trip its own way, so the overview stands
|
||||
// down there rather than drawing a second set of lines over it.
|
||||
const overviewActive = overviewShown && !roadtripMode
|
||||
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
|
||||
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places)
|
||||
|
||||
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
|
||||
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
||||
@@ -2350,6 +2372,10 @@ export function useTripPlanner() {
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
if (isMobile && isTourPlace(place.id)) {
|
||||
handlePlaceClick(place.id, preferredAssignmentId)
|
||||
return
|
||||
}
|
||||
if (!can('place_edit', trip)) return
|
||||
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
|
||||
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
|
||||
@@ -2375,7 +2401,7 @@ export function useTripPlanner() {
|
||||
setPlaceFormDayId(null)
|
||||
setServiceStopForm(false)
|
||||
setShowPlaceForm(true)
|
||||
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
}, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
|
||||
/**
|
||||
* How long the drive stands here, for every stop alike.
|
||||
@@ -2389,24 +2415,42 @@ export function useTripPlanner() {
|
||||
setStayDraft(draft)
|
||||
}, [])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
setDeletePlaceId(placeId)
|
||||
}, [can, trip])
|
||||
const handleDeletePlace = useCallback(
|
||||
(placeId) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
if (toursEnabled && isTourPlace(placeId)) return
|
||||
setDeletePlaceId(placeId)
|
||||
},
|
||||
[can, trip, toursEnabled, isTourPlace]
|
||||
)
|
||||
|
||||
const handleDeleteTour = useCallback(
|
||||
(placeId: number) => {
|
||||
if (!can('place_edit', trip) || !toursEnabled || !isTourPlace(placeId)) return
|
||||
setDeletePlaceId(placeId)
|
||||
},
|
||||
[can, trip, toursEnabled, isTourPlace]
|
||||
)
|
||||
|
||||
const confirmDeletePlace = useCallback(async () => {
|
||||
if (!deletePlaceId) return
|
||||
if (!deletePlaceId) return null
|
||||
const state = useTripStore.getState()
|
||||
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
|
||||
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
||||
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
|
||||
)
|
||||
try {
|
||||
await tripActions.deletePlace(tripId, deletePlaceId)
|
||||
const deletion = await tripActions.deletePlace(tripId, deletePlaceId)
|
||||
const deletedTour = Array.isArray(deletion?.tourPlaceIds)
|
||||
? deletion.tourPlaceIds.includes(deletePlaceId)
|
||||
: isTourPlace(deletePlaceId)
|
||||
if (deletedTour) invalidateTourPlaceIds({ removedPlaceIds: [deletePlaceId] })
|
||||
else void reloadTourPlaceIds()
|
||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
if (capturedPlace) {
|
||||
if (deletedTour) forgetPlace(deletePlaceId)
|
||||
else if (capturedPlace) {
|
||||
pushUndo(t('undo.deletePlace'), async () => {
|
||||
const newPlace = await tripActions.addPlace(tripId, {
|
||||
name: capturedPlace.name,
|
||||
@@ -2426,8 +2470,12 @@ export function useTripPlanner() {
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
return deletedTour ? deletePlaceId : null
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return null
|
||||
}
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, invalidateTourPlaceIds, reloadTourPlaceIds, isTourPlace])
|
||||
|
||||
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
|
||||
const targetIds = ids ?? deletePlaceIds
|
||||
@@ -2438,12 +2486,17 @@ export function useTripPlanner() {
|
||||
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
|
||||
)
|
||||
try {
|
||||
await tripActions.deletePlacesMany(tripId, targetIds)
|
||||
const deletion = await tripActions.deletePlacesMany(tripId, targetIds)
|
||||
const deletedTourIds = Array.isArray(deletion?.tourPlaceIds)
|
||||
? deletion.tourPlaceIds.filter(id => targetIds.includes(id))
|
||||
: targetIds.filter(isTourPlace)
|
||||
void reloadTourPlaceIds()
|
||||
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
|
||||
if (!ids) setDeletePlaceIds(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
|
||||
if (capturedPlaces.length > 0) {
|
||||
if (deletedTourIds.length > 0) deletedTourIds.forEach(forgetPlace)
|
||||
else if (capturedPlaces.length > 0) {
|
||||
pushUndo(t('undo.deletePlaces'), async () => {
|
||||
const live = new Set(useTripStore.getState().days.map(d => d.id))
|
||||
for (const place of capturedPlaces) {
|
||||
@@ -2460,7 +2513,7 @@ export function useTripPlanner() {
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace])
|
||||
|
||||
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
|
||||
if (!ids.length) return
|
||||
@@ -2490,26 +2543,37 @@ export function useTripPlanner() {
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
|
||||
if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false
|
||||
const place = places.find(p => p.id === placeId)
|
||||
// A place with a start of its own is drawn by it, so it is stored there too, the
|
||||
// way a stop moved over from another day is. Without one it goes where it was put.
|
||||
const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position
|
||||
const plan = viasAfterInsert(target, slot, place)
|
||||
let assignment: Awaited<ReturnType<typeof tripActions.assignPlaceToDay>>
|
||||
try {
|
||||
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
if (plan) await roadtripVias.reanchor(target, plan)
|
||||
updateRouteForDay(target)
|
||||
if (assignment?.id) {
|
||||
const capturedAssignmentId = assignment.id
|
||||
const capturedTarget = target
|
||||
pushUndo(t('undo.assignPlace'), async () => {
|
||||
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
|
||||
}, [capturedTarget])
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
|
||||
assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return false
|
||||
}
|
||||
if (isTourPlace(placeId)) await reloadTourPlaceIds()
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
if (assignment?.id) {
|
||||
const capturedAssignmentId = assignment.id
|
||||
const capturedTarget = target
|
||||
pushUndo(t('undo.assignPlace'), async () => {
|
||||
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
|
||||
if (isTourPlace(placeId)) await reloadTourPlaceIds()
|
||||
}, [capturedTarget], [placeId])
|
||||
}
|
||||
if (plan) {
|
||||
try { await roadtripVias.reanchor(target, plan) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
updateRouteForDay(target)
|
||||
return true
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
|
||||
|
||||
/**
|
||||
* Moves a stop from the day list onto another day, at a row of that day or at its
|
||||
@@ -2532,6 +2596,7 @@ export function useTripPlanner() {
|
||||
const state = useTripStore.getState()
|
||||
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
|
||||
const capturedPlaceId = capturedAssignment?.place?.id
|
||||
const removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId)
|
||||
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
||||
// Worked out before the delete, while the day still has the stop the vias
|
||||
// were measured against. `after_order_index` is a POSITION, so taking a stop
|
||||
@@ -2547,6 +2612,7 @@ export function useTripPlanner() {
|
||||
: reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length)
|
||||
try {
|
||||
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||
if (removedTour) await reloadTourPlaceIds()
|
||||
if (plan) await roadtripVias.reanchor(dayId, plan)
|
||||
updateRouteForDay(dayId)
|
||||
if (capturedPlaceId != null) {
|
||||
@@ -2554,15 +2620,18 @@ export function useTripPlanner() {
|
||||
const capturedPos = capturedOrderIndex
|
||||
pushUndo(t('undo.removeAssignment'), async () => {
|
||||
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
|
||||
}, [capturedDayId])
|
||||
if (removedTour) await reloadTourPlaceIds()
|
||||
}, [capturedDayId], [capturedPlaceId])
|
||||
}
|
||||
}
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf])
|
||||
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace])
|
||||
|
||||
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
|
||||
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
|
||||
const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || []
|
||||
const prevIds = assignmentsBefore
|
||||
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))]
|
||||
// The rail counts anchors over routable stops only, so the plan is built in
|
||||
// that space. A drag here hands a whole new ordering rather than one move,
|
||||
// and any permutation is possible — so the anchors follow the stop they were
|
||||
@@ -2583,7 +2652,7 @@ export function useTripPlanner() {
|
||||
const capturedPrevIds = prevIds
|
||||
pushUndo(t('undo.reorder'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
}, [capturedDayId])
|
||||
}, [capturedDayId], placeIdsBefore)
|
||||
})
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
|
||||
updateRouteForDay(dayId)
|
||||
@@ -2626,7 +2695,8 @@ export function useTripPlanner() {
|
||||
forgetDay(dayId)
|
||||
loadAccommodations()
|
||||
updateRouteForDay(useTripStore.getState().selectedDayId)
|
||||
}, [loadAccommodations, updateRouteForDay, forgetDay])
|
||||
void reloadTourPlaceIds()
|
||||
}, [loadAccommodations, updateRouteForDay, forgetDay, reloadTourPlaceIds])
|
||||
const dayDelete = useDayDelete({
|
||||
tripId, trip, days, places: allPlaces, reservations, accommodations: tripAccommodations,
|
||||
canEditDays: can('day_edit', trip), t, locale, toast, onDeleted: afterDayDeleted,
|
||||
@@ -2634,6 +2704,7 @@ export function useTripPlanner() {
|
||||
|
||||
const dayClear = useDayClear({
|
||||
tripId, days, canEditDays: can('day_edit', trip), t, locale, toast, roadtripVias, updateRouteForDay, pushUndo,
|
||||
onToursChanged: reloadTourPlaceIds,
|
||||
})
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
@@ -2897,6 +2968,9 @@ export function useTripPlanner() {
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
const selectedTour = toursEnabled && selectedPlaceId
|
||||
? tours.find(tour => tour.place_id === selectedPlaceId) ?? null
|
||||
: null
|
||||
// The stops the inspector speaks for. A booked night at a day's edge stands on the
|
||||
// hotel's place without being a stop of the day, so it is left out: counted, the hotel's
|
||||
// own stop lost its stay and its day end to a second match.
|
||||
@@ -2966,6 +3040,8 @@ export function useTripPlanner() {
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
||||
startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax,
|
||||
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
||||
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
|
||||
reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour, selectedTour,
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
stayPickerDayId, setStayPickerDayId,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
@@ -3006,12 +3082,14 @@ export function useTripPlanner() {
|
||||
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
||||
isTourPlace, deletePlaceIsTour, deletePlacesIncludeTours,
|
||||
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeWalking, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleDeleteTour,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
|
||||
...dayDelete,
|
||||
...dayClear,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
|
||||
export function useTripPlannerPage() {
|
||||
const planner = useTripPlanner()
|
||||
const {
|
||||
activeTab,
|
||||
can,
|
||||
enabledAddons,
|
||||
isMobile,
|
||||
places,
|
||||
reloadTourPlaceIds,
|
||||
setSelectedPlaceId,
|
||||
t,
|
||||
toast,
|
||||
trip,
|
||||
tripActions,
|
||||
tripId,
|
||||
upsertTour,
|
||||
} = planner
|
||||
|
||||
const canPlaceEdit = can('place_edit', trip)
|
||||
const canDayEdit = can('day_edit', trip)
|
||||
const tourPlanner = useTourPlanner({
|
||||
tripId,
|
||||
canEdit: canPlaceEdit,
|
||||
canAssign: canDayEdit,
|
||||
active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
|
||||
onSaved: async result => {
|
||||
upsertTour(result.tour)
|
||||
toast.success(t('tours.planner.saved'))
|
||||
await tripActions.loadTrip(tripId)
|
||||
void reloadTourPlaceIds()
|
||||
},
|
||||
})
|
||||
|
||||
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
|
||||
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
|
||||
const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState<Bbox | null>(lastPlanMapViewportRef.current)
|
||||
useEffect(() => {
|
||||
if (activeTab === 'tour-planner') setPlannerViewportSnapshot(lastPlanMapViewportRef.current)
|
||||
}, [activeTab, tourPlanner.newDraftGeneration])
|
||||
|
||||
const plannerFallbackFocusPoints = useMemo<[number, number][]>(() => {
|
||||
const bounds = plannerViewportSnapshot
|
||||
if (bounds) {
|
||||
const latitudeSpan = bounds.north - bounds.south
|
||||
const longitudeSpan = bounds.east - bounds.west
|
||||
if (latitudeSpan > 0 && latitudeSpan < 160 && longitudeSpan > 0 && longitudeSpan < 340) {
|
||||
return [[bounds.south, bounds.west], [bounds.north, bounds.east]]
|
||||
}
|
||||
}
|
||||
return places
|
||||
.filter(place => Number.isFinite(place.lat) && Number.isFinite(place.lng))
|
||||
.map(place => [place.lat, place.lng] as [number, number])
|
||||
}, [places, plannerViewportSnapshot])
|
||||
|
||||
const focusPoints = useMemo<[number, number][]>(() => {
|
||||
if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? []
|
||||
if (tourPlanner.mode.type === 'new-draft') {
|
||||
return tourPlanner.waypoints.map(point => [point.lat, point.lng])
|
||||
}
|
||||
if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) {
|
||||
return tourPlanner.waypoints.map(point => [point.lat, point.lng])
|
||||
}
|
||||
return plannerFallbackFocusPoints
|
||||
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
|
||||
|
||||
const route = useMemo<[number, number][][] | null>(() => {
|
||||
if (tourPlanner.mode.type === 'view-gpx') {
|
||||
const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates
|
||||
return coordinates?.length ? [coordinates] : null
|
||||
}
|
||||
if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') {
|
||||
return tourPlanner.route ? [tourPlanner.route] : null
|
||||
}
|
||||
return null
|
||||
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route])
|
||||
|
||||
const { closeGpxTour } = tourPlanner
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'tour-planner') closeGpxTour()
|
||||
}, [activeTab, closeGpxTour])
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const poiViewportChangeRef = useRef(poi.onViewportChange)
|
||||
poiViewportChangeRef.current = poi.onViewportChange
|
||||
const captureViewport = useCallback((bounds: Bbox) => {
|
||||
lastPlanMapViewportRef.current = bounds
|
||||
poiViewportChangeRef.current(bounds)
|
||||
}, [])
|
||||
|
||||
const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => {
|
||||
tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null
|
||||
setSelectedPlaceId(tour?.place_id ?? null)
|
||||
}
|
||||
|
||||
return {
|
||||
planner,
|
||||
poi,
|
||||
permissions: { canPlaceEdit, canDayEdit },
|
||||
tourPlanner,
|
||||
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
|
||||
tourMap: { route, focusPoints, focusKey: tourPlanner.mapFocusKey, captureViewport },
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export const placeRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
||||
async delete(tripId: number | string, id: number | string): Promise<{ success?: boolean; tourPlaceIds?: number[] }> {
|
||||
if (isEffectivelyOffline()) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
@@ -102,7 +102,7 @@ export const placeRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
|
||||
async deleteMany(tripId: number | string, ids: number[]): Promise<{ deleted?: number[]; count?: number; tourPlaceIds?: number[] }> {
|
||||
if (isEffectivelyOffline()) {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
|
||||
@@ -133,6 +133,30 @@ describe('placesSlice', () => {
|
||||
});
|
||||
|
||||
describe('deletePlace', () => {
|
||||
it('removes a deleted Tour and all local assignments after server success', async () => {
|
||||
const tour = buildPlace({ id: 10, trip_id: 1, tour_place_id: 10, route_geometry: '[[1,2],[3,4]]' })
|
||||
const ordinary = buildPlace({ id: 20, trip_id: 1 })
|
||||
const tourDayOne = buildAssignment({ id: 100, day_id: 1, place: tour })
|
||||
const ordinaryDayOne = buildAssignment({ id: 101, day_id: 1, place: ordinary })
|
||||
const tourDayTwo = buildAssignment({ id: 200, day_id: 2, place: tour })
|
||||
seedStore(useTripStore, {
|
||||
places: [tour, ordinary],
|
||||
assignments: { '1': [tourDayOne, ordinaryDayOne], '2': [tourDayTwo] },
|
||||
})
|
||||
server.use(
|
||||
http.delete('/api/trips/1/places/10', () =>
|
||||
HttpResponse.json({ success: true, tourPlaceIds: [10] }),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await useTripStore.getState().deletePlace(1, tour.id)
|
||||
|
||||
expect(result).toMatchObject({ success: true, tourPlaceIds: [tour.id] })
|
||||
expect(useTripStore.getState().places).toEqual([ordinary])
|
||||
expect(useTripStore.getState().assignments['1']).toEqual([ordinaryDayOne])
|
||||
expect(useTripStore.getState().assignments['2']).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-TSLICE-PLACE-007: rethrows the server message and keeps the pool intact', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { places: [place] });
|
||||
|
||||
@@ -15,8 +15,8 @@ export interface PlacesSlice {
|
||||
uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise<Place>
|
||||
setPlaceImageFromFile: (tripId: number | string, placeId: number, fileId: number) => Promise<Place>
|
||||
ratePlace: (tripId: number | string, placeId: number, rating: number | null) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<{ success?: boolean; tourPlaceIds?: number[] }>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<{ deleted?: number[]; count?: number; tourPlaceIds?: number[] }>
|
||||
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placeRepo.delete(tripId, placeId)
|
||||
const result = await placeRepo.delete(tripId, placeId)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
@@ -150,15 +150,16 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
return result
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePlacesMany: async (tripId, placeIds) => {
|
||||
if (placeIds.length === 0) return
|
||||
if (placeIds.length === 0) return { deleted: [], count: 0, tourPlaceIds: [] }
|
||||
try {
|
||||
await placeRepo.deleteMany(tripId, placeIds)
|
||||
const result = await placeRepo.deleteMany(tripId, placeIds)
|
||||
const idSet = new Set(placeIds)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
@@ -174,6 +175,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
return result
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isVectorStyle, resolveBasemap, resolveTileUrl } from './tileUrl'
|
||||
import { OFM_POSITRON, attributionForTile, OFM_ATTRIBUTION } from '../constants/mapDefaults'
|
||||
import {
|
||||
OFM_POSITRON,
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
attributionForTile,
|
||||
OFM_ATTRIBUTION,
|
||||
} from '../constants/mapDefaults'
|
||||
|
||||
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const CUSTOM = 'https://tiles.example.test/{z}/{x}/{y}.png'
|
||||
@@ -68,4 +75,12 @@ describe('attributionForTile', () => {
|
||||
expect(attributionForTile(CUSTOM)).toMatch(/OpenStreetMap/)
|
||||
expect(attributionForTile(null)).toMatch(/OpenStreetMap/)
|
||||
})
|
||||
|
||||
it('FE-UTIL-BASEMAP-010: OpenTopoMap carries its required data and style credits', () => {
|
||||
expect(OPENTOPOMAP_TILE_URL).toBe('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png')
|
||||
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenStreetMap')
|
||||
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('SRTM')
|
||||
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenTopoMap')
|
||||
expect(OPENTOPOMAP_TILE_MAXZOOM).toBe(17)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { analyzeRouteGeometry, focusRouteProfileAtDistance, parseRenderableRouteGeometry } from './routeGeometry'
|
||||
|
||||
describe('analyzeRouteGeometry', () => {
|
||||
it('shares a validated rendering parser that retains valid elevation values', () => {
|
||||
expect(parseRenderableRouteGeometry('[[48,11,512.125],[48.01,11.02,520.75]]'))
|
||||
.toEqual([[48, 11, 512.125], [48.01, 11.02, 520.75]])
|
||||
})
|
||||
|
||||
it('derives distance, elevation extrema, gain, loss and cumulative-distance samples together', () => {
|
||||
const analysis = analyzeRouteGeometry(JSON.stringify([
|
||||
[0, 0, 100],
|
||||
[0, 0.01, 130],
|
||||
[0, 0.03, 110],
|
||||
]))
|
||||
|
||||
expect(analysis).not.toBeNull()
|
||||
expect(analysis!.routeCoordinates).toEqual([[0, 0], [0, 0.01], [0, 0.03]])
|
||||
expect(analysis!.distanceKm).toBeCloseTo(3.3358, 3)
|
||||
expect(analysis!.minEle).toBe(100)
|
||||
expect(analysis!.maxEle).toBe(130)
|
||||
expect(analysis!.gain).toBe(30)
|
||||
expect(analysis!.loss).toBe(20)
|
||||
expect(analysis!.distanceIndexedProfileSamples).toHaveLength(3)
|
||||
expect(analysis!.distanceIndexedProfileSamples[1]).toMatchObject({ lat: 0, lng: 0.01 })
|
||||
expect(analysis!.distanceIndexedProfileSamples[1].distanceMeters).toBeCloseTo(1111.95, 1)
|
||||
expect(analysis!.distanceIndexedProfileSamples[2].distanceMeters).toBeCloseTo(3335.85, 1)
|
||||
})
|
||||
|
||||
it('maps route endpoints and interpolates focus between prepared samples', () => {
|
||||
const samples = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20 },
|
||||
{ distanceMeters: 100, elevationMeters: 200, lat: 12, lng: 24 },
|
||||
{ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28 },
|
||||
]
|
||||
expect(focusRouteProfileAtDistance(samples, -20)).toMatchObject({ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20, sampleIndex: 0 })
|
||||
expect(focusRouteProfileAtDistance(samples, 150)).toMatchObject({ distanceMeters: 150, elevationMeters: 225, lat: 12.5, lng: 25, sampleIndex: 1 })
|
||||
expect(focusRouteProfileAtDistance(samples, 500)).toMatchObject({ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28, sampleIndex: 2 })
|
||||
})
|
||||
|
||||
it('does not create geographic focus for samples without coordinates', () => {
|
||||
expect(focusRouteProfileAtDistance([
|
||||
{ distanceMeters: 0, elevationMeters: 100 },
|
||||
{ distanceMeters: 10, elevationMeters: 110 },
|
||||
], 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps distance but omits all elevation outputs when any point lacks elevation', () => {
|
||||
const analysis = analyzeRouteGeometry(JSON.stringify([
|
||||
[48, 11, 500],
|
||||
[48.01, 11.01],
|
||||
]))
|
||||
|
||||
expect(analysis?.distanceKm).toBeGreaterThan(0)
|
||||
expect(analysis?.minEle).toBeNull()
|
||||
expect(analysis?.maxEle).toBeNull()
|
||||
expect(analysis?.gain).toBeNull()
|
||||
expect(analysis?.loss).toBeNull()
|
||||
expect(analysis?.distanceIndexedProfileSamples).toEqual([])
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'not json', '[]', '[[0,0]]', '[[0,"bad"],[1,1]]'])('returns null for unusable geometry %s', geometry => {
|
||||
expect(analyzeRouteGeometry(geometry)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
export interface DistanceIndexedProfileSample {
|
||||
distanceMeters: number
|
||||
elevationMeters: number
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
export interface RouteProfileFocus {
|
||||
distanceMeters: number
|
||||
elevationMeters: number | null
|
||||
lat: number
|
||||
lng: number
|
||||
sampleIndex: number
|
||||
}
|
||||
|
||||
export interface RouteGeometryAnalysis {
|
||||
routeCoordinates: [number, number][]
|
||||
distanceKm: number
|
||||
minEle: number | null
|
||||
maxEle: number | null
|
||||
gain: number | null
|
||||
loss: number | null
|
||||
distanceIndexedProfileSamples: DistanceIndexedProfileSample[]
|
||||
}
|
||||
|
||||
export type RouteGeometryPoint = [number, number] | [number, number, number]
|
||||
|
||||
function isRoutePoint(value: unknown): value is RouteGeometryPoint {
|
||||
return Array.isArray(value) && (value.length === 2 || value.length === 3) &&
|
||||
Number.isFinite(value[0]) && Number.isFinite(value[1]) &&
|
||||
Math.abs(value[0]) <= 90 && Math.abs(value[1]) <= 180 &&
|
||||
(value.length === 2 || Number.isFinite(value[2]))
|
||||
}
|
||||
|
||||
/** Parses and filters route coordinates for analysis and map rendering. */
|
||||
export function parseRenderableRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryPoint[] | null {
|
||||
if (!routeGeometry) return null
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(routeGeometry)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null
|
||||
const points = parsed.filter(isRoutePoint)
|
||||
return points.length >= 2 ? points : null
|
||||
}
|
||||
|
||||
function haversineMeters(a: RouteGeometryPoint, b: RouteGeometryPoint): number {
|
||||
const earthRadiusMeters = 6_371_000
|
||||
const toRadians = (degrees: number) => degrees * Math.PI / 180
|
||||
const [lat1, lng1] = a
|
||||
const [lat2, lng2] = b
|
||||
const deltaLat = toRadians(lat2 - lat1)
|
||||
const deltaLng = toRadians(lng2 - lng1)
|
||||
const chord = Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) ** 2
|
||||
const boundedChord = Math.min(1, Math.max(0, chord))
|
||||
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(boundedChord), Math.sqrt(1 - boundedChord))
|
||||
}
|
||||
|
||||
/** Interpolates a focus point from prepared samples in O(log n). */
|
||||
export function focusRouteProfileAtDistance(
|
||||
samples: readonly DistanceIndexedProfileSample[],
|
||||
requestedDistanceMeters: number,
|
||||
): RouteProfileFocus | null {
|
||||
if (samples.length < 2 || !Number.isFinite(requestedDistanceMeters)) return null
|
||||
const first = samples[0]
|
||||
const last = samples[samples.length - 1]
|
||||
if (![first.lat, first.lng, last.lat, last.lng].every(Number.isFinite)) return null
|
||||
const distanceMeters = Math.max(first.distanceMeters, Math.min(last.distanceMeters, requestedDistanceMeters))
|
||||
|
||||
let low = 0
|
||||
let high = samples.length - 1
|
||||
while (low + 1 < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (samples[middle].distanceMeters < distanceMeters) low = middle
|
||||
else high = middle
|
||||
}
|
||||
if (distanceMeters <= first.distanceMeters) low = high = 0
|
||||
else if (distanceMeters >= last.distanceMeters) low = high = samples.length - 1
|
||||
|
||||
const start = samples[low]
|
||||
const end = samples[high]
|
||||
if (![start.lat, start.lng, end.lat, end.lng].every(Number.isFinite)) return null
|
||||
const span = end.distanceMeters - start.distanceMeters
|
||||
const ratio = low === high || span <= 0 ? 0 : (distanceMeters - start.distanceMeters) / span
|
||||
const interpolate = (a: number | undefined, b: number | undefined): number | null =>
|
||||
Number.isFinite(a) && Number.isFinite(b) ? a! + (b! - a!) * ratio : null
|
||||
|
||||
return {
|
||||
distanceMeters,
|
||||
elevationMeters: interpolate(start.elevationMeters, end.elevationMeters),
|
||||
lat: interpolate(start.lat, end.lat)!,
|
||||
lng: interpolate(start.lng, end.lng)!,
|
||||
sampleIndex: ratio < 0.5 ? low : high,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives all route metrics and chart samples from the current place geometry.
|
||||
* Persisted tour metrics are only a list/display cache; detail views use this
|
||||
* live analysis so their values and profile can never drift apart.
|
||||
*/
|
||||
export function analyzeRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryAnalysis | null {
|
||||
const points = parseRenderableRouteGeometry(routeGeometry)
|
||||
if (!points) return null
|
||||
const hasElevation = points.every(point => point.length >= 3 && Number.isFinite(point[2]))
|
||||
let distanceMeters = 0
|
||||
let minEle = hasElevation ? points[0][2]! : null
|
||||
let maxEle = minEle
|
||||
let gain = hasElevation ? 0 : null
|
||||
let loss = hasElevation ? 0 : null
|
||||
const distanceIndexedProfileSamples: DistanceIndexedProfileSample[] = hasElevation
|
||||
? [{ distanceMeters: 0, elevationMeters: points[0][2]!, lat: points[0][0], lng: points[0][1] }]
|
||||
: []
|
||||
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
distanceMeters += haversineMeters(points[index - 1], points[index])
|
||||
if (!hasElevation) continue
|
||||
|
||||
const elevation = points[index][2]!
|
||||
minEle = Math.min(minEle!, elevation)
|
||||
maxEle = Math.max(maxEle!, elevation)
|
||||
const delta = elevation - points[index - 1][2]!
|
||||
if (delta > 0) gain! += delta
|
||||
else loss! += Math.abs(delta)
|
||||
distanceIndexedProfileSamples.push({ distanceMeters, elevationMeters: elevation, lat: points[index][0], lng: points[index][1] })
|
||||
}
|
||||
|
||||
return {
|
||||
routeCoordinates: points.map(([lat, lng]) => [lat, lng]),
|
||||
distanceKm: distanceMeters / 1000,
|
||||
minEle,
|
||||
maxEle,
|
||||
gain,
|
||||
loss,
|
||||
distanceIndexedProfileSamples,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user