diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2f42296ef9..bff0db6b0c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 => + (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 => + apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data), + update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise => + apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data), + importGpx: (tripId: number | string, file: File): Promise => { + 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), diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index ba7233fe60..6bf2794934 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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', diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index c4579c3d93..6414d1b77d 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '../../../tests/helpers/render' -import { MapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher' +import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher' describe('MapLayerSwitcher', () => { it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => { @@ -42,7 +42,23 @@ describe('MapLayerSwitcher', () => { expect(button.style.background).toBe('transparent') }) - it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => { + it('FE-COMP-MAPLAYER-006: offers all three Tours-only base layers', () => { + render( {}} />) + + 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() + + 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( {}} />) const button = screen.getByRole('button') const shell = button.parentElement as HTMLElement diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 3dd3fb8aa6..45455be015 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,9 +1,18 @@ -import { Map as MapIcon, Satellite } from 'lucide-react' +import { Map as MapIcon, Mountain, Satellite } from 'lucide-react' import { useTranslation } from '../../i18n' import { Tooltip } from '../shared/Tooltip' import { MAP_CONTROL_SHADOW } from './mapControlShadow' export type BaseLayer = 'default' | 'satellite' +export type TourBaseLayer = BaseLayer | 'topo' + +const FROSTED_SHELL = { + display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const, + background: 'var(--sidebar-bg)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + boxShadow: MAP_CONTROL_SHADOW, +} // Where the switcher sits and how much room it takes, so a control placed beside it // (the phone's compass) is positioned off the same numbers instead of guessing them. @@ -28,13 +37,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo const label = isSatellite ? t('map.baseLayer.switchToDefault') : t('map.baseLayer.switchToSatellite') return ( -
+
{/* Same tooltip as its neighbours on the map, for the same reasons — see TripRouteOverviewPill. To the right, though: this one sits at the bottom left of the map, where a tooltip to the left would land on the @@ -61,3 +64,47 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
) } + +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 ( +
+ {TOUR_LAYERS.map(({ id, labelKey, Icon }) => { + const selected = active === id + const label = labelKey ? t(labelKey) : 'Topo' + return ( + + ) + })} +
+ ) +} diff --git a/client/src/components/Map/MapView.leaflet.test.tsx b/client/src/components/Map/MapView.leaflet.test.tsx index f76fe298d4..b5525a6d39 100644 --- a/client/src/components/Map/MapView.leaflet.test.tsx +++ b/client/src/components/Map/MapView.leaflet.test.tsx @@ -16,6 +16,8 @@ import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest import { render, waitFor, act } from '../../../tests/helpers/render' import { resetAllStores } from '../../../tests/helpers/store' import { buildPlace } from '../../../tests/helpers/factories' +import { AMAP_ROAD } from '../../constants/mapDefaults' +import { useSettingsStore } from '../../store/settingsStore' vi.mock('../../hooks/useGeolocation', () => ({ useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }), @@ -112,6 +114,58 @@ async function renderMap() { } describe('MapView on a real Leaflet map', () => { + it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } }) + const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null } + const onMapClick = vi.fn() + const route: [number, number][][] = [[[39.9042, 116.4074], [39.9052, 116.4084]]] + const waypoints = [{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' as const }] + const renderLayer = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ( + + ) + 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]) diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 4bdfb8e5b0..58f2d85880 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -5,8 +5,9 @@ import { act, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { resetAllStores } from '../../../tests/helpers/store' import { buildPlace, buildReservation } from '../../../tests/helpers/factories' -import { MAP_MAX_ZOOM } from '../../constants/mapDefaults' +import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults' import { useAuthStore } from '../../store/authStore' +import { useSettingsStore } from '../../store/settingsStore' import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import * as photoService from '../../services/photoService' import { Coffee, MapPin, Mountain, Signpost, type LucideIcon } from 'lucide-react' @@ -14,6 +15,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup' const mapMock = vi.hoisted(() => ({ getContainer: vi.fn(() => document.createElement('div')), + getCenter: vi.fn(() => ({ lat: 0, lng: 0 })), panTo: vi.fn(), setView: vi.fn(), fitBounds: vi.fn(), @@ -92,10 +94,10 @@ const clusterMock = vi.hoisted(() => { vi.mock('react-leaflet', () => ({ // center/zoom are surfaced so tests can assert the camera the map is built // with; maxZoom because a cluster refuses to attach to a map without one. - MapContainer: ({ children, center, zoom, maxZoom }: any) => ( -
{children}
+ MapContainer: ({ children, center, zoom, maxZoom, crs }: any) => ( +
{children}
), - TileLayer: () =>
, + TileLayer: ({ url }: any) =>
, Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
{ @@ -148,7 +150,14 @@ vi.mock('react-leaflet', () => ({ onClick={() => eventHandlers?.click?.()} /> ), - CircleMarker: () =>
, + CircleMarker: ({ center, interactive, pathOptions }: any) => ( +
+ ), Circle: () =>
, Tooltip: ({ children }: any) => <>{children}, useMap: () => mapMock, @@ -195,6 +204,10 @@ vi.mock('../../services/photoService', () => ({ getAllThumbs: vi.fn(() => ({})), })) +vi.mock('./gcj02Crs', () => ({ + crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined, +})) + import { MapView } from './MapView' // Helper: build a place with the extra fields MapView uses (category_name/color/icon) @@ -231,6 +244,53 @@ describe('MapView', () => { expect(screen.getByTestId('map-container')).toBeTruthy() }) + it('uses the CRS of the Tours-visible layer and preserves the viewport across datum switches', () => { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } }) + const beijing = buildMapPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }) + const onViewBaseLayerChange = vi.fn() + const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][] + const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }] + const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 } + const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({ + places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15, + route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange, + }) + const { rerender } = render() + 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() + 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() + 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() + expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'TREK:GCJ02') + expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD) + expect(useSettingsStore.getState().settings.map_base_layer).toBe('default') + expect(onViewBaseLayerChange).not.toHaveBeenCalled() + }) + it('FE-COMP-MAPVIEW-002: renders one marker per place', () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), @@ -349,6 +409,135 @@ 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() + 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() + 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() + expect(screen.queryByTestId('profile-focus-marker')).toBeNull() + expect(mapMock.fitBounds).not.toHaveBeenCalled() + + rerender() + 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( + , + ) + expect(mapMock.fitBounds).not.toHaveBeenCalled() + + rerender() + expect(mapMock.fitBounds).toHaveBeenCalledOnce() + mapMock.fitBounds.mockClear() + + rerender( + , + ) + 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() + 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( + , + ) + 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() + mapMock.fitBounds.mockClear() + + rerender() + expect(mapMock.fitBounds).toHaveBeenCalled() + mapMock.fitBounds.mockClear() + + rerender() + 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() + 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()).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() + 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 diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index f931742b14..c933e79241 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -59,14 +59,15 @@ import type { Poi } from './poiCategories' import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import DawarichTrailLayer from './DawarichTrailLayer' -import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults' +import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults' import { crsForBasemap } from './gcj02Crs' import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl' import VectorBasemap from './VectorBasemap' import { useSettingsStore } from '../../store/settingsStore' -import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher' +import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport' +import { parseRenderableRouteGeometry } from '../../utils/routeGeometry' function categoryIconSvg(iconName: string | null | undefined, size: number): string { const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] @@ -356,6 +357,20 @@ function ViewportController({ onViewportChange }: { onViewportChange?: (b: { sou return null } +function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) { + const map = useMap() + useEffect(() => { + const save = () => { + const center = map.getCenter() + onChange([center.lat, center.lng], map.getZoom()) + } + map.whenReady(save) + map.on('moveend zoomend', save) + return () => { map.off('moveend zoomend', save) } + }, [map, onChange]) + return null +} + interface SelectionControllerProps { /** False while the traveller locked the map (#2010): a pick changes nothing on it. */ follow: boolean @@ -433,6 +448,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 /** @@ -455,11 +472,13 @@ function leafletPadding(box: ViewportPadding): L.FitBoundsOptions { } } -function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding, follow = true }: BoundsControllerProps) { +function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, focusKey, fitPadding, follow = true }: BoundsControllerProps) { const map = useMap() 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) => { if (coords.length === 0) return @@ -522,10 +541,16 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo // 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 } @@ -734,6 +759,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: `${index + 1}`, + }) +} + export const MapView = memo(function MapView({ places = [], dayPlaces = [], @@ -761,6 +796,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, @@ -799,7 +835,19 @@ export const MapView = memo(function MapView({ activeAlternative, onChooseAlternative, onHighlightAlternative, + plannerWaypoints = [], + selectedPlannerWaypointId = null, + onPlannerWaypointClick, + routeProfileFocus = null, + viewBaseLayer, + onViewBaseLayerChange, }: any) { + const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const updateSetting = useSettingsStore(s => s.updateSetting) + const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer + const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' + const isSatellite = baseLayer === 'satellite' + const isTopo = baseLayer === 'topo' // The caller hands over whatever the user configured; what kind of basemap // that is decides which layer draws it. A saved raster template still wins, // the default is a vector style. @@ -811,7 +859,7 @@ export const MapView = memo(function MapView({ * decided before the map is constructed, because Leaflet cannot change a map's * CRS afterwards — hence the whole map, not just the tile layer. */ - const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url) + const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url) const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap]) const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( (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 ) const [hoveredPlace, setHoveredPlace] = useState(null) @@ -1045,12 +1097,10 @@ export const MapView = memo(function MapView({ // of points), so it hangs off `places` alone — a selection change must not // re-parse every track. const gpxTracks = useMemo(() => places.flatMap(place => { - if (!place.route_geometry) return [] - try { - const coords = JSON.parse(place.route_geometry) as [number, number][] - if (!coords || coords.length < 2) return [] - return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }] - } catch { return [] } + const geometry = parseRenderableRouteGeometry(place.route_geometry) + if (!geometry) return [] + const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng]) + return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }] }), [places]) // Keeps the click handler out of the polyline memo below: `handleMarkerClick` @@ -1119,9 +1169,6 @@ export const MapView = memo(function MapView({ ? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 84px) + 12px)' - const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' - const updateSetting = useSettingsStore(s => s.updateSetting) - const isSatellite = baseLayer === 'satellite' const toggleBaseLayer = useCallback(() => { // Store flips state synchronously (instant, works offline); a failed save is logged there. updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) @@ -1149,8 +1196,8 @@ export const MapView = memo(function MapView({ // the cluster group away on every template or API-key edit. key={isGcjBasemap ? 'gcj02' : 'wgs84'} id="trek-map" - center={initialView.center} - zoom={initialView.zoom} + center={cameraSnapshotRef.current?.center ?? initialView.center} + zoom={cameraSnapshotRef.current?.zoom ?? initialView.zoom} zoomControl={false} // On the map itself, not left to the base layer. Leaflet reads its zoom // ceiling from the map options or, failing that, from a GridLayer that @@ -1184,6 +1231,18 @@ export const MapView = memo(function MapView({ updateWhenIdle={true} referrerPolicy="strict-origin-when-cross-origin" /> + ) : isTopo ? ( + ) : basemap.kind === 'vector' ? ( ) : ( @@ -1200,7 +1259,8 @@ export const MapView = memo(function MapView({ )} - 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} /> + {hasViewBaseLayer && } + 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} /> @@ -1311,10 +1371,37 @@ export const MapView = memo(function MapView({ ) : null} + {(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => ( + { + event.originalEvent.stopPropagation() + onPlannerWaypointClick?.(point.id) + }, + }} + > + {index + 1} + + ))} + {/* GPX imported route geometries */} {gpxPolylines} + {routeProfileFocus && ( + + )} + {onToggleFollow && } - + {hasViewBaseLayer + ? + : }
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 5acb542b32..d05411e1c6 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -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(), @@ -889,6 +890,116 @@ 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() + await act(async () => {}) + + const marker = glCanvasContainer.querySelector('[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() + await act(async () => {}) + const movedMarker = glCanvasContainer.querySelector('[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() + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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() + await act(async () => {}) + glMap.fitBounds.mockClear() + + rerender() + await act(async () => {}) + expect(glMap.fitBounds).toHaveBeenCalled() + glMap.fitBounds.mockClear() + + rerender() + await act(async () => {}) + expect(glMap.fitBounds).toHaveBeenCalled() + }) + // jsdom drives requestAnimationFrame off a timer, so the marker reconcile and // the batched photo updates only land after a short tick. const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) @@ -1782,6 +1893,33 @@ describe('MapViewGL', () => { expect(features[1].properties.color).toBeNull() }) + it('uses view-local Topo tiles and controls without writing settings or moving the camera', async () => { + glMap.on.mockImplementation((event, handlerOrLayer) => { + if (event === 'load' && typeof handlerOrLayer === 'function') handlerOrLayer() + return glMap + }) + const onChange = vi.fn() + const settings = useSettingsStore.getState().settings.map_base_layer + const { container, rerender } = render() + 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() + await act(async () => {}) + expect(glMap.fitBounds).not.toHaveBeenCalled() + expect(glMap.flyTo).not.toHaveBeenCalled() + expect(glMap.jumpTo).not.toHaveBeenCalled() + }) + it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => { const gpxSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null)) @@ -1800,6 +1938,47 @@ describe('MapViewGL', () => { expect(features[0].properties.place_id).toBe(74) }) + it.each([ + ['two-point', [[48, 2], [49, 3]]], + ['elevation', [[48, 2, 12.5], [49, 3, 22.75]]], + ['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])], + ['loop', [[48, 2], [48.1, 2.1], [48, 2]]], + ['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]], + ])('renders valid %s geometry with MapLibre coordinate order', async (_label, geometry) => { + const features = gpxFeatures() + render() + 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()).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() + 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({ diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index ebb398e585..85229418c6 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -9,7 +9,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n/TranslationContext' -import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher' +import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher' +import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults' import { MapLockPill } from './MapLockPill' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' @@ -41,6 +42,7 @@ import { ratingBadgeHtml } from './ratingBadge' import type { Poi } from './poiCategories' import { poiPinParts } from './poiMarker' import { resolveTrackColor, hasManualTrackColor } from './trackColors' +import { parseRenderableRouteGeometry, type RouteProfileFocus } from '../../utils/routeGeometry' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' @@ -199,6 +201,13 @@ interface Props { onChooseAlternative?: (index: number) => void /** Reports which option the pointer is over, so the list and the map agree. */ onHighlightAlternative?: (index: number | null) => void + /** Generic numbered control points for list-first route editors. */ + plannerWaypoints?: Array<{ id: string; lat: number; lng: number }> + selectedPlannerWaypointId?: string | null + onPlannerWaypointClick?: (id: string) => void + routeProfileFocus?: RouteProfileFocus | null + viewBaseLayer?: TourBaseLayer + onViewBaseLayerChange?: (layer: TourBaseLayer) => void /** * An explicit stretch of map to frame, independent of the day being shown. * @@ -207,6 +216,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. * @@ -330,23 +341,27 @@ function addPlaceClusterLayers(map: any): void { * yet. The catch covers that case, and `styledata` brings the pass back once it is in. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function applySatellite(map: any, on: boolean): void { +function applySatellite(map: any, on: boolean, topo = false): void { + const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID + const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID try { - if (map.getLayer(SATELLITE_LAYER_ID)) { - map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none') + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none') return } if (!on) return // Nothing to build while it is switched off. // The layer is what is missing, not necessarily the source: a pass that got the // source in and then failed on the layer would otherwise leave a source that stops // every later pass from ever building the layer. - if (!map.getSource(SATELLITE_SOURCE_ID)) { - map.addSource(SATELLITE_SOURCE_ID, { + if (!map.getSource(sourceId)) { + map.addSource(sourceId, { type: 'raster', - tiles: [SATELLITE_TILE_URL], + tiles: topo + ? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain)) + : [SATELLITE_TILE_URL], tileSize: 256, - maxzoom: SATELLITE_TILE_MAXZOOM, - attribution: SATELLITE_TILE_ATTRIBUTION, + maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM, + attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION, }) } // Under the first thing TREK draws, over everything the basemap style draws. @@ -355,9 +370,9 @@ function applySatellite(map: any, on: boolean): void { const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix))) map.addLayer({ - id: SATELLITE_LAYER_ID, + id: layerId, type: 'raster', - source: SATELLITE_SOURCE_ID, + source: sourceId, paint: { 'raster-opacity': 1 }, }, firstOwn?.id) } catch { /* a style that refuses the layer keeps the plain basemap */ } @@ -700,6 +715,7 @@ export function MapViewGL({ zoom = DEFAULT_MAP_ZOOM, fitKey = 0, focusPoints, + focusKey, fitPadding, clusterLoosely = false, hazards, @@ -727,6 +743,12 @@ export function MapViewGL({ activeAlternative, onChooseAlternative, onHighlightAlternative, + plannerWaypoints = [], + selectedPlannerWaypointId = null, + onPlannerWaypointClick, + routeProfileFocus = null, + viewBaseLayer, + onViewBaseLayerChange, roadtripVias, onMoveVia, onRemoveVia, @@ -740,7 +762,9 @@ export function MapViewGL({ const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') // The same stored choice the Leaflet map reads, so the two renderers agree. - const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const baseLayer = viewBaseLayer ?? globalBaseLayer + const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' const updateSetting = useSettingsStore(s => s.updateSetting) const isSatellite = baseLayer === 'satellite' const toggleBaseLayer = useCallback(() => { @@ -809,6 +833,8 @@ export function MapViewGL({ const routeViaMarkersRef = useRef([]) /** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */ const viaPinsRef = useRef([]) + const plannerWaypointPinsRef = useRef([]) + const profileFocusPinRef = useRef(null) /** The drive-time pills on the offered routes; same treatment. */ const altLabelsRef = useRef([]) // Every hand-positioned pin, whichever set it belongs to. They all have to be written @@ -819,6 +845,8 @@ export function MapViewGL({ pluginMarkersRef.current.forEach(pin => pin.reposition()) routeViaMarkersRef.current.forEach(pin => pin.reposition()) viaPinsRef.current.forEach(pin => pin.reposition()) + plannerWaypointPinsRef.current.forEach(pin => pin.reposition()) + profileFocusPinRef.current?.reposition() altLabelsRef.current.forEach(pin => pin.reposition()) }, []) // Single reusable hover popup for POI markers. Planned places use the @@ -837,6 +865,61 @@ export function MapViewGL({ * drag is therefore hand-rolled — pointer events on the element, unproject on move. */ const viaCleanupRef = useRef<(() => void)[]>([]) + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + plannerWaypointPinsRef.current.forEach(pin => pin.remove()) + plannerWaypointPinsRef.current = [] + + for (const [index, point] of plannerWaypoints.entries()) { + const selected = point.id === selectedPlannerWaypointId + const size = selected ? 30 : 26 + const el = document.createElement('button') + el.type = 'button' + el.textContent = String(index + 1) + el.setAttribute('aria-label', `Waypoint ${index + 1}`) + el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0` + const swallow = (event: Event) => event.stopPropagation() + const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) } + el.addEventListener('pointerdown', swallow) + el.addEventListener('touchstart', swallow, { passive: true }) + el.addEventListener('click', select) + plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)) + } + + return () => { + plannerWaypointPinsRef.current.forEach(pin => pin.remove()) + plannerWaypointPinsRef.current = [] + } + }, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId]) + + useEffect(() => () => { + profileFocusPinRef.current?.remove() + profileFocusPinRef.current = null + }, [gl, mapReady]) + + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + if (!routeProfileFocus) { + profileFocusPinRef.current?.remove() + profileFocusPinRef.current = null + return + } + let pin = profileFocusPinRef.current + if (!pin) { + const element = document.createElement('div') + element.setAttribute('aria-hidden', 'true') + element.dataset.tourProfileFocus = 'true' + element.style.cssText = 'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;' + pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat) + profileFocusPinRef.current = pin + } else { + pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat]) + } + pin.el.style.pointerEvents = 'none' + }, [gl, mapReady, routeProfileFocus]) + /** * The list only changes when a via does, and the callbacks are read through a ref. * @@ -2188,20 +2271,17 @@ export function MapViewGL({ const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined if (!src) return const features = places.flatMap(place => { - if (!place.route_geometry) return [] - try { - const coords = JSON.parse(place.route_geometry) as [number, number][] - if (!coords || coords.length < 2) return [] - return [{ - type: 'Feature' as const, - properties: { - color: resolveTrackColor(place), - cased: hasManualTrackColor(place), - place_id: place.id, - }, - geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) }, - }] - } catch { return [] } + const coords = parseRenderableRouteGeometry(place.route_geometry) + if (!coords) return [] + return [{ + type: 'Feature' as const, + properties: { + color: resolveTrackColor(place), + cased: hasManualTrackColor(place), + place_id: place.id, + }, + geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) }, + }] }) src.setData({ type: 'FeatureCollection', features }) }, [places, mapReady]) @@ -2311,12 +2391,20 @@ export function MapViewGL({ // The caller's padding as a value, so a parent that builds the object inline on every // render does not move the camera each time it renders. 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. @@ -2329,7 +2417,7 @@ export function MapViewGL({ duration: 400, }) } 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(() => { @@ -2405,11 +2493,14 @@ export function MapViewGL({ useEffect(() => { const map = mapRef.current if (!map || !mapReady) return - const apply = () => applySatellite(map, isSatellite) + const apply = () => { + applySatellite(map, isSatellite) + applySatellite(map, baseLayer === 'topo', true) + } apply() map.on('styledata', apply) return () => { map.off('styledata', apply) } - }, [isSatellite, mapReady, glProvider]) + }, [isSatellite, baseLayer, mapReady, glProvider]) if (!isMapLibre && !mapboxToken) { return ( @@ -2455,7 +2546,9 @@ export function MapViewGL({ : 'calc(var(--bottom-nav-h, 0px) + 12px)', }}> {onToggleFollow && } - + {hasViewBaseLayer + ? + : }
{/* Hover tooltip — cursor-following name/category/address card, identical to the Leaflet map's overlay (no anchored popup, no photo). */} diff --git a/client/src/components/Map/dayRoutePlan.test.ts b/client/src/components/Map/dayRoutePlan.test.ts index 5c808e70d0..406a72adbd 100644 --- a/client/src/components/Map/dayRoutePlan.test.ts +++ b/client/src/components/Map/dayRoutePlan.test.ts @@ -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 = {}) => @@ -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] }) + }) +}) diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts index 0a17784d44..e88d887696 100644 --- a/client/src/components/Map/dayRoutePlan.ts +++ b/client/src/components/Map/dayRoutePlan.ts @@ -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, diff --git a/client/src/components/Map/dayTourProjection.ts b/client/src/components/Map/dayTourProjection.ts new file mode 100644 index 0000000000..4866fa3ab0 --- /dev/null +++ b/client/src/components/Map/dayTourProjection.ts @@ -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 + }) +} \ No newline at end of file diff --git a/client/src/components/Map/tripRouteGeometry.ts b/client/src/components/Map/tripRouteGeometry.ts index 8f14f676e1..dbdd191b74 100644 --- a/client/src/components/Map/tripRouteGeometry.ts +++ b/client/src/components/Map/tripRouteGeometry.ts @@ -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), diff --git a/client/src/components/Map/useTripRouteOverview.test.ts b/client/src/components/Map/useTripRouteOverview.test.ts index 4561eb118f..52ec287398 100644 --- a/client/src/components/Map/useTripRouteOverview.test.ts +++ b/client/src/components/Map/useTripRouteOverview.test.ts @@ -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) diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts index d35e524211..25ec5d67e9 100644 --- a/client/src/components/Map/useTripRouteOverview.ts +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -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(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], ) diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index 49b1c7f5b6..7e0198a9ec 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -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 | null = null + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + body = await request.json() as Record + 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, diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index 5c43693270..a8deff8651 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -191,6 +191,7 @@ export async function valhallaRun( profile: 'driving' | 'walking' | 'cycling', avoid: readonly AvoidClass[], signal?: AbortSignal, + costingOptions: Readonly> = {}, ): Promise { 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 { 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 = {} + const options: Record = { ...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> = {}, ): Promise | 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) } diff --git a/client/src/components/PDF/TripPDF.map.test.ts b/client/src/components/PDF/TripPDF.map.test.ts index 99494353bf..17e8f144de 100644 --- a/client/src/components/PDF/TripPDF.map.test.ts +++ b/client/src/components/PDF/TripPDF.map.test.ts @@ -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() @@ -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() diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 3a4bda72ff..bfee187233 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -12,6 +12,7 @@ import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, import { useSettingsStore } from '../../store/settingsStore' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' +import { projectDayItinerary } from '../Map/dayTourProjection' import { planCosts } from '../Planner/planCosts' import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry' import { buildTripMapSvg } from './tripMapSvg' @@ -271,6 +272,9 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor const unit: DistanceUnit = (_distanceUnit || useSettingsStore.getState().settings.distance_unit) === 'imperial' ? 'imperial' : 'metric' let tripRoute: TripRouteSummary | null = null + const toursEnabled = useAddonStore.getState().isEnabled('tours') + const hasInvalidTour = toursEnabled && Object.values(assignments).some(list => + projectDayItinerary(list || [], true, places).some(item => item.kind === 'tour' && !item.valid)) try { tripRoute = await routeTrip( { @@ -279,6 +283,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor reservations, accommodations: accommodationList, optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation, + toursEnabled, + places, }, { profile: 'driving', tripId: trip.id, timeoutMs: 8000 }, ) @@ -932,6 +938,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
+${hasInvalidTour ? `

${escHtml(tr('tours.dayRoute.endpointUnknown'))}

` : ''} ${tripMapHtml} diff --git a/client/src/components/PDF/tripMapImage.ts b/client/src/components/PDF/tripMapImage.ts index 194b28ec3e..fd09604258 100644 --- a/client/src/components/PDF/tripMapImage.ts +++ b/client/src/components/PDF/tripMapImage.ts @@ -39,8 +39,8 @@ export async function renderTripMapImage( opts: TripMapImageOptions, ): Promise { 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 diff --git a/client/src/components/PDF/tripMapSvg.ts b/client/src/components/PDF/tripMapSvg.ts index 5c36406516..f8abb4f585 100644 --- a/client/src/components/PDF/tripMapSvg.ts +++ b/client/src/components/PDF/tripMapSvg.ts @@ -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 `` + + `` + })) + 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))}` : '' - 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 diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index a7af472179..5dc9f7d540 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -194,6 +194,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 @@ -1374,6 +1375,8 @@ describe('DayPlanSidebar', () => { render() await waitFor(() => expect(onSelectDay).toHaveBeenCalledWith(days[1].id, true)) + await act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve())) }) + expect(Element.prototype.scrollTo).toHaveBeenCalled() }) it('FE-PLANNER-DAYPLAN-196: a trip that is not running is left alone', async () => { @@ -1490,6 +1493,55 @@ 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( + + ) + + 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() + + 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 () => { @@ -2857,6 +2909,76 @@ describe('DayPlanSidebar', () => { expect(pairs).toContainEqual([{ lat: 43.66, lng: 7.21 }, { lat: 43.70, lng: 7.26 }]) }) + it.each([ + { enabled: true, excluded: false, pairs: [[48.85, 48.86], [48.88, 48.89]] }, + { enabled: true, excluded: true, pairs: [[48.85, 48.89]] }, + { enabled: false, excluded: false, pairs: [[48.85, 48.86], [48.86, 48.89]] }, + ])('B5 sidebar Tour connector parity enabled=$enabled excluded=$excluded', async ({ enabled, excluded, pairs }) => { + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled }] }) + const day = buildDay({ id: 10, date: '2025-06-01' }) + const places = [ + buildPlace({ id: 1, name: 'Before Tour', lat: 48.85, lng: 2.35 }), + buildPlace({ id: 2, name: 'Assigned Tour', lat: 48.86, lng: 2.35, route_geometry: '[[48.86,2.35],[48.88,2.35]]' }), + buildPlace({ id: 3, name: 'After Tour', lat: 48.89, lng: 2.35 }), + ] + const assignments = { '10': places.map((place, order) => buildAssignment({ + id: order + 1, day_id: 10, order_index: order, place, + ...(order === 1 ? { tour_place_id: 2, tour_route_geometry: places[1].route_geometry, route_excluded: excluded } : {}), + })) } + render() + 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() + + 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() + + 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')) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index a9e5eea75e..74ed8b8f84 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -60,6 +60,7 @@ import { findTodayDayId } from './today' import { markdownLinkComponents } from '../shared/markdownLink' import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector' import { resolveLegMode } from './legMode' +import { projectDayItinerary } from '../Map/dayTourProjection' import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' @@ -77,6 +78,7 @@ interface DayPlanSidebarProps { trip: Trip days: Day[] places: Place[] + tourPlaceIds?: ReadonlySet categories: Category[] assignments: AssignmentsMap selectedDayId: number | null @@ -130,7 +132,7 @@ interface DayPlanSidebarProps { onCreatePlaceForDay?: (dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void /** `dayIds`: the days the step acts on, so deleting one of them drops it. */ - pushUndo?: (label: string, undoFn: () => Promise | void, dayIds?: number[]) => void + pushUndo?: (label: string, undoFn: () => Promise | void, dayIds?: number[], placeIds?: number[]) => void canUndo?: boolean lastActionLabel?: string | null onUndo?: () => void @@ -233,6 +235,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) + const toursEnabled = useAddonStore(s => s.isEnabled('tours')) const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -643,6 +646,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // legs to draw. Side-effect free, so the async loop below only does OSRM I/O. const planDay = (dayId: number) => { const merged = mergedItemsMap[dayId] || [] + const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item])) // Each run point carries the ORIGIN assignment's per-segment travel mode // (#1281): the leg from this point to the next is routed with `mode` (null = // inherit the day default). Transport endpoints carry no mode → day default. @@ -654,8 +658,24 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // flight, not a drive — and surface it as a bogus connector distance (#1394). let curHasPlace = false for (const it of merged) { - // A stop out of the route (#2532) is passed by, like a note. if (it.type === 'place' && it.data.route_excluded) continue + const tour = it.type === 'place' ? projected.get(it.data.id) : undefined + if (tour?.kind === 'tour') { + if (tour.start) { + const prev = cur[cur.length - 1] + if (prev && !prev.isPlace && !withinDriveRange(prev, tour.start)) { + if (cur.length >= 2 && curHasPlace) runs.push(cur) + cur = [] + curHasPlace = false + } + cur.push({ id: it.data.id, ...tour.start, isPlace: true, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) + curHasPlace = true + } + if (cur.length >= 2 && curHasPlace) runs.push(cur) + cur = tour.end ? [{ id: it.data.id, ...tour.end, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null }] : [] + curHasPlace = !!tour.end + continue + } if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { // Mirror of the guard below: the open run may hold nothing but a far-away // arrival endpoint, which is no more drivable in this direction (#2133). @@ -714,7 +734,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // is one you flew out of or landed at rather than drove between (#2133). const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = [] for (const it of merged) { - if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng && !it.data.route_excluded) { + if (it.type === 'place' && it.data.route_excluded) continue + const tour = it.type === 'place' ? projected.get(it.data.id) : undefined + if (tour?.kind === 'tour') { + if (tour.start) wayPts.push({ ...tour.start, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) + if (tour.end) wayPts.push({ ...tour.end, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, leg_transport_mode: it.data.leg_transport_mode ?? null }) + continue + } + if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, dayId) @@ -725,14 +752,31 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] + const hasRouteWaypoint = (item: typeof merged[number]) => { + if (item.type === 'place') { + if (item.data.route_excluded) return false + const tour = projected.get(item.data.id) + if (tour?.kind === 'tour') return !!tour.start || !!tour.end + return !!item.data.place?.lat && !!item.data.place?.lng + } + if (item.type === 'transport') { + const { from, to } = getTransportRouteEndpoints(item.data, dayId) + return !!from || !!to + } + return false + } const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) => !h || !w || w.isPlace || h.place_lat == null || h.place_lng == null || withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w) // Same carrier evidence the map route uses (#2157): with a located carrier // endpoint on the day, the no-time default must not open a hotel leg. const dayHasCarrier = wayPts.some(w => w.carrierEdge != null) - const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay) - const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay) + const firstItem = merged.find(hasRouteWaypoint) + const firstTour = firstItem?.type === 'place' ? projected.get(firstItem.data.id) : undefined + const wantTop = !(firstTour?.kind === 'tour' && !firstTour.start) && !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay) + const lastItem = [...merged].reverse().find(hasRouteWaypoint) + const lastTour = lastItem?.type === 'place' ? projected.get(lastItem.data.id) : undefined + const wantBottom = !(lastTour?.kind === 'tour' && !lastTour.end) && !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay) return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } } @@ -822,7 +866,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // routeDayIds is memoized from the same inputs as routeDayKey below, so keying the // effect on the string is equivalent while staying stable across unrelated renders. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit]) + }, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places]) const openAddNote = (dayId, e) => { e?.stopPropagation() @@ -842,7 +886,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { // Capture previous place order for undo - const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const previousAssignments = assignments[String(dayId)] || [] + const prevAssignmentIds = previousAssignments.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const prevPlaceIds = [...new Set(previousAssignments.map(a => a.place?.id).filter((id): id is number => id != null))] // …and, per booking, the fields this call is about to overwrite, so a failed write // can put the visible order back instead of leaving a phantom one behind the error // toast. Restoring the whole array instead would also drop what a collaborator's @@ -958,7 +1004,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const capturedPrevIds = prevAssignmentIds pushUndo?.(t('undo.reorder'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) - }, [capturedDayId]) + }, [capturedDayId], prevPlaceIds) } } catch (err: unknown) { rollBackReservations() @@ -1090,6 +1136,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (da.length < 3) return const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const placeIds = [...new Set((assignments[String(dayId)] || []).map(a => a.place?.id).filter((id): id is number => id != null))] // Separate fixed (stay at their index) and movable assignments. A place is // fixed if it's locked OR has a set time — timed places are anchored by their @@ -1133,7 +1180,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const capturedDayId = dayId pushUndo?.(t('undo.optimize'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) - }, [capturedDayId]) + }, [capturedDayId], placeIds) } @@ -1364,6 +1411,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) const dateFirst = useSettingsStore(s => s.settings.day_date_first === true) const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) + const toursEnabled = useAddonStore(s => s.isEnabled('tours')) // Plugin time contributions in the day plan (dayScheduleProvider hook). const daySchedule = usePluginDaySchedule(S.tripId) // Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a @@ -1625,6 +1673,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // Where nothing sits above it (the mobile shell), the height still fills the panel.
{/* Toolbar */} + {toursEnabled && selectedDayId != null && projectDayItinerary(assignments[String(selectedDayId)] || [], true, places).some(item => item.kind === 'tour' && !item.valid) && ( +
+ {t('tours.dayRoute.endpointUnknown')} +
+ )} window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') }, collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, { divider: true }, - canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, + canEditPlaces && + onDeletePlace && + !(toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)) && { + label: t('common.delete'), + icon: Trash2, + danger: true, + onClick: () => onDeletePlace(place.id), + }, ] return ( diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 194b786f72..658917f19e 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -689,20 +689,21 @@ 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(() => { 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(); - 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(); + 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(); + 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(); + } }); // ── File size display ────────────────────────────────────────────────────── diff --git a/client/src/components/Planner/PlacesSidebar.b4a.test.tsx b/client/src/components/Planner/PlacesSidebar.b4a.test.tsx new file mode 100644 index 0000000000..7c2b886a22 --- /dev/null +++ b/client/src/components/Planner/PlacesSidebar.b4a.test.tsx @@ -0,0 +1,166 @@ +import { act, fireEvent, render, screen } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildAssignment, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories' +import { useAuthStore } from '../../store/authStore' +import { useTripStore } from '../../store/tripStore' +import { usePermissionsStore } from '../../store/permissionsStore' +import { PlacesHeader } from './PlacesSidebarHeader' +import { usePlacesSidebar, type PlacesSidebarProps, type SidebarState } from './usePlacesSidebar' + +let sidebar: SidebarState + +function Host(props: PlacesSidebarProps) { + sidebar = usePlacesSidebar(props) + return ( +
+ + {sidebar.filtered.map(place => {place.name})} +
+ ) +} + +function makeProps(overrides: Partial = {}): 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() + 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() + 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() + 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() + expect(sidebar.filtered.map(place => place.id)).toEqual([2]) + rerender() + 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() + 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() + 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() + 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() + 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() + 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() + }) +}) \ No newline at end of file diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 70d18fd78d..926eb5be82 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -408,7 +408,7 @@ function LocalityFilterDropdown(S: SidebarState) { } function AddRow(S: SidebarState) { - const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders } = S + const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, toursEnabled } = S const dayOpen = selectedDayId != null const split = dayOpen && !!onAddPlaceToSelectedDay const { ref: rowRef, width: rowWidth } = useElementSize() @@ -418,7 +418,7 @@ function AddRow(S: SidebarState) { const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace') const importMenu = useContextMenu() const importItems = [ - { label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) }, + ...(!toursEnabled ? [{ label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) }] : []), { label: t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList'), icon: ListPlus, onClick: () => setListImportOpen(true) }, ] return ( diff --git a/client/src/components/Planner/usePlacesSidebar.test.tsx b/client/src/components/Planner/usePlacesSidebar.test.tsx index 07aaf764b8..68078f0a5a 100644 --- a/client/src/components/Planner/usePlacesSidebar.test.tsx +++ b/client/src/components/Planner/usePlacesSidebar.test.tsx @@ -188,6 +188,42 @@ 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(); + + 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(); + + 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. diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 6a6c9abf54..4ca56c0fdf 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -66,6 +66,13 @@ export interface PlacesSidebarProps { pushUndo?: (label: string, undoFn: () => Promise | 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 } /** @@ -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(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>(new Set()) const [pendingDeleteIds, setPendingDeleteIds] = useState(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 - }, [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. */ diff --git a/client/src/components/Tours/PlacesToursModeSwitch.tsx b/client/src/components/Tours/PlacesToursModeSwitch.tsx new file mode 100644 index 0000000000..0882fb41de --- /dev/null +++ b/client/src/components/Tours/PlacesToursModeSwitch.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { MapPin, Mountain } from 'lucide-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 ( +
+
+ {options.map(([value, label, Icon]) => { + const selected = active === value + return ( + + ) + })} +
+
+ ) +} diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx new file mode 100644 index 0000000000..b0bf855ef7 --- /dev/null +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react' +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { buildPlace } from '../../../tests/helpers/factories' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import TourDetailDialog from './TourDetailDialog' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const place = buildPlace({ + id: 42, + name: 'Ridge walk', + lat: 48, + lng: 11, + notes: 'Bring water', + route_geometry: JSON.stringify([ + [48, 11, 500], + [48, 11.01, 550], + [48, 11.03, 520], + ]), +}) + +const tour: TourListItem = { + place_id: 42, + name: 'Ridge walk', + tour_type: 'hike', + distance: 999, + elevation_gain: 999, + elevation_loss: 999, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +const defaultProps = { + canEdit: true, + canAssign: true, + tour, + place, + onClose: vi.fn(), + onUpdatePlace: vi.fn().mockResolvedValue(undefined), + onAssignToDay: vi.fn(), + onRemoveAssignment: vi.fn(), + onDelete: vi.fn(), +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) +}) + +describe('TourDetailDialog', () => { + it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => { + const opener = document.createElement('button') + opener.textContent = 'Open Tour' + document.body.appendChild(opener) + const onClose = vi.fn() + function DesktopDetail() { + const [open, setOpen] = useState(true) + return open ? { onClose(); setOpen(false) }} /> : null + } + + const view = render() + 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() + + 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() + fireEvent.click(screen.getByRole('button', { name: /Add to Day/i })) + expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42) + + rerender() + 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() + + 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() + 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() + + expect(screen.getByText('GPX import')).toBeInTheDocument() + expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx new file mode 100644 index 0000000000..dae97809eb --- /dev/null +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -0,0 +1,376 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Check, ChevronDown, ChevronUp, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' +import type { TourListItem } from '@trek/shared' +import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types' +import { useTranslation, translateApiError } from '../../i18n' +import { useToast } from '../shared/Toast' +import { useSettingsStore } from '../../store/settingsStore' +import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' +import TrackColorPicker from '../shared/TrackColorPicker' +import DetailShell from '../shared/DetailShell' +import ElevationProfile from '../shared/ElevationProfile' +import { NavigationMenu } from '../shared/NavigationMenu' +import { markdownLinkComponents } from '../shared/markdownLink' +import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' +import { BOX } from '../Planner/bookings/bookingParts' +import { SoftPill, TimePill, tintOf } from '../Planner/planParts' +import { analyzeRouteGeometry } from '../../utils/routeGeometry' +import { filesForPlace } from '../../utils/placeFiles' +import { openFile } from '../../utils/fileDownload' +import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation' +import { hikeSourceBadgeLabel, tourSource } from './tourPresentation' +import { TourMetricFields, TourNotice, TourTile } from './tourParts' +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) => Promise | void + onFileUpload?: (formData: FormData) => Promise + 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(null) + const fileInputRef = useRef(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) => { + const selectedFiles = Array.from(event.target.files || []) + event.target.value = '' + if (!canEdit || !onFileUpload || selectedFiles.length === 0) return + setUploading(true) + try { + for (const file of selectedFiles) { + const formData = new FormData() + formData.append('file', file) + formData.append('place_id', String(place.id)) + await onFileUpload(formData) + } + setFilesExpanded(true) + } catch (error: unknown) { + toast.error(translateApiError(t, error, 'files.uploadError')) + } finally { + setUploading(false) + } + } + + const header = ( + <> +
+ +
+
+ {canEdit && editingName ? ( +
+ 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')} + /> + + + +
+ ) : ( +
+

{place.name}

+ {canEdit && onUpdatePlace && ( + + + + )} +
+ )} +
+ }>{t(`tourTypes.${tour.tour_type}`)} + {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} + {tour.duration != null && {t('tours.durationMinutes', { count: tour.duration })}} + {hikeSourceBadgeLabel(tour, t)} + {tour.caution && ( + + + }>{t('tours.caution.badge')} + + + )} +
+
+ + ) + + const footer = <> + {canAssign && selectedDayId != null && (assignment ? onRemoveAssignment && ( + { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={} aria-label={t('inspector.removeFromDay')}> + {t('inspector.removeFromDay')} + + ) : ( + onAssignToDay && ( + { if (canAssign) onAssignToDay(place.id) }} icon={}> + {t('inspector.addToDay')} + + ) + ))} + {navigationTargets.length > 0 && ( + <> + + { + 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={} + > + {navigationLabel} + + + {navigationOpen && setNavigationOpen(false)} />} + + )} + + {canEdit && onDelete && { if (canEdit) onDelete() }} />} + + + return ( + + {source === 'gpx' && ( + }> + {t('tours.detail.gpxReadOnly')} + {/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */} + + )} + + {analysis && ( + +
+ + {analysis.distanceIndexedProfileSamples.length >= 2 && ( +
+ +
+ )} +
+
+ )} + + + {canEdit && onUpdatePlace ? ( +
+ + {colorOpen && ( +
+ { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> +
+ )} +
+ ) : ( +
+ + {colorLabel} +
+ )} +
+ + {place.description && {place.description}} + {place.notes && {place.notes}} + {assignment?.notes && {assignment.notes}} + + {(placeFiles.length > 0 || (canEdit && onFileUpload)) && ( + + + {uploading ? … : <>{t('common.upload')}} + + )} + > +
+ + {filesExpanded && placeFiles.length > 0 && ( +
+ {placeFiles.map(file => ( + + ))} +
+ )} +
+
+ )} + + {navigationTargets.length > 0 && ( +
+ + {t('tours.detail.navigationHint')} +
+ )} +
+ ) +} + +/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */ +function MarkdownBox({ children }: { children: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx new file mode 100644 index 0000000000..08c2d63767 --- /dev/null +++ b/client/src/components/Tours/TourListRow.tsx @@ -0,0 +1,51 @@ +import { useId, type ReactNode } from 'react' +import type { TourListItem } from '@trek/shared' +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 ( +
  • { 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) + } + }} + > +
    + +
    + {tour.name} + +
    + {action && ( +
    event.stopPropagation()}> + {action} +
    + )} +
    + {children} +
  • + ) +} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx new file mode 100644 index 0000000000..cad5e72f9a --- /dev/null +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -0,0 +1,233 @@ +import { act, fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { useTripStore } from '../../store/tripStore' +import { toursApi } from '../../api/client' +import ToursSidebar from './ToursSidebar' + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })) + +vi.mock('../shared/Toast', () => ({ useToast: () => toast })) + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, + name: 'Selected ridge walk', + tour_type: 'hike', + distance: 4, + elevation_gain: 100, + elevation_loss: 80, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +beforeEach(() => { + resetAllStores() + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) +}) + +describe('ToursSidebar', () => { + it('uses the compact counted dropdown for All, Unplanned and Planned', () => { + render() + 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() + 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() + 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() + + 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(resolve => { resolveAssignment = resolve }) + const onAssignToDay = vi.fn(() => assignment) + const onToursChanged = vi.fn() + render() + + 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() + + 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() + + 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() + + const input = container.querySelector('input[type="file"]')! + const file = new File([''], '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() + + fireEvent.change(container.querySelector('input[type=file]')!, { + target: { files: [new File([''], '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() + }) +}) diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx new file mode 100644 index 0000000000..f61b1a9076 --- /dev/null +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, FileDown } from 'lucide-react' +import type { TourListItem } from '@trek/shared' +import type { Day } from '../../types' +import { toursApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import TourListRow from './TourListRow' +import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' +import { NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' +import EmptyState from '../shared/EmptyState' +import { TourDayMenu } from './tourParts' + +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 + pushUndo?: (label: string, undo: () => void | Promise) => void + /** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */ + onToursChanged?: (placeIds?: number[]) => void | Promise + /** 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(null) + const filterRef = useRef(null) + const filterButtonRef = useRef(null) + const [filterOpen, setFilterOpen] = useState(false) + + const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() } + + const handleFileChange = async (e: React.ChangeEvent) => { + 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 ( +
    +
    + {/* GPX import stays — the tour-appropriate import path. List Import + (place/route-of-driving oriented) is deliberately absent here. */} +
    + + + + +
    +
    + + {filterOpen && ( +
    + {(['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 + })} +
    + )} +
    +
    + +
    + {!loading && filtered.length === 0 ? ( + {t('tours.empty.body')}

    } /> + ) : ( +
      + {filtered.map(tour => ( + onSelectTour?.(selectedTour, opener)} + action={ { void handleAssign(tour, day, index) }} />} + /> + ))} +
    + )} +
    +
    + ) +} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx new file mode 100644 index 0000000000..93d0c90534 --- /dev/null +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -0,0 +1,584 @@ +import { act, useState } from 'react' +import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { resetAllStores, seedStore } from '../../../../tests/helpers/store' +import { useSettingsStore } from '../../../store/settingsStore' +import { analyzeRouteGeometry } from '../../../utils/routeGeometry' +import type { TourPlannerController } from './useTourPlanner' +import type { Day } from '../../../types' +import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels' + +vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, + name: 'Saved ridge walk', + tour_type: 'hike', + distance: 4, + elevation_gain: 100, + elevation_loss: 80, + duration: 60, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +const days = [{ id: 7, title: 'Summit day', date: '2026-05-15' } as Day] +const railProps = { + days, + loading: false, + onAssignToDay: vi.fn(), + onViewGpxTour: vi.fn(), +} + +const routeAnalysis = analyzeRouteGeometry(JSON.stringify([[48, 11, 500], [48.036, 11, 550]])) + +function planner(overrides: Partial = {}): TourPlannerController { + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + 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() + + 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() + + 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() + : render() + 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() + 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() + + 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() + + 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() + + 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() + + 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 { setDifficulty(value); setDifficultyState(value as 1 | 2 | 3 | 4 | 5 | 6) }, + })} /> + } + render() + 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() + + 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() + 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() + 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 ( + setExpanded(value => !value), + })} + tours={[tour]} + {...railProps} + /> + ) + } + + const user = userEvent.setup() + const { container } = render() + 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() + + expect(screen.getByRole('heading', { name: 'Start a new tour?' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(controller.cancelNewTour).toHaveBeenCalledOnce() + + rerender() + 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() + + 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() + + 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() + expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument() + + rerender() + 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() + expect(screen.getAllByRole('option').every(row => row.getAttribute('aria-selected') !== 'true')).toBe(true) + + rerender() + expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveAttribute('aria-selected', 'true') + + rerender() + 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() + 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() + 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() + + 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() + expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored') + expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument() + + rerender() + expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored') + expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx new file mode 100644 index 0000000000..37ca7ae87f --- /dev/null +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -0,0 +1,524 @@ +import { useRef, useState, type ReactNode } from 'react' +import type { TourListItem } from '@trek/shared' +import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react' +import type { Day } from '../../../types' +import { useTranslation } from '../../../i18n' +import { useSettingsStore } from '../../../store/settingsStore' +import { formatDistance, formatElevation } from '../../../utils/units' +import ConfirmDialog from '../../shared/ConfirmDialog' +import CustomSelect from '../../shared/CustomSelect' +import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell' +import { INPUT, LABEL } from '../../shared/dialogParts' +import EmptyState from '../../shared/EmptyState' +import { useToast } from '../../shared/Toast' +import { Tooltip } from '../../shared/Tooltip' +import { BOX } from '../../Planner/bookings/bookingParts' +import { BarButton, SoftPill } from '../../Planner/planParts' +import TourListRow from '../TourListRow' +import ElevationProfile from '../../shared/ElevationProfile' +import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation' +import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts' +import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' +import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner' +import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' + +const STATUS_KEY: Partial> = { + 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 + +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 ( +
    +
    +

    {title}

    + {aside} +
    + {sub &&

    {sub}

    } +
    + ) +} + +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 ( +
    +
    + +
    +
    + {t('tours.planner.neutralBody')}

    } + /> +
    +
    + ) + } + + 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 ( + <> +
    +
    + planner.returnToNeutral()} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + + {hikeSourceBadgeLabel(tour, t)}} /> +
    +
    + }>{t('tours.detail.gpxReadOnly')} + {analysis ? ( + <> + + + + + + ) : ( + {t('tours.planner.gpxNoGeometry')} + )} +
    +
    + + + ) + } + + const isSavedEdit = planner.mode.type === 'edit-saved' + const lastIndex = planner.waypoints.length - 1 + + return ( + <> +
    +
    + requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + + + { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}> + + + { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}> + + +
    + +
    + {planner.draftRestored && planner.hasUnsavedChanges && ( + }>{t('tours.planner.restored')} + )} + +
    +
    + + { if (canEdit && !isSaving) planner.setName(event.target.value) }} + maxLength={255} + placeholder={t('tours.planner.namePlaceholder')} + className={INPUT} + /> +
    +
    + + { + 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}`) }))} + /> +
    + {planner.maxHikingDifficulty === 3 && ( + }>{t('tours.planner.difficulty.t3Warning')} + )} +
    + + + {planner.waypoints.length === 0 ? ( +
    + +

    {t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}

    +
    + ) : ( +
      + {planner.waypoints.map((point, index) => { + const selected = planner.selectedWaypointId === point.id + return ( +
    1. + +
    2. + ) + })} +
    + )} +
    + + {(statusKey || planner.error) && ( + : } + > + {planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null} + {canRetry && ( + + )} + + )} +
    + +
    +

    + + {t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')} +

    +
    + } onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}> + {t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')} + + } onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}> + {t(isSavedEdit ? 'tours.planner.saveChanges' : 'tours.planner.save')} + +
    +
    + + setConfirmDiscard(false)} + onConfirm={planner.returnToNeutral} + title={t('tours.planner.discardTitle')} + message={t('tours.planner.discardBody')} + confirmLabel={t('tours.planner.discardUnsaved')} + /> + + 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')} + /> +
    + + ) +} + +/** 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 ( + + { 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} + + + ) +} + +/** 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 ( + + {t('tours.planner.inspector.elevationPlaceholder')} + + ) + } + const expanded = planner.elevationProfileExpanded + return ( + } + > + + + ) +} + +function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) { + const { t } = useTranslation() + return ( + { 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 + 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(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 ( + + ) +} diff --git a/client/src/components/Tours/planner/tourRouting.test.ts b/client/src/components/Tours/planner/tourRouting.test.ts new file mode 100644 index 0000000000..f37c5f2bb0 --- /dev/null +++ b/client/src/components/Tours/planner/tourRouting.test.ts @@ -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) + }) +}) diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts new file mode 100644 index 0000000000..393bc1f5ef --- /dev/null +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -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[], + signal?: AbortSignal, + maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY, +): Promise { + 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 { + 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] + }) +} diff --git a/client/src/components/Tours/planner/useTourPlanner.test.tsx b/client/src/components/Tours/planner/useTourPlanner.test.tsx new file mode 100644 index 0000000000..04aedac096 --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -0,0 +1,1127 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '../../../../tests/helpers/render' + +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 { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({ + routeWalkingTour: vi.fn(), + enrichTourElevations: vi.fn(), + createTour: vi.fn(), + detailTour: vi.fn(), + updateTour: vi.fn(), +})) + +vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations })) +vi.mock('../../../api/client', () => ({ toursApi: { create: createTour, detail: detailTour, update: updateTour } })) +vi.mock('../useTourPermissions', () => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ canEdit, canAssign }) })) + +import { useTourPlanner } from './useTourPlanner' +import { TourPlannerRail } from './TourPlannerPanels' + +const routed = { + coordinates: [[48, 11], [48.01, 11.02]] as [number, number][], + distanceMeters: 2200, + durationSeconds: 1800, +} +const enriched = [[48, 11, 500], [48.01, 11.02, 550]] as [number, number, number][] +const savedTour = { + place_id: 42, + name: 'Saved ridge', + tour_type: 'hike' as const, + distance: 2.2, + elevation_gain: 50, + elevation_loss: 0, + duration: 30, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((done, fail) => { resolve = done; reject = fail }) + return { promise, resolve, reject } +} + +describe('useTourPlanner', () => { + beforeEach(() => { + vi.useFakeTimers() + localStorage.clear() + routeWalkingTour.mockReset().mockResolvedValue(routed) + enrichTourElevations.mockReset().mockResolvedValue(enriched) + createTour.mockReset() + detailTour.mockReset() + updateTour.mockReset() + }) + + afterEach(() => vi.useRealTimers()) + + it('TOUR-PLANNER-001: normalizes roles and stores settled waypoint undo/redo snapshots', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 7 })) + act(() => result.current.addWaypoint(48, 11)) + const firstId = result.current.waypoints[0].id + act(() => result.current.addWaypoint(49, 12)) + + expect(result.current.waypoints.map(point => point.role)).toEqual(['start', 'end']) + act(() => result.current.addWaypoint(50, 13)) + expect(result.current.waypoints.map(point => point.role)).toEqual(['start', 'via', 'end']) + + act(() => result.current.undo()) + expect(result.current.waypoints).toHaveLength(2) + expect(result.current.waypoints[0].id).toBe(firstId) + act(() => result.current.redo()) + expect(result.current.waypoints).toHaveLength(3) + }) + + it('changes the map focus intent only for explicit Tour context changes', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + const { result } = renderHook(() => useTourPlanner({ tripId: 8 })) + const initialFocusKey = result.current.mapFocusKey + + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.mapFocusKey).toBe(initialFocusKey) + + act(() => result.current.retry()) + expect(result.current.mapFocusKey).toBe(initialFocusKey) + + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) }) + expect(result.current.mapFocusKey).toBe(initialFocusKey + 1) + act(() => result.current.addWaypoint(48.02, 11.03)) + expect(result.current.mapFocusKey).toBe(initialFocusKey + 1) + + act(() => result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, '[[48,11],[48.01,11.02]]')) + expect(result.current.mapFocusKey).toBe(initialFocusKey + 2) + }) + + it('TOUR-PLANNER-002: restores a trip-scoped local draft without changing stable ids', () => { + localStorage.setItem('tour-draft-8', JSON.stringify({ + version: 1, + name: 'Recovered ridge', + waypoints: [ + { id: 'stable-a', lat: 48, lng: 11, role: 'via' }, + { id: 'stable-b', lat: 49, lng: 12, role: 'via' }, + ], + })) + + const { result } = renderHook(() => useTourPlanner({ tripId: 8 })) + expect(result.current.name).toBe('Recovered ridge') + expect(result.current.waypoints.map(point => [point.id, point.role])).toEqual([ + ['stable-a', 'start'], ['stable-b', 'end'], + ]) + }) + + it('TOUR-PLANNER-003: cancels and rejects a stale route response after a waypoint edit', async () => { + const first = deferred() + routeWalkingTour.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ + ...routed, + coordinates: [[50, 13], [51, 14]], + }) + enrichTourElevations.mockResolvedValue([[50, 13, 700], [51, 14, 710]]) + const { result } = renderHook(() => useTourPlanner({ tripId: 9 })) + + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(49, 12) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.status).toBe('routing') + + act(() => result.current.addWaypoint(50, 13)) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.route).toEqual([[50, 13], [51, 14]]) + + await act(async () => { first.resolve(routed); await Promise.resolve() }) + expect(result.current.route).toEqual([[50, 13], [51, 14]]) + }) + + it('TOUR-PLANNER-004: saves only the full elevation-enriched route and clears the draft', async () => { + createTour.mockResolvedValue({ tour: { place_id: 42 }, waypoints: [] }) + const { result } = renderHook(() => useTourPlanner({ tripId: 10 })) + act(() => { + result.current.setName('Summit loop') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + + expect(result.current.canSave).toBe(true) + expect(result.current.routeAnalysis?.minEle).toBe(500) + expect(result.current.routeAnalysis?.maxEle).toBe(550) + expect(result.current.routeAnalysis?.gain).toBe(50) + expect(result.current.routeAnalysis?.distanceIndexedProfileSamples).toHaveLength(2) + await act(async () => { await result.current.save() }) + expect(createTour).toHaveBeenCalledWith(10, expect.objectContaining({ route_geometry: enriched })) + expect(localStorage.getItem('tour-draft-10')).toBeNull() + expect(result.current.status).toBe('saved') + expect(result.current.canUndo).toBe(false) + expect(result.current.canRedo).toBe(false) + expect(result.current.isSaving).toBe(false) + }) + + it('keeps a partially enriched route usable but unavailable for profile metrics and saving', async () => { + enrichTourElevations.mockResolvedValue([[48, 11, 0], [48.01, 11.02]]) + const { result } = renderHook(() => useTourPlanner({ tripId: 100 })) + act(() => { + result.current.setName('Partial elevation ridge') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + + expect(result.current.status).toBe('elevation-failed') + expect(result.current.route).toEqual(routed.coordinates) + expect(result.current.enrichedGeometry).toEqual([[48, 11, 0], [48.01, 11.02]]) + expect(result.current.routeAnalysis?.distanceKm).toBeGreaterThan(0) + expect(result.current.routeAnalysis).toMatchObject({ minEle: null, maxEle: null, gain: null, loss: null, distanceIndexedProfileSamples: [] }) + expect(result.current.canSave).toBe(false) + await act(async () => { expect(await result.current.save()).toBeNull() }) + expect(createTour).not.toHaveBeenCalled() + }) + + it('does not mark a reopened editable Tour ready when re-enrichment has missing samples', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + enrichTourElevations.mockResolvedValue([[48, 11, 0], [48.01, 11.02]]) + const { result } = renderHook(() => useTourPlanner({ tripId: 1001 })) + + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + + expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: savedTour.place_id }) + expect(result.current.status).toBe('elevation-failed') + expect(result.current.route).toEqual(routed.coordinates) + expect(result.current.routeAnalysis).toMatchObject({ gain: null, loss: null, minEle: null, maxEle: null }) + expect(result.current.canSave).toBe(false) + await act(async () => { expect(await result.current.save()).toBeNull() }) + expect(updateTour).not.toHaveBeenCalled() + }) + + it('finalizes only the submitted revision and preserves edits made while its request is pending', async () => { + const pending = deferred<{ tour: typeof savedTour; waypoints: [] }>() + createTour.mockReturnValue(pending.promise) + const onSaved = vi.fn() + const { result } = renderHook(() => useTourPlanner({ tripId: 101, onSaved })) + act(() => { + result.current.setName('Submitted ridge') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + let savePromise!: Promise + act(() => { savePromise = result.current.save() }) + expect(result.current.isSaving).toBe(true) + + act(() => result.current.setName('Newer ridge edit')) + expect(result.current.name).toBe('Newer ridge edit') + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.canUndo).toBe(true) + expect(JSON.parse(localStorage.getItem('tour-draft-101') || 'null')).toMatchObject({ name: 'Newer ridge edit' }) + + await act(async () => { + pending.resolve({ tour: { ...savedTour, name: 'Submitted ridge' }, waypoints: [] }) + await savePromise + }) + + expect(result.current.name).toBe('Newer ridge edit') + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.canUndo).toBe(true) + expect(result.current.saveOutcome).toBeNull() + expect(localStorage.getItem('tour-draft-101')).not.toBeNull() + expect(JSON.parse(localStorage.getItem('tour-draft-101') || 'null')).toMatchObject({ name: 'Newer ridge edit' }) + expect(onSaved).toHaveBeenCalledOnce() + expect(result.current.isSaving).toBe(false) + }) + + it('does not start overlapping Saves and prevents context replacement while saving', async () => { + const pending = deferred<{ tour: typeof savedTour; waypoints: [] }>() + createTour.mockReturnValue(pending.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 102 })) + act(() => { + result.current.setName('Single request') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + + let firstSave!: Promise + let secondSave!: Promise + act(() => { firstSave = result.current.save(); secondSave = result.current.save() }) + expect(createTour).toHaveBeenCalledOnce() + expect(result.current.isSaving).toBe(true) + act(() => { + result.current.startNewTour(true) + result.current.returnToNeutral() + result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, '[[48,11],[48.01,11.02]]') + }) + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(false) }) + expect(result.current.name).toBe('Single request') + expect(result.current.mode).toEqual({ type: 'new-draft' }) + + await act(async () => { + pending.resolve({ tour: { ...savedTour, name: 'Single request' }, waypoints: [] }) + await Promise.all([firstSave, secondSave]) + }) + expect(createTour).toHaveBeenCalledOnce() + expect(result.current.status).toBe('saved') + }) + + it('ignores an old Save failure after a newer draft revision exists', async () => { + const pending = deferred<{ tour: typeof savedTour; waypoints: [] }>() + createTour.mockReturnValue(pending.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 103 })) + act(() => { + result.current.setName('Submitted name') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + let savePromise!: Promise + act(() => { savePromise = result.current.save() }) + act(() => result.current.setName('Latest name')) + + await act(async () => { + pending.reject(new Error('old request failed')) + await savePromise + }) + + expect(result.current.name).toBe('Latest name') + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.error).toBeNull() + expect(result.current.status).toBe('ready') + expect(JSON.parse(localStorage.getItem('tour-draft-103') || 'null')).toMatchObject({ name: 'Latest name' }) + expect(result.current.isSaving).toBe(false) + }) + + it('does not finalize a pending Save after unmount', async () => { + const pending = deferred<{ tour: typeof savedTour; waypoints: [] }>() + createTour.mockReturnValue(pending.promise) + const onSaved = vi.fn() + const { result, unmount } = renderHook(() => useTourPlanner({ tripId: 104, onSaved })) + act(() => { + result.current.setName('Unmounting save') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + let savePromise!: Promise + act(() => { savePromise = result.current.save() }) + unmount() + + await act(async () => { + pending.resolve({ tour: { ...savedTour, name: 'Unmounting save' }, waypoints: [] }) + await savePromise + }) + expect(onSaved).not.toHaveBeenCalled() + }) + + it('TOUR-PLANNER-005: keeps the Tours base layer in per-view planner state', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 11 })) + + expect(result.current.mapBaseLayer).toBe('default') + act(() => result.current.setMapBaseLayer('topo')) + expect(result.current.mapBaseLayer).toBe('topo') + act(() => result.current.setMapBaseLayer('satellite')) + expect(result.current.mapBaseLayer).toBe('satellite') + }) + + it('TOUR-PLANNER-006: opens persisted controls from a saved tour and re-routes them', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + const { result } = renderHook(() => useTourPlanner({ tripId: 12 })) + + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) }) + + expect(detailTour).toHaveBeenCalledWith(12, 42, expect.any(AbortSignal)) + expect(result.current.editingPlaceId).toBe(42) + expect(result.current.name).toBe('Saved ridge') + expect(result.current.waypoints.map(point => point.role)).toEqual(['start', 'end']) + expect(result.current.hasUnsavedChanges).toBe(false) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(routeWalkingTour).toHaveBeenCalledWith(result.current.waypoints, expect.any(AbortSignal), 2) + expect(result.current.status).toBe('ready') + expect(result.current.route).toEqual(routed.coordinates) + + act(() => result.current.removeWaypoint(result.current.waypoints[1].id)) + expect(result.current.editingPlaceId).toBe(savedTour.place_id) + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.waypoints).toHaveLength(1) + expect(result.current.route).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.canSave).toBe(false) + }) + + it('TOUR-PLANNER-007: saves edits back to the opened tour and retains edit identity', async () => { + const detail = { + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + } + detailTour.mockResolvedValue(detail) + updateTour.mockResolvedValue({ ...detail, tour: { ...savedTour, name: 'Edited ridge' } }) + const { result } = renderHook(() => useTourPlanner({ tripId: 13 })) + await act(async () => { await result.current.openTour(savedTour) }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + act(() => result.current.setName('Edited ridge')) + + expect(result.current.canSave).toBe(true) + await act(async () => { await result.current.save() }) + + expect(updateTour).toHaveBeenCalledWith(13, 42, expect.objectContaining({ name: 'Edited ridge' })) + expect(createTour).not.toHaveBeenCalled() + expect(result.current.saveOutcome).toMatchObject({ place_id: 42, name: 'Edited ridge' }) + expect(result.current.editingPlaceId).toBe(42) + }) + + it('keeps the newest Tour detail when requests resolve out of order', async () => { + const first = deferred<{ tour: typeof savedTour; waypoints: { lat: number; lng: number; role: 'start' | 'end'; sequence: number }[] }>() + const second = deferred() + detailTour.mockImplementation((_tripId: number, placeId: number) => placeId === 42 ? first.promise : second.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 130 })) + const otherTour = { ...savedTour, place_id: 43, name: 'North ridge' } + + let openFirst!: Promise + let openSecond!: Promise + act(() => { openFirst = result.current.openTour(savedTour); openSecond = result.current.openTour(otherTour) }) + const firstSignal = detailTour.mock.calls[0][2] as AbortSignal + expect(firstSignal.aborted).toBe(true) + expect(result.current.openingTourId).toBe(43) + + await act(async () => { + second.resolve({ tour: otherTour, waypoints: [ + { lat: 50, lng: 13, role: 'start', sequence: 0 }, + { lat: 50.01, lng: 13.02, role: 'end', sequence: 1 }, + ] }) + expect(await openSecond).toBe(true) + }) + await act(async () => { + first.resolve({ tour: savedTour, waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ] }) + expect(await openFirst).toBe(false) + }) + + expect(result.current.editingPlaceId).toBe(43) + expect(result.current.name).toBe('North ridge') + expect(result.current.waypoints.map(point => point.lat)).toEqual([50, 50.01]) + expect(result.current.openingTourId).toBeNull() + }) + + it('does not let pending detail replace a GPX view or a newly started draft', async () => { + const gpxRequest = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockReturnValueOnce(gpxRequest.promise) + const gpxPlanner = renderHook(() => useTourPlanner({ tripId: 131 })) + let openGpxRequest!: Promise + act(() => { openGpxRequest = gpxPlanner.result.current.openTour(savedTour) }) + const gpxSignal = detailTour.mock.calls[0][2] as AbortSignal + act(() => gpxPlanner.result.current.viewGpxTour({ ...savedTour, place_id: 44, has_waypoints: false }, '[[35,139],[35.1,139.1]]')) + expect(gpxSignal.aborted).toBe(true) + await act(async () => { + gpxRequest.resolve({ tour: savedTour, waypoints: [] }) + expect(await openGpxRequest).toBe(false) + }) + expect(gpxPlanner.result.current.mode.type).toBe('view-gpx') + expect(gpxPlanner.result.current.mode.type === 'view-gpx' && gpxPlanner.result.current.mode.placeId).toBe(44) + + const draftRequest = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockReturnValueOnce(draftRequest.promise) + const draftPlanner = renderHook(() => useTourPlanner({ tripId: 132 })) + let openDraftRequest!: Promise + act(() => { openDraftRequest = draftPlanner.result.current.openTour(savedTour) }) + act(() => draftPlanner.result.current.startNewTour()) + const draftRecovery = localStorage.getItem('tour-draft-132') + await act(async () => { + draftRequest.resolve({ tour: savedTour, waypoints: [] }) + expect(await openDraftRequest).toBe(false) + }) + expect(draftPlanner.result.current.mode).toEqual({ type: 'new-draft' }) + expect(localStorage.getItem('tour-draft-132')).toBe(draftRecovery) + expect(draftPlanner.result.current.editingPlaceId).toBeNull() + }) + + it('ignores detail completion after trip change, Planner close, or unmount', async () => { + const tripRequest = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockReturnValueOnce(tripRequest.promise) + const tripPlanner = renderHook(({ tripId }) => useTourPlanner({ tripId }), { initialProps: { tripId: 133 } }) + let openTripRequest!: Promise + act(() => { openTripRequest = tripPlanner.result.current.openTour(savedTour) }) + tripPlanner.rerender({ tripId: 134 }) + await act(async () => { + tripRequest.resolve({ tour: savedTour, waypoints: [] }) + expect(await openTripRequest).toBe(false) + }) + expect(tripPlanner.result.current.editingPlaceId).toBeNull() + + const closeRequest = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockReturnValueOnce(closeRequest.promise) + const closingPlanner = renderHook(({ active }) => useTourPlanner({ tripId: 135, active }), { initialProps: { active: true } }) + let openCloseRequest!: Promise + act(() => { openCloseRequest = closingPlanner.result.current.openTour(savedTour) }) + const closeSignal = detailTour.mock.calls[detailTour.mock.calls.length - 1][2] as AbortSignal + closingPlanner.rerender({ active: false }) + expect(closeSignal.aborted).toBe(true) + await act(async () => { + closeRequest.resolve({ tour: savedTour, waypoints: [] }) + expect(await openCloseRequest).toBe(false) + }) + expect(closingPlanner.result.current.editingPlaceId).toBeNull() + + const unmountRequest = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockReturnValueOnce(unmountRequest.promise) + const unmountPlanner = renderHook(() => useTourPlanner({ tripId: 136 })) + let openUnmountRequest!: Promise + act(() => { openUnmountRequest = unmountPlanner.result.current.openTour(savedTour) }) + const unmountSignal = detailTour.mock.calls[detailTour.mock.calls.length - 1][2] as AbortSignal + unmountPlanner.unmount() + expect(unmountSignal.aborted).toBe(true) + await act(async () => { + unmountRequest.resolve({ tour: savedTour, waypoints: [] }) + expect(await openUnmountRequest).toBe(false) + }) + }) + + it('ignores stale failure and finalization while preserving current request loading', async () => { + const first = deferred<{ tour: typeof savedTour; waypoints: [] }>() + const second = deferred<{ tour: typeof savedTour; waypoints: [] }>() + detailTour.mockImplementation((_tripId: number, placeId: number) => placeId === 42 ? first.promise : second.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 137 })) + const otherTour = { ...savedTour, place_id: 43, name: 'Other ridge' } + let openFirst!: Promise + let openSecond!: Promise + act(() => { openFirst = result.current.openTour(savedTour); openSecond = result.current.openTour(otherTour) }) + + await act(async () => { first.reject(new Error('stale detail failed')); expect(await openFirst).toBe(false) }) + expect(result.current.openingTourId).toBe(43) + expect(result.current.error).toBeNull() + + await act(async () => { + second.resolve({ tour: otherTour, waypoints: [] }) + expect(await openSecond).toBe(false) + }) + expect(result.current.error).toBe('open') + expect(result.current.openingTourId).toBeNull() + + detailTour.mockRejectedValueOnce(new Error('current detail failed')) + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(false) }) + expect(result.current.error).toBe('open') + expect(result.current.openingTourId).toBeNull() + }) + + it('TOUR-PLANNER-008: views GPX geometry read-only and clears editor state', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 14 })) + act(() => { + result.current.setName('Unsaved planner draft') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + const waypoints = result.current.waypoints + const geometry = JSON.stringify([[48, 11, 500], [48.01, 11.02, 550]]) + + act(() => result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, geometry)) + + expect(result.current.readOnlyGpxTour?.tour.name).toBe('Saved ridge') + expect(result.current.readOnlyGpxTour?.routeGeometry).toBe(geometry) + expect(result.current.mode.type).toBe('view-gpx') + expect(result.current.waypoints).toEqual([]) + expect(result.current.name).toBe('') + expect(result.current.hasUnsavedChanges).toBe(false) + + act(() => result.current.closeGpxTour()) + expect(result.current.readOnlyGpxTour).toBeNull() + expect(result.current.mode).toEqual({ type: 'neutral' }) + expect(result.current.waypoints).toEqual([]) + expect(result.current.hasUnsavedChanges).toBe(false) + }) + + it('keeps profile focus transient and clears it on collapse and GPX selection changes', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 141 })) + 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 } + + act(() => result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, geometry)) + act(() => result.current.setRouteProfileFocus(focus)) + expect(result.current.routeProfileFocus).toEqual(focus) + expect(localStorage.getItem('tour-draft-141')).toBeNull() + + act(() => result.current.toggleElevationProfile()) + expect(result.current.elevationProfileExpanded).toBe(false) + expect(result.current.routeProfileFocus).toBeNull() + + act(() => result.current.setRouteProfileFocus(focus)) + act(() => result.current.viewGpxTour({ ...savedTour, place_id: 43, has_waypoints: false }, geometry)) + expect(result.current.routeProfileFocus).toBeNull() + expect(result.current.readOnlyGpxTour?.tour.place_id).toBe(43) + }) + + it('keeps focus on last-good geometry while rerouting and clears it when replacement geometry becomes ready', async () => { + const replacementRoute = { ...routed, coordinates: [[49, 12], [49.02, 12.03]] as [number, number][] } + const replacementGeometry = [[49, 12, 600], [49.02, 12.03, 640]] as [number, number, number][] + routeWalkingTour.mockResolvedValueOnce(routed).mockResolvedValueOnce(replacementRoute) + enrichTourElevations.mockResolvedValueOnce(enriched).mockResolvedValueOnce(replacementGeometry) + const { result } = renderHook(() => useTourPlanner({ tripId: 142 })) + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + const focus = { distanceMeters: 500, elevationMeters: 525, lat: 48.005, lng: 11.01, sampleIndex: 1 } + act(() => result.current.setRouteProfileFocus(focus)) + + act(() => result.current.addWaypoint(48.02, 11.04)) + expect(result.current.routeProfileFocus).toEqual(focus) + expect(result.current.enrichedGeometry).toEqual(enriched) + + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.enrichedGeometry).toEqual(replacementGeometry) + expect(result.current.routeProfileFocus).toBeNull() + expect(JSON.parse(localStorage.getItem('tour-draft-142') || '{}')).not.toHaveProperty('routeProfileFocus') + }) + + it('clears derived route state when deleting below two waypoints', async () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 143 })) + act(() => { + result.current.setName('Keep this draft') + result.current.setMaxHikingDifficulty(4) + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.status).toBe('ready') + + act(() => result.current.setRouteProfileFocus({ + distanceMeters: 500, elevationMeters: 525, lat: 48.005, lng: 11.01, sampleIndex: 1, + })) + const removedId = result.current.waypoints[1].id + act(() => result.current.removeWaypoint(removedId)) + + expect(result.current.waypoints).toHaveLength(1) + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.routeProfileFocus).toBeNull() + expect(result.current.canSave).toBe(false) + expect(result.current.name).toBe('Keep this draft') + expect(result.current.maxHikingDifficulty).toBe(4) + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.canUndo).toBe(true) + expect(JSON.parse(localStorage.getItem('tour-draft-143') || 'null')).toMatchObject({ + name: 'Keep this draft', maxHikingDifficulty: 4, waypoints: [{ lat: 48, lng: 11, role: 'start' }], + }) + }) + + it('keeps the last-good route while deleting from three waypoints to two', async () => { + const replacementRoute = { ...routed, coordinates: [[49, 12], [49.02, 12.03]] as [number, number][] } + const replacementGeometry = [[49, 12, 600], [49.02, 12.03, 640]] as [number, number, number][] + routeWalkingTour.mockResolvedValueOnce(routed).mockResolvedValueOnce(replacementRoute) + enrichTourElevations.mockResolvedValueOnce(enriched).mockResolvedValueOnce(replacementGeometry) + const { result } = renderHook(() => useTourPlanner({ tripId: 144 })) + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + result.current.addWaypoint(48.02, 11.04) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + act(() => result.current.removeWaypoint(result.current.waypoints[2].id)) + + expect(result.current.waypoints).toHaveLength(2) + expect(result.current.status).toBe('dirty') + expect(result.current.route).toEqual(routed.coordinates) + expect(result.current.enrichedGeometry).toEqual(enriched) + expect(result.current.distanceMeters).toBe(routed.distanceMeters) + expect(result.current.durationSeconds).toBe(routed.durationSeconds) + + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.route).toEqual(replacementRoute.coordinates) + expect(result.current.enrichedGeometry).toEqual(replacementGeometry) + }) + + it('clears all derived route state when deleting to zero waypoints', async () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 145 })) + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + act(() => { + for (const point of [...result.current.waypoints]) result.current.removeWaypoint(point.id) + }) + + expect(result.current.waypoints).toEqual([]) + expect(result.current.status).toBe('empty') + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.routeProfileFocus).toBeNull() + expect(result.current.canSave).toBe(false) + }) + + it('does not restore a late route response after deleting to one waypoint', async () => { + const pendingRoute = deferred() + routeWalkingTour.mockReturnValueOnce(pendingRoute.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 146 })) + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + const signal = routeWalkingTour.mock.calls[0][1] as AbortSignal + expect(signal.aborted).toBe(false) + + act(() => result.current.removeWaypoint(result.current.waypoints[1].id)) + expect(signal.aborted).toBe(true) + await act(async () => { pendingRoute.resolve(routed); await Promise.resolve() }) + + expect(result.current.route).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + }) + + it('does not restore late elevation after deleting to one waypoint', async () => { + const pendingElevation = deferred() + enrichTourElevations.mockReturnValueOnce(pendingElevation.promise) + const { result } = renderHook(() => useTourPlanner({ tripId: 147 })) + act(() => { + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + const signal = enrichTourElevations.mock.calls[0][1] as AbortSignal + expect(result.current.status).toBe('enriching-elevation') + + act(() => result.current.removeWaypoint(result.current.waypoints[1].id)) + expect(signal.aborted).toBe(true) + await act(async () => { pendingElevation.resolve(enriched); await Promise.resolve() }) + + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + }) + + it('reroutes after Undo and clears again on Redo to one waypoint', async () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 148 })) + act(() => { + result.current.setName('Undo ridge') + result.current.setMaxHikingDifficulty(5) + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + act(() => result.current.removeWaypoint(result.current.waypoints[1].id)) + expect(result.current.route).toBeNull() + + act(() => result.current.undo()) + expect(result.current.waypoints).toHaveLength(2) + expect(result.current.canRedo).toBe(true) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.route).toEqual(routed.coordinates) + expect(result.current.enrichedGeometry).toEqual(enriched) + + act(() => result.current.redo()) + expect(result.current.waypoints).toHaveLength(1) + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.routeProfileFocus).toBeNull() + expect(result.current.name).toBe('Undo ridge') + expect(result.current.maxHikingDifficulty).toBe(5) + expect(result.current.canSave).toBe(false) + expect(JSON.parse(localStorage.getItem('tour-draft-148') || 'null')?.waypoints).toHaveLength(1) + }) + + it('restores a one-waypoint draft without routed or elevation output', async () => { + localStorage.setItem('tour-draft-149', JSON.stringify({ + version: 2, + name: 'Recovered incomplete ridge', + waypoints: [{ id: 'only-point', lat: 48, lng: 11, role: 'start' }], + maxHikingDifficulty: 6, + editingPlaceId: null, + })) + const { result } = renderHook(() => useTourPlanner({ tripId: 149 })) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + + expect(result.current.waypoints).toHaveLength(1) + expect(result.current.name).toBe('Recovered incomplete ridge') + expect(result.current.maxHikingDifficulty).toBe(6) + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.routeAnalysis).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.canSave).toBe(false) + expect(routeWalkingTour).not.toHaveBeenCalled() + expect(localStorage.getItem('tour-draft-149')).not.toBeNull() + }) + + it('TOUR-PLANNER-009: persists a changed routing difficulty in the recoverable draft', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 15 })) + act(() => result.current.setMaxHikingDifficulty(4)) + + expect(JSON.parse(localStorage.getItem('tour-draft-15') || 'null')).toMatchObject({ + maxHikingDifficulty: 4, + }) + }) + + it('TOUR-PLANNER-010: starts a clean T2 draft and clears route, selection, history, and stored draft', async () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 16 })) + act(() => { + result.current.setName('Old draft') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + result.current.setMaxHikingDifficulty(4) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + const previousDraft = localStorage.getItem('tour-draft-16') + expect(previousDraft).not.toBeNull() + expect(result.current.canUndo).toBe(true) + + act(() => result.current.startNewTour(true)) + + expect(result.current.name).toBe('') + expect(result.current.waypoints).toEqual([]) + expect(result.current.maxHikingDifficulty).toBe(2) + expect(result.current.editingPlaceId).toBeNull() + expect(result.current.readOnlyGpxTour).toBeNull() + expect(result.current.route).toBeNull() + expect(result.current.enrichedGeometry).toBeNull() + expect(result.current.distanceMeters).toBeNull() + expect(result.current.durationSeconds).toBeNull() + expect(result.current.canUndo).toBe(false) + expect(result.current.canRedo).toBe(false) + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.mode).toEqual({ type: 'new-draft' }) + expect(result.current.status).toBe('empty') + expect(JSON.parse(localStorage.getItem('tour-draft-16') || 'null')).toMatchObject({ + name: '', waypoints: [], maxHikingDifficulty: 2, editingPlaceId: null, + }) + + act(() => result.current.addWaypoint(47, 10)) + expect(localStorage.length).toBe(1) + expect(JSON.parse(localStorage.getItem('tour-draft-16') || 'null')).toMatchObject({ + name: '', + maxHikingDifficulty: 2, + editingPlaceId: null, + waypoints: [{ lat: 47, lng: 10, role: 'start' }], + }) + }) + + it('TOUR-PLANNER-011: protects a dirty draft until new-tour confirmation and cancel preserve it', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 17 })) + act(() => { + result.current.setName('Keep this draft') + result.current.addWaypoint(48, 11) + result.current.addWaypoint(48.01, 11.02) + }) + const waypoints = result.current.waypoints + const storedDraft = localStorage.getItem('tour-draft-17') + + act(() => result.current.startNewTour()) + expect(result.current.newTourConfirmationOpen).toBe(true) + expect(result.current.name).toBe('Keep this draft') + expect(result.current.waypoints).toBe(waypoints) + expect(localStorage.getItem('tour-draft-17')).toBe(storedDraft) + + act(() => result.current.cancelNewTour()) + expect(result.current.newTourConfirmationOpen).toBe(false) + expect(result.current.name).toBe('Keep this draft') + expect(result.current.waypoints).toBe(waypoints) + expect(localStorage.getItem('tour-draft-17')).toBe(storedDraft) + }) + + it('TOUR-PLANNER-012: opening a saved Tour then starting new never updates the saved Tour', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + const { result } = renderHook(() => useTourPlanner({ tripId: 18 })) + await act(async () => { await result.current.openTour(savedTour) }) + expect(result.current.editingPlaceId).toBe(42) + + act(() => result.current.startNewTour()) + + expect(result.current.editingPlaceId).toBeNull() + expect(result.current.newTourConfirmationOpen).toBe(false) + expect(result.current.waypoints).toEqual([]) + expect(updateTour).not.toHaveBeenCalled() + expect(createTour).not.toHaveBeenCalled() + }) + + it('TOUR-PLANNER-013: starting new from GPX clears read-only selection and creates no waypoints', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 19 })) + act(() => result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, JSON.stringify([[48, 11], [48.01, 11.02]]))) + expect(result.current.readOnlyGpxTour).not.toBeNull() + + act(() => result.current.startNewTour()) + + expect(result.current.readOnlyGpxTour).toBeNull() + expect(result.current.newTourConfirmationOpen).toBe(false) + expect(result.current.waypoints).toEqual([]) + expect(result.current.route).toBeNull() + expect(result.current.maxHikingDifficulty).toBe(2) + }) + + it('TOUR-PLANNER-014: an empty draft with no name or waypoints starts over without confirmation', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 20 })) + act(() => result.current.setMaxHikingDifficulty(4)) + expect(result.current.hasUnsavedChanges).toBe(true) + + act(() => result.current.startNewTour()) + + expect(result.current.newTourConfirmationOpen).toBe(false) + expect(result.current.maxHikingDifficulty).toBe(2) + expect(result.current.waypoints).toEqual([]) + expect(JSON.parse(localStorage.getItem('tour-draft-20') || 'null')).toMatchObject({ + name: '', waypoints: [], maxHikingDifficulty: 2, + }) + }) + + it('TOUR-PLANNER-015: starting new before any edits does not ask for confirmation', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 21 })) + act(() => result.current.startNewTour()) + + expect(result.current.newTourConfirmationOpen).toBe(false) + expect(result.current.hasUnsavedChanges).toBe(true) + expect(result.current.mode).toEqual({ type: 'new-draft' }) + expect(result.current.waypoints).toEqual([]) + expect(result.current.maxHikingDifficulty).toBe(2) + }) + + it('TOUR-PLANNER-016: derives neutral without creating an implicit local draft', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 22 })) + + expect(result.current.mode).toEqual({ type: 'neutral' }) + expect(result.current.name).toBe('') + expect(result.current.waypoints).toEqual([]) + expect(result.current.hasUnsavedChanges).toBe(false) + expect(localStorage.getItem('tour-draft-22')).toBeNull() + }) + + it('TOUR-PLANNER-017: starting new derives new-draft and persists its blank T2 draft', () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 23 })) + act(() => result.current.startNewTour()) + + expect(result.current.mode).toEqual({ type: 'new-draft' }) + expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({ + version: 2, + name: '', + waypoints: [], + maxHikingDifficulty: 2, + editingPlaceId: null, + }) + }) + + it('TOUR-PLANNER-018: restores new and saved drafts into their derived modes', () => { + localStorage.setItem('tour-draft-24', JSON.stringify({ + version: 2, + name: 'Restored new hike', + waypoints: [{ id: 'new-start', lat: 48, lng: 11, role: 'start' }], + maxHikingDifficulty: 2, + editingPlaceId: null, + })) + localStorage.setItem('tour-draft-25', JSON.stringify({ + version: 2, + name: 'Restored saved hike', + waypoints: [{ id: 'saved-start', lat: 48, lng: 11, role: 'start' }], + maxHikingDifficulty: 3, + editingPlaceId: 42, + })) + + const freshDraft = renderHook(() => useTourPlanner({ tripId: 24 })) + const savedEdit = renderHook(() => useTourPlanner({ tripId: 25 })) + + expect(freshDraft.result.current.mode).toEqual({ type: 'new-draft' }) + expect(freshDraft.result.current.draftRestored).toBe(true) + expect(savedEdit.result.current.mode).toEqual({ type: 'edit-saved', placeId: 42 }) + expect(savedEdit.result.current.draftRestored).toBe(true) + }) + + it('TOUR-PLANNER-019: saved selection remains edit-saved after save and GPX selection derives view-gpx', async () => { + const detail = { + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + } + detailTour.mockResolvedValue(detail) + updateTour.mockResolvedValue({ ...detail, tour: savedTour }) + const { result } = renderHook(() => useTourPlanner({ tripId: 26 })) + + await act(async () => { await result.current.openTour(savedTour) }) + expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: 42 }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + act(() => result.current.setName('Saved ridge renamed')) + await act(async () => { await result.current.save() }) + expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: 42 }) + expect(result.current.hasUnsavedChanges).toBe(false) + + act(() => result.current.startNewTour()) + expect(result.current.mode).toEqual({ type: 'new-draft' }) + expect(result.current.editingPlaceId).toBeNull() + expect(result.current.saveOutcome).toBeNull() + + act(() => result.current.viewGpxTour({ ...savedTour, place_id: 43, has_waypoints: false }, '[[48,11],[48.01,11.02]]')) + expect(result.current.mode).toEqual({ type: 'view-gpx', placeId: 43, tour: expect.objectContaining({ place_id: 43 }) }) + expect(result.current.editingPlaceId).toBeNull() + expect(result.current.hasUnsavedChanges).toBe(false) + }) + + it('TOUR-PLANNER-020: leaving Tours forgets GPX but preserves a meaningful editable draft', () => { + const { result, unmount } = renderHook(() => useTourPlanner({ tripId: 27 })) + act(() => { + result.current.startNewTour() + result.current.setName('Keep this new draft') + result.current.addWaypoint(48, 11) + }) + const savedDraft = localStorage.getItem('tour-draft-27') + + act(() => result.current.closeGpxTour()) + + expect(result.current.mode).toEqual({ type: 'new-draft' }) + expect(result.current.name).toBe('Keep this new draft') + expect(result.current.waypoints).toHaveLength(1) + expect(localStorage.getItem('tour-draft-27')).toBe(savedDraft) + + unmount() + const reopened = renderHook(() => useTourPlanner({ tripId: 27 })) + expect(reopened.result.current.mode).toEqual({ type: 'new-draft' }) + expect(reopened.result.current.name).toBe('Keep this new draft') + + act(() => reopened.result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, '[[48,11],[48.01,11.02]]')) + expect(reopened.result.current.mode.type).toBe('view-gpx') + act(() => reopened.result.current.closeGpxTour()) + expect(reopened.result.current.mode).toEqual({ type: 'neutral' }) + expect(reopened.result.current.readOnlyGpxTour).toBeNull() + + reopened.unmount() + const cleanReopen = renderHook(() => useTourPlanner({ tripId: 27 })) + expect(cleanReopen.result.current.mode).toEqual({ type: 'neutral' }) + expect(cleanReopen.result.current.readOnlyGpxTour).toBeNull() + }) + + it('TOUR-PLANNER-021: returning from a dirty saved edit clears only local editor state', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + const { result } = renderHook(() => useTourPlanner({ tripId: 29 })) + await act(async () => { await result.current.openTour(savedTour) }) + act(() => result.current.setName('Unsaved rename')) + expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: savedTour.place_id }) + const persistedDraft = localStorage.getItem('tour-draft-29') + expect(persistedDraft).not.toBeNull() + + act(() => result.current.returnToNeutral()) + + expect(result.current.mode).toEqual({ type: 'neutral' }) + expect(result.current.waypoints).toEqual([]) + expect(result.current.name).toBe('') + expect(localStorage.getItem('tour-draft-29')).toBeNull() + expect(updateTour).not.toHaveBeenCalled() + expect(createTour).not.toHaveBeenCalled() + }) + + it('clears a successfully deleted saved Tour from the editor and blocks a stale update', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + const { result } = renderHook(() => useTourPlanner({ tripId: 29 })) + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) }) + act(() => result.current.setName('Unsaved rename')) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: savedTour.place_id }) + const view = render() + expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument() + + act(() => result.current.forgetDeletedTour(savedTour.place_id)) + view.rerender() + + expect(result.current.mode).toEqual({ type: 'neutral' }) + expect(result.current.editingPlaceId).toBeNull() + expect(result.current.waypoints).toEqual([]) + expect(result.current.route).toBeNull() + expect(result.current.routeProfileFocus).toBeNull() + expect(result.current.error).toBeNull() + expect(result.current.hasUnsavedChanges).toBe(false) + expect(result.current.canSave).toBe(false) + expect(localStorage.getItem('tour-draft-29')).toBeNull() + expect(screen.getByText('Plan a tour')).toBeInTheDocument() + expect(screen.queryByText('Tour could not be saved. Your draft is still here.')).not.toBeInTheDocument() + await act(async () => { expect(await result.current.save()).toBeNull() }) + expect(updateTour).not.toHaveBeenCalled() + }) + + it('keeps the existing save-failure notice and draft when a Tour update fails', async () => { + detailTour.mockResolvedValue({ + tour: savedTour, + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end', sequence: 1 }, + ], + }) + updateTour.mockRejectedValue(new Error('Tour update failed')) + const { result } = renderHook(() => useTourPlanner({ tripId: 29 })) + await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) }) + act(() => result.current.setName('Keep this draft')) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + const view = render() + + await act(async () => { expect(await result.current.save()).toBeNull() }) + view.rerender() + + expect(result.current.editingPlaceId).toBe(savedTour.place_id) + expect(result.current.name).toBe('Keep this draft') + expect(screen.getByText('Tour could not be saved. Your draft is still here.')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts new file mode 100644 index 0000000000..f8642f419a --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -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 + 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 +} + +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(restored?.waypoints ?? []) + const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState(restored?.maxHikingDifficulty ?? 2) + const [selectedWaypointId, setSelectedWaypointId] = useState(restored?.waypoints[0]?.id ?? null) + const [history, setHistory] = useState({ past: [], future: [] }) + const [status, setStatus] = useState(restored?.waypoints.length ? 'dirty' : 'empty') + const [route, setRoute] = useState<[number, number][] | null>(null) + const [enrichedGeometry, setEnrichedGeometry] = useState(null) + const [routeForKey, setRouteForKey] = useState(null) + const [distanceMeters, setDistanceMeters] = useState(null) + const [durationSeconds, setDurationSeconds] = useState(null) + const [error, setError] = useState(null) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(Boolean(restored)) + const [isSaving, setIsSaving] = useState(false) + const [draftRestored, setDraftRestored] = useState(Boolean(restored)) + const [editingPlaceId, setEditingPlaceId] = useState(restored?.editingPlaceId ?? null) + const [openingTourId, setOpeningTourId] = useState(null) + const [saveOutcome, setSaveOutcome] = useState(null) + const [retryToken, setRetryToken] = useState(0) + const [mapBaseLayer, setMapBaseLayer] = useState('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(null) + const [newDraftGeneration, setNewDraftGeneration] = useState(0) + const [mapFocusKey, setMapFocusKey] = useState(0) + const abortRef = useRef(null) + const generationRef = useRef(0) + const draftRevisionRef = useRef(0) + const saveRequestIdRef = useRef(0) + const saveInFlightRef = useRef(false) + const deletedTourPlaceIdsRef = useRef(new Set()) + const mountedRef = useRef(false) + const contextGenerationRef = useRef(0) + const detailRequestIdRef = useRef(0) + const detailAbortRef = useRef(null) + const detailPlaceIdRef = useRef(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 diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx new file mode 100644 index 0000000000..2449f8eab4 --- /dev/null +++ b/client/src/components/Tours/tourParts.tsx @@ -0,0 +1,191 @@ +import type { ReactNode } from 'react' +import type { TourListItem } from '@trek/shared' +import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Mountain, Plus, Ruler } from 'lucide-react' +import type { Day, DistanceUnit } from '../../types' +import type { RouteGeometryAnalysis } from '../../utils/routeGeometry' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance, formatElevation } from '../../utils/units' +import { formatDate } from '../../utils/formatters' +import { ContextMenu, useContextMenu } from '../shared/ContextMenu' +import { DialogButton, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' +import { useTripStore } from '../../store/tripStore' +import { BOX, Eyebrow } from '../Planner/bookings/bookingParts' +import { SoftPill } from '../Planner/planParts' +import { hikeSourceBadgeLabel } from './tourPresentation' + +type Translate = (key: string, params?: Record) => 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 ( + + + + ) +} + +/** 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 ( +
    + }> + {formatDistance(tour.distance ?? 0, unit)} + + {tour.elevation_gain != null && ( + }> + {formatElevation(tour.elevation_gain, unit)} + + )} + {tour.elevation_loss != null && ( + }> + {formatElevation(tour.elevation_loss, unit)} + + )} + + + T{tour.max_hiking_difficulty} + + + {hikeSourceBadgeLabel(tour, t)} + {tour.caution && ( + + + }>{t('tours.caution.badge')} + + + )} +
    + ) +} + +/** "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) => { + if (disabled || !hasAvailableDay) return + if (menu.menu) { event.stopPropagation(); menu.close(); return } + menu.open(event, items, true) + } + return ( + <> + {variant === 'round' ? ( + + + + ) : ( + }> + {label} + + + )} + + + ) +} + +/** 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 ( +
    +
    + {label} + {action && {action}} +
    + {children} +
    + ) +} + +/** 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 ( +
    + {icon && {icon}} +
    {children}
    +
    + ) +} + +/** 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 ( + + + + ) +} + +/** + * 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 | 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 ( +
    + {fields.map(field => ( +
    +
    {field.label}
    +
    {field.value}
    +
    + ))} +
    + ) +} diff --git a/client/src/components/Tours/tourPresentation.test.ts b/client/src/components/Tours/tourPresentation.test.ts new file mode 100644 index 0000000000..31f7e209cb --- /dev/null +++ b/client/src/components/Tours/tourPresentation.test.ts @@ -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') + }) +}) diff --git a/client/src/components/Tours/tourPresentation.ts b/client/src/components/Tours/tourPresentation.ts new file mode 100644 index 0000000000..c26294ff80 --- /dev/null +++ b/client/src/components/Tours/tourPresentation.ts @@ -0,0 +1,22 @@ +import type { TourListItem } from '@trek/shared' + +export type TourSource = 'trek' | 'gpx' | 'wanderer' + +type Translate = (key: string, params?: Record) => 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): TourSource { + if (tour.wanderer_ref) return 'wanderer' + return tour.has_waypoints === false ? 'gpx' : 'trek' +} + +export function hikeSourceBadgeLabel( + tour: Pick, + t: Translate, +): string { + return t(`tours.badge.${tourSource(tour)}`) +} diff --git a/client/src/components/Tours/toursI18n.test.tsx b/client/src/components/Tours/toursI18n.test.tsx new file mode 100644 index 0000000000..ebf8e90503 --- /dev/null +++ b/client/src/components/Tours/toursI18n.test.tsx @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { within } from '@testing-library/react' +import en from '@trek/shared/i18n/en' +import de from '@trek/shared/i18n/de' +import fr from '@trek/shared/i18n/fr' +import type { TourListItem } from '@trek/shared' +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { useTranslation } from '../../i18n' +import { analyzeRouteGeometry } from '../../utils/routeGeometry' +import TourListRow from './TourListRow' +import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels' +import type { TourPlannerController } from './planner/useTourPlanner' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4, + elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: false, caution: false, +} + +function toursText(strings: typeof en, key: string): string { + const value = strings[key] + if (typeof value !== 'string') throw new Error(`Missing Tours text: ${key}`) + return value +} + +function planner(overrides: Partial): TourPlannerController { + 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() + expect(await screen.findByText(neutral)).toBeInTheDocument() + assertNoRawToursKeys() + + const waypoint = { id: 'first', lat: 48, lng: 11, role: 'start' as const } + rerender() + 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() + 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() + 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( {}} />) + await waitFor(() => expect(screen.getByLabelText(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument()) + const onAssignToDay = vi.fn() + rerender() + 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() + 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() + expect(screen.getByText(toursText(de, key))).toBeInTheDocument() + assertNoRawToursKeys() + } + rerender() + 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() + 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() + 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 {t('tours.planner.neutralTitle')} + } + render() + await waitFor(() => expect(document.documentElement.lang).toBe('fr')) + expect(await screen.findByText(toursText(fr, 'tours.planner.neutralTitle'))).toBeInTheDocument() + assertNoRawToursKeys() + }) +}) \ No newline at end of file diff --git a/client/src/components/Tours/toursPermissions.test.tsx b/client/src/components/Tours/toursPermissions.test.tsx new file mode 100644 index 0000000000..cd58dd6d23 --- /dev/null +++ b/client/src/components/Tours/toursPermissions.test.tsx @@ -0,0 +1,216 @@ +import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const state = vi.hoisted(() => ({ + trip: { id: 7, user_id: 1 } as { id: number; user_id: number } | null, + user: { id: 2, role: 'user' } as { id: number; role: string } | null, + importGpx: vi.fn(), + loadTrip: vi.fn(), + create: vi.fn(), + update: vi.fn(), + detail: vi.fn(), + route: vi.fn(), + elevation: vi.fn(), +})) + +vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip }) })) +vi.mock('../../store/authStore', () => ({ useAuthStore: (selector: (store: unknown) => unknown) => selector({ user: state.user }) })) +vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (selector: (store: unknown) => unknown) => selector({ settings: { distance_unit: 'metric' } }) })) +vi.mock('../../api/client', () => ({ toursApi: { importGpx: state.importGpx, create: state.create, update: state.update, detail: state.detail } })) +vi.mock('./planner/tourRouting', () => ({ routeWalkingTour: state.route, enrichTourElevations: state.elevation })) +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key, locale: 'en' }), translateApiError: () => 'error' })) +vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }) })) +vi.mock('../shared/ConfirmDialog', () => ({ default: () => null })) +vi.mock('../shared/EmptyState', () => ({ default: ({ action }: { action?: React.ReactNode }) =>
    {action}
    })) +vi.mock('../shared/CustomSelect', () => ({ default: ({ id, ariaLabel, disabled, value }: { id: string; ariaLabel: string; disabled: boolean; value: number }) => })) +vi.mock('../shared/ElevationProfile', () => ({ default: () => null })) +vi.mock('../shared/DetailShell', () => ({ default: ({ header, footer, children }: { header: React.ReactNode; footer: React.ReactNode; children: React.ReactNode }) =>
    {header}{children}{footer}
    })) +vi.mock('../shared/TrackColorPicker', () => ({ default: ({ onChange }: { onChange: (color: string) => void }) => })) +vi.mock('../shared/NavigationMenu', () => ({ NavigationMenu: () => null })) +vi.mock('../shared/markdownLink', () => ({ markdownLinkComponents: {} })) +vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn() })) +vi.mock('../Planner/placeNavigation', () => ({ getNavigationTargets: () => [], openNavigationTarget: vi.fn() })) + +import { usePermissionsStore } from '../../store/permissionsStore' +import { useTourPermissions } from './useTourPermissions' +import ToursSidebar from './ToursSidebar' +import { useTourPlanner } from './planner/useTourPlanner' +import type { TourPlannerController } from './planner/useTourPlanner' +import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels' +import TourDetailDialog from './TourDetailDialog' +import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList' + +const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem +const days = [{ id: 3, title: 'Summit' }] as never + +beforeEach(() => { + vi.clearAllMocks() + state.trip = { id: 7, user_id: 1 } + state.user = { id: 2, role: 'user' } + state.importGpx.mockResolvedValue({ tours: [tour] }) + usePermissionsStore.setState({ permissions: { place_edit: 'admin', day_edit: 'admin' } }) +}) +afterEach(() => { cleanup(); vi.useRealTimers(); localStorage.clear() }) + +describe('Tours permissions without database setup', () => { + it('revoking sidebar rights closes assignment choices and blocks file-input invocation', async () => { + const assign = vi.fn() + const props = { tripId: 7, days, tours: [tour], onAssignToDay: assign } + const { container, rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'tours.addToDay' })) + expect(screen.getByRole('button', { name: /Summit/ })).toBeTruthy() + rerender() + 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() + 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() + 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( 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(<>) + 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() + 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 }) + }) +}) \ No newline at end of file diff --git a/client/src/components/Tours/useTourPermissions.ts b/client/src/components/Tours/useTourPermissions.ts new file mode 100644 index 0000000000..726ee18c85 --- /dev/null +++ b/client/src/components/Tours/useTourPermissions.ts @@ -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)), + } +} \ No newline at end of file diff --git a/client/src/components/shared/ContextMenu.test.tsx b/client/src/components/shared/ContextMenu.test.tsx index 5f00397f62..4838ac09ad 100644 --- a/client/src/components/shared/ContextMenu.test.tsx +++ b/client/src/components/shared/ContextMenu.test.tsx @@ -79,4 +79,13 @@ describe('ContextMenu', () => { }); expect(onClose).toHaveBeenCalledOnce(); }); + + it('does not activate a disabled menu item', () => { + const onClick = vi.fn() + render() + const item = screen.getByRole('button', { name: 'Already assigned' }) + expect(item).toBeDisabled() + fireEvent.click(item) + expect(onClick).not.toHaveBeenCalled() + }) }); diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 3558a93989..6cdb6a22f9 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -6,6 +6,7 @@ interface MenuItem { label?: string icon?: LucideIcon onClick?: () => void + disabled?: boolean danger?: boolean divider?: boolean } @@ -84,15 +85,15 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { if (item.divider) return
    const Icon = item.icon return ( - } + onClose={onClose} + closeLabel="Close" + leftWidth={240} + rightWidth={320} + testId="detail-scroll" + > +

    Detail content

    + , + ) + + 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() + }) +}) diff --git a/client/src/components/shared/DetailShell.tsx b/client/src/components/shared/DetailShell.tsx new file mode 100644 index 0000000000..7dadde8fa1 --- /dev/null +++ b/client/src/components/shared/DetailShell.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import { X } from 'lucide-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 ( +
    +
    +
    + {header} + + + +
    +
    + {children} +
    + {footer && ( +
    + {footer} +
    + )} +
    +
    + ) +} diff --git a/client/src/components/shared/ElevationProfile.test.tsx b/client/src/components/shared/ElevationProfile.test.tsx new file mode 100644 index 0000000000..3a6d425f0d --- /dev/null +++ b/client/src/components/shared/ElevationProfile.test.tsx @@ -0,0 +1,157 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import ElevationProfile from './ElevationProfile' +import type { RouteProfileFocus } from '../../utils/routeGeometry' +import * as elevationProfileModel from './elevationProfileModel' + +const samples = [ + { distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 }, + { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01 }, + { distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03 }, +] + +function InteractiveProfile({ onFocus, samples: profileSamples = samples, externalFocus = null, height = 100 }: { + onFocus?: (focus: RouteProfileFocus | null) => void + samples?: typeof samples + externalFocus?: RouteProfileFocus | null + height?: number +}) { + const [focus, setFocus] = useState(null) + return { 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( + , + ) + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(prepared).toHaveBeenCalledTimes(1) + + rerender() + expect(prepared).toHaveBeenCalledTimes(2) + unmount() + }) +}) diff --git a/client/src/components/shared/ElevationProfile.tsx b/client/src/components/shared/ElevationProfile.tsx new file mode 100644 index 0000000000..33e25ec9aa --- /dev/null +++ b/client/src/components/shared/ElevationProfile.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { focusRouteProfileAtDistance, type DistanceIndexedProfileSample, type RouteProfileFocus } from '../../utils/routeGeometry' +import * as elevationProfileModel from './elevationProfileModel' + +interface ElevationProfileProps { + samples: DistanceIndexedProfileSample[] + color: string + height?: number + gradientId: string + ariaLabel?: string + focus?: RouteProfileFocus | null + onFocusChange?: (focus: RouteProfileFocus | null) => void + formatFocus?: (focus: RouteProfileFocus) => string +} + +/** A route profile whose horizontal axis represents travelled distance. */ +export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus }: ElevationProfileProps) { + const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('') + const pointerFrameRef = useRef(null) + const pendingPointerDistanceRef = useRef(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): 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 ( +
    + { 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} + > + + + + + + + + + {focusX != null && ( + + )} + + {interactive && focusX != null && ( +
    + {focusText} +
    + )} + {interactive && {keyboardAnnouncement}} +
    + ) +} diff --git a/client/src/components/shared/elevationProfileModel.ts b/client/src/components/shared/elevationProfileModel.ts new file mode 100644 index 0000000000..9fa8c60def --- /dev/null +++ b/client/src/components/shared/elevationProfileModel.ts @@ -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`, + } +} \ No newline at end of file diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index d2ed63272d..701d721113 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -22,6 +22,12 @@ export const SATELLITE_TILE_ATTRIBUTION = 'Imagery © Esri, 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: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' +export const OPENTOPOMAP_TILE_MAXZOOM = 17 + /** * The basemap for a browser that will not give MapLibre a WebGL context (#2288). * diff --git a/client/src/constants/tripTabs.ts b/client/src/constants/tripTabs.ts index 62e734fc63..b46c8e4ab4 100644 --- a/client/src/constants/tripTabs.ts +++ b/client/src/constants/tripTabs.ts @@ -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 = { plan: 'trip.tabs.plan', + 'tour-planner': 'trip.tabs.tourPlanner', roadtrip: 'roadtrip.title', transports: 'trip.tabs.transports', buchungen: 'trip.tabs.reservations', diff --git a/client/src/hooks/usePlannerHistory.ts b/client/src/hooks/usePlannerHistory.ts index 050cd3c0af..8803e895d6 100644 --- a/client/src/hooks/usePlannerHistory.ts +++ b/client/src/hooks/usePlannerHistory.ts @@ -5,14 +5,16 @@ export interface UndoEntry { undo: () => Promise | 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([]) const [, forceUpdate] = useReducer((x: number) => x + 1, 0) - const pushUndo = (label: string, undoFn: () => Promise | void, dayIds?: number[]) => { - historyRef.current = [{ label, undo: undoFn, dayIds }, ...historyRef.current].slice(0, maxEntries) + const pushUndo = (label: string, undoFn: () => Promise | 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 } } diff --git a/client/src/hooks/useRouteCalculation.b5.test.ts b/client/src/hooks/useRouteCalculation.b5.test.ts new file mode 100644 index 0000000000..541713fa57 --- /dev/null +++ b/client/src/hooks/useRouteCalculation.b5.test.ts @@ -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(), + 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]) + }) +}) \ No newline at end of file diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index e002dea331..a2efe0bec4 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -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([]) 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(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 } } diff --git a/client/src/hooks/useTourPlaceIds.test.ts b/client/src/hooks/useTourPlaceIds.test.ts new file mode 100644 index 0000000000..da8f7040ec --- /dev/null +++ b/client/src/hooks/useTourPlaceIds.test.ts @@ -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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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>>() + 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 + let secondRefresh!: Promise + 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) + }) +}) diff --git a/client/src/hooks/useTourPlaceIds.ts b/client/src/hooks/useTourPlaceIds.ts new file mode 100644 index 0000000000..b84dcb9b47 --- /dev/null +++ b/client/src/hooks/useTourPlaceIds.ts @@ -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 } | 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, + } +} diff --git a/client/src/hooks/useTripWebSocket.test.ts b/client/src/hooks/useTripWebSocket.test.ts new file mode 100644 index 0000000000..2247cd002f --- /dev/null +++ b/client/src/hooks/useTripWebSocket.test.ts @@ -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) => 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) => void) => listeners.add(listener)), + removeListener: vi.fn((listener: (event: Record) => 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): 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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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) + }) +}) \ No newline at end of file diff --git a/client/src/hooks/useTripWebSocket.ts b/client/src/hooks/useTripWebSocket.ts index 5a226de793..0a1d825120 100644 --- a/client/src/hooks/useTripWebSocket.ts +++ b/client/src/hooks/useTripWebSocket.ts @@ -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]) } diff --git a/client/src/mobile/components/MDancingTrek.tsx b/client/src/mobile/components/MDancingTrek.tsx index 7e0d99aa6e..2c938620ea 100644 --- a/client/src/mobile/components/MDancingTrek.tsx +++ b/client/src/mobile/components/MDancingTrek.tsx @@ -37,6 +37,7 @@ export type TrekScene = | 'costs' | 'search' | 'tasks' + | 'tours' export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error' @@ -87,6 +88,7 @@ export default function MDancingTrek({ + @@ -166,8 +168,52 @@ function Eyes({ mood }: { mood: TrekMood }) { `transform` attribute (a CSS-animation transform would override it) — any static rotation lives on a wrapping 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 ( + + {/* a small smile under the eyes, glad to be out walking */} + + {/* the pack, slung over the left side */} + + + {/* sleeping mat rolled on top, two straps around it */} + + + + {/* body of the pack */} + + {/* lid seam and buckle */} + + + {/* front pocket */} + + + + + + ) +} + 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 ( + + + + + + + + ) case 'transport': // skateboard under the body — deck + two wheels spinning in place return ( @@ -285,6 +331,21 @@ 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 ( + + + + + + + + + + + + ) // Files reuse the booking ticket per design — a card getting written on. case 'bookings': case 'files': diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index 83d0b7c758..cf38e9f964 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -251,6 +251,11 @@ @keyframes trek-magnifier { 0%, 100% { transform: translateX(-3px) rotate(-3deg); } 50% { transform: translateX(3px) rotate(3deg); } } /* to-do checkmark ticking on, holding, then resetting */ @keyframes trek-check { 0%, 8% { transform: scale(0); } 22% { transform: scale(1.2); } 32%, 88% { transform: scale(1); } 96%, 100% { 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(.34, 1.4, .64, 1) both; transform-origin: 50% 100%; } .trek-root { animation: trek-idle 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } @@ -265,6 +270,7 @@ .trek-bounce--idle { animation: trek-hop 4.6s ease-in-out infinite; } .trek-bounce--polls { animation: trek-nod 3s ease-in-out infinite; } .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; transform-box: fill-box; transform-origin: 50% 65%; } .trek-wheel { animation: trek-roll 1.1s linear infinite; transform-box: fill-box; transform-origin: center; } @@ -287,6 +293,9 @@ .trek-magnifier { animation: trek-magnifier 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } .trek-z { animation: trek-z 2.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } .trek-check { animation: trek-check 3s ease-in-out infinite; 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 .8s linear infinite; } /* staggered starts for written lines / poll bars / Zzz */ .trek-d2, .trek-z2 { animation-delay: .5s; } .trek-d3, .trek-z3 { animation-delay: 1s; } @@ -326,7 +335,7 @@ @media (prefers-reduced-motion: reduce) { .trek, .trek-root, .trek-body, .trek-shadow, .trek-eyes, .trek-pupils, - .trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport, + .trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport, .trek-bounce--tours, .trek-pole, .trek-pack, .trek-trail, .trek-board, .trek-wheel, .trek-flag, .trek-ticket, .trek-note, .trek-pencil, .trek-write, .trek-bar, .trek-suitcase, .trek-handle, .trek-pin, .trek-globe, .trek-coin, .trek-page, .trek-plane, .trek-magnifier, .trek-z, .trek-check, .trek-chain, .trek-link { animation: none; } diff --git a/client/src/mobile/screens/settings/MSettingsGeneral.tsx b/client/src/mobile/screens/settings/MSettingsGeneral.tsx index 59a0011b96..5b9d5cad7b 100644 --- a/client/src/mobile/screens/settings/MSettingsGeneral.tsx +++ b/client/src/mobile/screens/settings/MSettingsGeneral.tsx @@ -234,7 +234,7 @@ export default function MSettingsGeneral() { title={t('settings.startTripTab')} value={startTripTab} onSelect={(v) => save('start_trip_tab', v)} - options={TRIP_TAB_IDS.map((id) => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))} + options={TRIP_TAB_IDS.filter(id => id !== 'tour-planner').map((id) => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))} /> ) diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 17074abd7d..96d5746f58 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -286,6 +286,10 @@ export default function MTripShell({ const trTab = planner.activeTab + useEffect(() => { + if (trTab === 'tour-planner') planner.handleTabChange('plan') + }, [trTab, planner.handleTabChange]) + const setTrTab = (tabId: string) => { planner.handleTabChange(tabId) if (mode === 'browse') setMode('go') @@ -404,9 +408,10 @@ export default function MTripShell({ } if (!trip) return null - const enabledTabIds = new Set(planner.TRIP_TABS.map(tab => tab.id)) + const mobileTabs = planner.TRIP_TABS.filter(tab => !tab.desktopOnly) + const enabledTabIds = new Set(mobileTabs.map(tab => tab.id)) const dockTabs = pickDockTabs(enabledTabIds) - const tabLabel = (id: string) => planner.TRIP_TABS.find(tab => tab.id === id)?.label ?? id + const tabLabel = (id: string) => mobileTabs.find(tab => tab.id === id)?.label ?? id // While the trip runs, today's chip carries a ring and a button jumps back to it // from wherever the rail was scrolled (#2392). Null outside the trip's dates. diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index 19da6cdc4b..2b9a5b2f46 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -21,28 +21,38 @@ import { useTranslation } from '../../../../i18n' import type { Place } from '../../../../types' import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet' import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet' +import MPlacesToursModeSwitch from './MPlacesToursModeSwitch' +import MToursSelectionList from './MToursSelectionList' import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel' +import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds' import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls' import { countActivePlacesFilters } from '../../../../utils/placesFilter' /** * 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) @@ -64,9 +74,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). @@ -75,10 +83,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. @@ -122,10 +133,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 = () => { @@ -137,20 +149,38 @@ 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 (
    + {toursEnabled && ( + + )} + {toursEnabled && toursMode ? ( + <> + {/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select + buttons: tours are attached here, never created or bulk-managed. */} +
    + setToursFilter('all')} /> + setToursFilter('unplanned')} /> + setToursFilter('planned')} /> +
    + + + ) : ( + <> {/* ── Filter chips + Import (Add sits in the search row below to free space) ── */}
    - setCategoryPickerOpen(true)}> + 0} onClick={() => setCategoryPickerOpen(true)}> {collectionsEnabled && ( - setSaveToListOpen(true)}> + 0} onClick={() => setSaveToListOpen(true)}> )} {collectionsEnabled && ( 0 || markVisitedBusy} onClick={markSelectionVisited} > {markVisitedBusy ? : } )} - setConfirmDeleteOpen(true)}> + 0} onClick={() => setConfirmDeleteOpen(true)}>
    @@ -294,7 +324,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
    setSelectedIds(new Set())} />
    - +
    )} @@ -329,7 +359,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) return (
    + ) + })} +
    + ) +} diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx new file mode 100644 index 0000000000..baa2b169d0 --- /dev/null +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from '../../../../../tests/helpers/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import en from '@trek/shared/i18n/en' +import de from '@trek/shared/i18n/de' +import { resetAllStores, seedStore } from '../../../../../tests/helpers/store' +import { useSettingsStore } from '../../../../store/settingsStore' +import type { TripPlanner, MTripShellApi } from '../MTripShell' +import MToursSelectionList from './MToursSelectionList' + +const tour: TourListItem = { + place_id: 42, + name: 'Camping tour with a deliberately long readable name', + tour_type: 'hike', + distance: 12.5, + elevation_gain: 420, + elevation_loss: 390, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + has_waypoints: true, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +const reloadTourPlaceIds = vi.fn().mockResolvedValue(undefined) +const handlePlaceClick = vi.fn() +const openSheet = vi.fn() + +function planner(): TripPlanner { + return { + t: (key: string) => { + const value = en[key] + return typeof value === 'string' ? value : key + }, + tripId: 7, + tours: [tour], + selectedPlaceId: null, + selectedPlace: null, + selectedTour: null, + selectedDayId: null, + selectedAssignmentId: null, + assignments: {}, + days: [], + files: [], + canUploadFiles: false, + reloadTourPlaceIds, + handlePlaceClick, + } as unknown as TripPlanner +} + +function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi { + return { + sheet, + openSheet, + } as unknown as MTripShellApi +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) +}) + +describe('MToursSelectionList', () => { + it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => { + const germanPlanner = { ...planner(), t: (key: string) => { + const value = de[key] ?? en[key] + return typeof value === 'string' ? value : key + } } + const { rerender } = render() + + 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() + 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() + + 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() + 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() + 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() + + rerender() + + expect(reloadTourPlaceIds).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx new file mode 100644 index 0000000000..c711b74185 --- /dev/null +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx @@ -0,0 +1,121 @@ +import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucide-react' +import type { TourListItem } from '@trek/shared' +import { useSettingsStore } from '../../../../store/settingsStore' +import { formatDistance, formatElevation } from '../../../../utils/units' +import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation' +import type { TripPlanner, MTripShellApi } from '../MTripShell' +import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions' + +interface MToursSelectionListProps extends TourPermissionProps { + planner: TripPlanner + shell: MTripShellApi + /** All / Unplanned / Planned — same three states as the Places pool. */ + filter: 'all' | 'unplanned' | 'planned' +} + +/** + * Tours mode of the mobile places browser: a selection list that mirrors + * the desktop ToursSidebar's data, with viewing and day assignment only. + * This list does not provide GPX import. + * + * "Add to day" reuses the existing 'bract' place-actions sheet's day picker + * (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a + * Place under the hood, so no new linking code is needed here either. + */ +export default function MToursSelectionList({ planner, shell, filter, 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 ( + <> +

    + {t('tours.planner.mobileHint')} +

    +
    + +

    {t('tours.empty.title')}

    +

    {t('tours.empty.body')}

    +
    + + ) + } + + return ( + <> +

    + {t('tours.planner.mobileHint')} +

    +
      + {filtered.map(tour => { + const isSelected = planner.selectedPlaceId === tour.place_id + return ( +
    • 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)' }} + > +
      + {tour.name} +
      + + {hikeSourceBadgeLabel(tour, t)} + + T{tour.max_hiking_difficulty} + {tour.caution && ( + + + )} +
      +
      + + {formatDistance(tour.distance ?? 0, distanceUnit)} + + {tour.elevation_gain != null && ( + + + )} + {tour.elevation_loss != null && ( + + + )} +
      + +
      +
    • + ) + })} +
    + + ) +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index 84f921cd38..f4731ee19d 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -163,6 +163,11 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) { className="absolute left-4 right-4 overflow-x-hidden overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]" style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }} > + {planner.toursEnabled && tl.rows.some(row => row.kind === 'place' && row.invalidTour) && ( +
    + {t('tours.dayRoute.endpointUnknown')} +
    + )} {day && ( { 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) + }) }) diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index fcbb39dbf1..87696d0639 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -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 diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 3d65cfc1e0..768601bf01 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -82,8 +82,8 @@ export function useMPlanTimeline(planner: TripPlanner) { const rows = useMemo(() => { 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. diff --git a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx index c1cd48b990..18d5fae137 100644 --- a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx @@ -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()) useEffect(() => { setDaysOpen(open && Boolean(payload.dayPicker)) }, [open, payload.dayPicker]) // Hold the last place so the card content survives the exit animation. @@ -56,9 +58,20 @@ 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 = () => { @@ -84,13 +97,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps {t('mobileTrip.viewDetails')} - {canEditPlaces && ( + {canEditPlaces && !isTourPlace(place.id) && ( )} - {collectionsEnabled && ( + {collectionsEnabled && !isTourPlace(place.id) && (
    {place.description && ( @@ -362,33 +367,37 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { <> {t('inspector.trackColor')}
    - - {colorPickerOpen && ( -
    - -
    + {isTourPlace ? ( + + ) : ( + <> + + {colorPickerOpen && ( +
    + +
    + )} + )}
    @@ -403,7 +412,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { className={`flex items-center gap-1 rounded-full py-1 pl-[10px] text-[0.75rem] font-semibold ${INNER_CLS} ${canEditDays ? 'pr-1' : 'pr-[10px]'}`} > {day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })} - {canEditDays && place.lat != null && place.lng != null && ( + {canEditDays && !isTourPlace && place.lat != null && place.lng != null && ( // In or out of that day's route (#2532); the icon says which it is.
    @@ -882,7 +915,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { ) })()} - {selectedPlace && !isMobile && ( + {selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && !isMobile && ( )} - {selectedPlace && isMobile && createPortal( + {selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && isMobile && createPortal(
    setSelectedPlaceId(null)}>
    e.stopPropagation()}> 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( +
    setSelectedPlaceId(null)}> +
    event.stopPropagation()}> + 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} + /> +
    +
    , + document.body + )} + {mobileSidebarOpen && createPortal(
    setMobileSidebarOpen(null)}>
    e.stopPropagation()}> @@ -985,8 +1065,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
    {mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> - : handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> + : handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
    @@ -996,6 +1076,103 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
    )} + {activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && ( +
    + 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} + /> +
    + + + +
    + {!leftHidden && } + +
    +
    +
    + + + +
    + {!rightHidden && ( + + )} + {!rightHidden && ( + + handleAssignToDay(placeId, dayId)} + onDeleteTour={handleDeleteTour} + onViewGpxTour={tour => { + const place = places.find(item => item.id === tour.place_id) + tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null) + }} + /> + + )} +
    +
    +
    + )} + {activeTab === 'transports' && (
    @@ -1204,18 +1381,27 @@ function TripPlannerPageDesktop(): React.ReactElement | null { 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')} + message={deletePlaceIsTour + ? [t('tours.delete.confirmBody'), deletePlaceNote].filter(Boolean).join(' ') + : deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')} + confirmLabel={deletePlaceIsTour ? t('tours.delete.confirmAction') : undefined} /> setDeletePlaceIds(null)} onConfirm={confirmDeletePlaces} title={t('common.delete')} - message={deletePlacesNote - ? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}` - : t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} + message={deletePlacesIncludeTours + ? [t('tours.delete.bulkConfirmBody'), deletePlacesNote].filter(Boolean).join(' ') + : deletePlacesNote + ? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}` + : t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} + confirmLabel={deletePlacesIncludeTours ? t('tours.delete.confirmAction') : undefined} /> const captured: Record = {} +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) => { @@ -86,6 +103,39 @@ vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('book vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('airtrailImport') })) vi.mock('../components/Planner/ReservationsPanel', () => ({ default: stub('reservationsPanel', 'reservations-panel') })) vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') })) +vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') })) +vi.mock('../components/Tours/planner/useTourPlanner', () => ({ + useTourPlanner: () => ({ + route: null, + waypoints: plannerActions.waypoints, + routeProfileFocus: plannerActions.routeProfileFocus, + name: '', + startNewTour: 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') })) @@ -340,6 +390,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), @@ -355,6 +406,16 @@ function baseState(): HookState { selectedPlace: null, dayOrderMap: {}, dayPlaces: [], + toursEnabled: false, + toursMode: true, + setToursMode: vi.fn(), + tours: [], + toursLoading: false, + tourDataReady: true, + tourPlaceIds: new Set(), + reloadTourPlaceIds: vi.fn(async () => undefined), + upsertTour: vi.fn(), + selectedTour: null, mapTileUrl: 'https://tiles/{z}/{x}/{y}.png', fontStyle: { fontFamily: 'var(--font-system)' }, splashDone: true, @@ -374,17 +435,467 @@ function renderPage(overrides: HookState = {}) { return render() } +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('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('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() + + 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() + + 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() + + 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() + plannerActions.isSaving = true + view.rerender() + expect(props('map').onMapClick).toBeUndefined() + expect(screen.getByTestId('map-view')).toBeInTheDocument() + plannerActions.isSaving = false + view.rerender() + 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() + 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() + + 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() + 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() + 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() + 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() + + 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 => 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() + 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() + confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen) + const onConfirm = confirmation?.onConfirm as (() => void | Promise) | undefined + await act(async () => { await onConfirm?.() }) + expect(confirmDeletePlace).toHaveBeenCalledOnce() + expect(plannerActions.forgetDeletedTour).toHaveBeenCalledWith(savedTour.place_id) + view.rerender() + 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() + confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen) + const failedConfirm = confirmation?.onConfirm as (() => void | Promise) | undefined + await act(async () => { await failedConfirm?.() }) + expect(plannerActions.forgetDeletedTour).not.toHaveBeenCalled() + expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument() + view.rerender() + 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() + 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() + + 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 }) diff --git a/client/src/pages/tripPlanner/b4a-wiring.test.ts b/client/src/pages/tripPlanner/b4a-wiring.test.ts new file mode 100644 index 0000000000..dc51fa5a44 --- /dev/null +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -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/) +}) \ No newline at end of file diff --git a/client/src/pages/tripPlanner/useDayClear.ts b/client/src/pages/tripPlanner/useDayClear.ts index af146b88bb..25abc8fc1d 100644 --- a/client/src/pages/tripPlanner/useDayClear.ts +++ b/client/src/pages/tripPlanner/useDayClear.ts @@ -15,7 +15,8 @@ interface DayClearOptions { toast: { error: (message: string) => unknown } roadtripVias: Pick updateRouteForDay: (dayId: number) => void - pushUndo: (label: string, undoFn: () => Promise | void, dayIds?: number[]) => void + pushUndo: (label: string, undoFn: () => Promise | void, dayIds?: number[], placeIds?: number[]) => void + onToursChanged?: () => void | Promise } 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(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 } } diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index 33cf5c2556..13ba9abe86 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -10,15 +10,17 @@ import { useSettingsStore } from '../../store/settingsStore' import { usePermissionsStore } from '../../store/permissionsStore' import { usePluginStore } from '../../store/pluginStore' import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' +import { useAddonStore } from '../../store/addonStore' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation, buildBudgetItem } from '../../../tests/helpers/factories' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, - healthApi, airtrailApi, mapsApi, + healthApi, airtrailApi, mapsApi, toursApi, } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' import { offlineDb, saveImportFiles, getImportFiles } from '../../db/offlineDb' import { getCached, fetchPhoto } from '../../services/photoService' +import type { TourListItem } from '@trek/shared' import type { Accommodation, Place, Reservation, Settings } from '../../types' // ── Router ──────────────────────────────────────────────────────────────────── @@ -661,6 +663,30 @@ 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 })], @@ -1293,6 +1319,104 @@ 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() @@ -1303,14 +1427,39 @@ 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() - act(() => { result.current.handleDeletePlace(1) }) - await act(async () => { await result.current.confirmDeletePlace() }) + await waitFor(() => expect(result.current.tours).toEqual([listedTour])) + act(() => { result.current.handleDeleteTour(1) }) + let failedTourId: number | null = 1 + await act(async () => { 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 () => { @@ -1485,6 +1634,132 @@ 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 + 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(resolve => { resolveUndoRemoval = resolve }) + actions.removeAssignment.mockReturnValueOnce(undoRemoval) + let undoPromise!: Promise + 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(resolve => { resolveRemoval = resolve }) + actions.removeAssignment.mockReturnValueOnce(removal) + let removePromise!: Promise + 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 }) @@ -1542,17 +1817,25 @@ 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() await act(async () => { await result.current.handleRemoveAssignment(7, 10) }) expect(actions.removeAssignment).toHaveBeenCalledWith(42, 7, 10) + expect(actions.deletePlace).not.toHaveBeenCalled() + expect(actions.addPlace).not.toHaveBeenCalled() + 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() }) expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 4) @@ -2525,6 +2808,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() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 25b85e4c4d..e0e58d3f5b 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false }) + const [enabledAddons, setEnabledAddons] = useState>({ 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 = {} 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(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(0) const [deletePlaceId, setDeletePlaceId] = useState(null) const [deletePlaceIds, setDeletePlaceIds] = useState(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> 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 & { 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, diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts new file mode 100644 index 0000000000..e309b94bea --- /dev/null +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -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(null) + const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState(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 }, + } +} \ No newline at end of file diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 121ed11548..54c571d391 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -80,7 +80,7 @@ export const placeRepo = { return result }, - async delete(tripId: number | string, id: number | string): Promise { + 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 { + 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) { diff --git a/client/src/store/slices/placesSlice.test.ts b/client/src/store/slices/placesSlice.test.ts index 6ab305dea7..93a8a83693 100644 --- a/client/src/store/slices/placesSlice.test.ts +++ b/client/src/store/slices/placesSlice.test.ts @@ -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] }); diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 5782726780..bb6b94760e 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -15,8 +15,8 @@ export interface PlacesSlice { uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise setPlaceImageFromFile: (tripId: number | string, placeId: number, fileId: number) => Promise ratePlace: (tripId: number | string, placeId: number, rating: number | null) => Promise - deletePlace: (tripId: number | string, placeId: number) => Promise - deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise + 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) => Promise } @@ -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')) } diff --git a/client/src/utils/basemap.test.ts b/client/src/utils/basemap.test.ts index ee57591c11..082d63c907 100644 --- a/client/src/utils/basemap.test.ts +++ b/client/src/utils/basemap.test.ts @@ -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) + }) }) diff --git a/client/src/utils/routeGeometry.test.ts b/client/src/utils/routeGeometry.test.ts new file mode 100644 index 0000000000..a2346a5e01 --- /dev/null +++ b/client/src/utils/routeGeometry.test.ts @@ -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() + }) +}) diff --git a/client/src/utils/routeGeometry.ts b/client/src/utils/routeGeometry.ts new file mode 100644 index 0000000000..13c8066e82 --- /dev/null +++ b/client/src/utils/routeGeometry.ts @@ -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, + } +} diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index b705d0ff09..f373bda9b8 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -73,6 +73,7 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner tripActions: buildTripActions(), can: vi.fn(() => true), canUploadFiles: true, + isTourPlace: vi.fn(() => false), pushUndo: vi.fn(), undo: vi.fn(), diff --git a/client/tests/integration/hooks/useTripWebSocket.test.ts b/client/tests/integration/hooks/useTripWebSocket.test.ts index 6ea0ccbc63..9bd00202da 100644 --- a/client/tests/integration/hooks/useTripWebSocket.test.ts +++ b/client/tests/integration/hooks/useTripWebSocket.test.ts @@ -71,11 +71,22 @@ describe('useTripWebSocket', () => { expect(registeredFunctions).toContain(handler); }); - it('FE-HOOK-WS-006b: collab file sync listener is also registered (second addListener call)', () => { - const { unmount } = renderHook(() => useTripWebSocket(42)); - // Two listeners registered: handleRemoteEvent + collabFileSync - expect((wsMock.addListener as ReturnType).mock.calls.length).toBe(2); + it('FE-HOOK-WS-006b: collab file sync and Tours invalidation listeners are registered', () => { + const onToursChanged = vi.fn(); + const { unmount } = renderHook(() => useTripWebSocket(42, onToursChanged)); + const addListenerCalls = (wsMock.addListener as ReturnType).mock.calls; + // Listener order: handleRemoteEvent, collabFileSync, Tours invalidation. + expect(addListenerCalls).toHaveLength(3); + const toursInvalidation = addListenerCalls[2]?.[0]; + expect(toursInvalidation).toBeTypeOf('function'); + + act(() => { + toursInvalidation({ type: 'tours:changed', tripId: 42, placeIds: [101, 102] }); + toursInvalidation({ type: 'tours:changed', tripId: 43, placeIds: [201] }); + }); + expect(onToursChanged).toHaveBeenCalledExactlyOnceWith({ placeIds: [101, 102] }); unmount(); + expect(wsMock.removeListener).toHaveBeenCalledWith(toursInvalidation); }); it('FE-HOOK-WS-006c: collab file sync listener reacts to collab:note:deleted events', () => { diff --git a/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx b/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx index f44be1f3e2..7c10f7b8a0 100644 --- a/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx @@ -6,7 +6,7 @@ import { useSaveToCollectionStore } from '../../../../src/store/saveToCollection import type { Day, Place } from '../../../../src/types' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' -import { fireEvent, render, screen } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' // FE-MOB-BRACT-001 to FE-MOB-BRACT-013 // This sheet reads its labels from the real TranslationProvider, so the @@ -107,14 +107,57 @@ describe('MBrowseActionsSheet', () => { expect(screen.getByRole('button', { name: /Tokyo Bay/ })).toBeInTheDocument() }) - it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', () => { + it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', async () => { const { planner, shell } = setup({}, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } }) expect(screen.getByRole('button', { name: /Add to a day\?/ })).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('Thu, Apr 2')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ })) - expect(shell.closeSheet).toHaveBeenCalledTimes(1) expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2) + await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1)) + }) + + it('waits for the assignment handler before closing the mobile sheet', async () => { + let resolveAssignment!: () => void + const assignment = new Promise(resolve => { resolveAssignment = resolve }) + const { planner, shell } = setup({ handleAssignToDay: vi.fn(() => assignment.then(() => true)) }, { + sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } }, + }) + + fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ })) + expect(shell.closeSheet).not.toHaveBeenCalled() + expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2) + + await act(async () => { resolveAssignment(); await assignment }) + await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce()) + }) + + it('keeps the mobile sheet open when the assignment handler reports failure', async () => { + const { planner, shell } = setup({ handleAssignToDay: vi.fn().mockResolvedValue(false) }, { + sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } }, + }) + + fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ })) + await waitFor(() => expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2)) + + expect(shell.closeSheet).not.toHaveBeenCalled() + }) + + it('offers only view and day attachment for a dormant Tour in the generic Places pool', () => { + enableCollections() + const tourPlace = { ...PLACE, tour_place_id: PLACE.id } + const { planner } = setup({ + places: [tourPlace], + can: vi.fn(() => true), + isTourPlace: vi.fn((placeId: number) => placeId === PLACE.id), + }) + + expect(screen.getByRole('button', { name: /View details/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Delete/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Save to Collection/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Add to a day/ })).toBeInTheDocument() + expect(planner.handleDeletePlace).not.toHaveBeenCalled() }) it('FE-MOB-BRACT-011: numbers an untitled, undated day by its position when day_number is missing or zero', () => { diff --git a/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx b/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx new file mode 100644 index 0000000000..62e2fe57ec --- /dev/null +++ b/client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'; +import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet'; +import type { Place } from '../../../../src/types'; +import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'; +import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; + +function tourPlace(): Place { + return { + id: 42, + trip_id: 1, + name: 'Ridge walk', + address: null, + description: null, + category_id: null, + lat: 48, + lng: 11, + image_url: null, + google_place_id: null, + osm_id: null, + route_geometry: '[[48,11],[49,12]]', + route_color: null, + tour_place_id: 42, + } as unknown as Place; +} + +const days = [ + { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null }, + { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null }, + { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null }, +] as never; + +function mobilePlanner(overrides: Partial = {}) { + const place = tourPlace(); + return buildPlanner({ + places: [place], + days, + assignments: { '7': [{ id: 70, day_id: 7, place_id: place.id }] } as never, + isTourPlace: vi.fn((placeId: number) => placeId === place.id), + ...overrides, + } as Partial); +} + +function renderDayPicker(planner = mobilePlanner()) { + const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 42, dayPicker: true } } }); + const view = render(); + return { ...view, planner, shell }; +} + +describe('MBrowseActionsSheet Tour assignment picker', () => { + it('disables assigned Tour days, blocks repeat clicks, and refreshes on reopen', async () => { + resetAllStores(); + let finishAssignment!: (assigned: boolean) => void; + const handleAssignToDay = vi.fn( + () => + new Promise((resolve) => { + finishAssignment = resolve; + }) + ); + const planner = mobilePlanner({ handleAssignToDay }); + const { rerender, shell } = renderDayPicker(planner); + + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + + const dayOne = screen.getByRole('button', { name: /Day one/ }); + const dayTwo = screen.getByRole('button', { name: /Day two/ }); + expect(dayOne).toBeDisabled(); + expect(dayTwo).toBeEnabled(); + fireEvent.click(dayOne); + expect(handleAssignToDay).not.toHaveBeenCalled(); + + fireEvent.click(dayTwo); + fireEvent.click(dayTwo); + expect(handleAssignToDay).toHaveBeenCalledTimes(1); + expect(handleAssignToDay).toHaveBeenCalledWith(42, 8); + + await act(async () => { + finishAssignment(true); + }); + await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce()); + + const reopened = mobilePlanner({ + assignments: { + '7': [{ id: 70, day_id: 7, place_id: 42 }], + '8': [{ id: 80, day_id: 8, place_id: 42 }], + } as never, + }); + rerender(); + expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled(); + }); + + it('leaves ordinary Place day choices enabled and preserves their assignment write behavior', () => { + resetAllStores(); + const place = { ...tourPlace(), tour_place_id: undefined }; + const handleAssignToDay = vi.fn().mockResolvedValue(true); + const planner = mobilePlanner({ + places: [place], + isTourPlace: vi.fn(() => false), + assignments: { '7': [{ id: 70, day_id: 7, place_id: place.id }] } as never, + handleAssignToDay, + }); + renderDayPicker(planner); + const dayOne = screen.getByRole('button', { name: /Day one/ }); + expect(dayOne).toBeEnabled(); + fireEvent.click(dayOne); + expect(handleAssignToDay).toHaveBeenCalledWith(place.id, 7); + }); +}); diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index c089a3bcbd..a19bc80e88 100644 --- a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -279,6 +279,16 @@ describe('MPlaceEditSheet', () => { expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false) }) + it('requires explicit permanent confirmation when a dormant Tour is deleted from the Place editor', async () => { + const tour = { ...EDITED, tour_place_id: EDITED.id } as Place + const { planner } = setup({ editingPlace: tour, isTourPlace: vi.fn(() => true) }) + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) + + expect(planner.toast.warning).toHaveBeenCalledWith('tours.delete.confirmBody') + fireEvent.click(screen.getByRole('button', { name: 'tours.delete.confirmAction' })) + await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)) + }) + it('FE-MOB-PLEDIT-022: cancelling an armed delete un-stages the id', () => { const { planner } = setup({ editingPlace: EDITED }) fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx index 3224b5d3f4..20eea2e6df 100644 --- a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -104,6 +104,7 @@ function makePlanner(overrides: Record = {}) { addFile: vi.fn().mockResolvedValue(undefined), ratePlace: vi.fn().mockResolvedValue(undefined), }, + isTourPlace: vi.fn(() => false), can: vi.fn(() => true), reservations: [] as unknown[], TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']), diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index d13024adac..5ddad84d05 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -3,7 +3,8 @@ import { act, fireEvent, render, screen, waitFor, within } from '../../../helper import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import MPlacesBrowser from '../../../../src/mobile/screens/trip/places/MPlacesBrowser' import { collectionsApi } from '../../../../src/api/collections' -import type { CollectionListResponse } from '@trek/shared' +import { toursApi } from '../../../../src/api/client' +import type { CollectionListResponse, TourListItem } from '@trek/shared' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' import { resetAllStores, seedStore } from '../../../helpers/store' @@ -35,6 +36,7 @@ function place(over: Partial): Place { const LOUVRE = place({ id: 1, name: 'Louvre', address: 'Rue de Rivoli', category_id: 1 }) const EIFFEL = place({ id: 2, name: 'Eiffel Tower', description: 'Iron lady' }) const SEINE = place({ id: 3, name: 'Seine Track', category_id: 2, route_geometry: 'abc', route_color: '#ff0000' }) +const TOUR = place({ id: 4, name: 'Ridge walk', tour_place_id: 4 }) const PLACES = [LOUVRE, EIFFEL, SEINE] @@ -343,6 +345,51 @@ describe('MPlacesBrowser', () => { expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument() }) + it('routes a Tour row to read-only detail instead of mobile Place bulk management', () => { + const planner = makePlanner({ + places: [LOUVRE, TOUR], + isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id), + }) + renderBrowser(planner) + fireEvent.click(screen.getByRole('button', { name: 'common.select' })) + fireEvent.click(row('Louvre')) + fireEvent.click(row('Ridge walk')) + + expect(planner.handlePlaceClick).toHaveBeenCalledWith(TOUR.id) + expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'common.deselectAll' })).toBeInTheDocument() + }) + + it('keeps Tour-backed Places out of the Places pool and Tours mode browse/attach only', async () => { + const ridge: TourListItem = { + place_id: TOUR.id, 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, + } + seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }, { id: 'tours', enabled: true }] }) + vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [ridge] }) + const planner = makePlanner({ + places: [...PLACES, TOUR], + tours: [ridge], + isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id), + }) + const { shell } = renderBrowser(planner) + + await waitFor(() => expect(screen.getByText('places.count:3')).toBeInTheDocument()) + expect(screen.queryByText('Ridge walk')).not.toBeInTheDocument() + expect(toursApi.list).toHaveBeenCalledWith(planner.tripId) + + fireEvent.click(screen.getByRole('tab', { name: /^Tours$/i })) + expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument() + expect(screen.queryByText('Louvre')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /tours.addToDay|Add to day/i })).toBeInTheDocument() + }) + it('FE-MOB-PBROW-020b: a failed bulk delete keeps the selection for a retry', async () => { const planner = makePlanner() ;(planner.confirmDeletePlaces as ReturnType).mockRejectedValueOnce(new Error('nope')) diff --git a/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx b/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx index e6c71fdd73..cc237db439 100644 --- a/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx @@ -17,13 +17,14 @@ const TAGGED = buildPlace({ id: 1, category_id: 1 }) const BARE = buildPlace({ id: 2, category_id: null }) const TRACK = buildPlace({ id: 3, category_id: 2, route_geometry: '[[1,2],[3,4]]' }) -function renderSheet({ places = [TAGGED, BARE], categories = CATEGORIES, open = true }: { +function renderSheet({ places = [TAGGED, BARE], categories = CATEGORIES, open = true, toursEnabled = false }: { places?: Place[] categories?: Category[] open?: boolean + toursEnabled?: boolean } = {}) { const onClose = vi.fn() - render() + render() return { onClose } } @@ -53,6 +54,13 @@ describe('MPlacesFilterSheet', () => { expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument() }) + it('hides Tracks from the map filter while Tours is enabled', async () => { + const tourTrack = buildPlace({ id: 4, route_geometry: '[[5,6],[7,8]]' }) + renderSheet({ places: [TAGGED, TRACK, tourTrack], toursEnabled: true }) + await screen.findByRole('dialog', { name: 'Filters' }) + expect(within(group('Show')).queryByRole('button', { name: 'Tracks' })).not.toBeInTheDocument() + }) + it('FE-MOB-PFSHEET-003: picking a pool writes it to the trip store', async () => { renderSheet() await screen.findByRole('dialog', { name: 'Filters' }) diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx index d6205892d1..a7d7d10c20 100644 --- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx @@ -358,6 +358,33 @@ describe('MPlanTimeline', () => { }) }) + describe('Tours read-only timeline integration', () => { + const tour = { ...MUSEUM, id: 91, place_id: 901, + place: { ...MUSEUM.place, id: 901, name: 'Assigned Tour' }, tour_place_id: 901 } as Assignment + const item: MergedItem = { type: 'place', sortKey: 0, data: tour } + const row: PlanRow = { key: 'pl-91', kind: 'place', item, assignment: tour, linkedReservations: [], invalidTour: true } + + it('shows one localized invalid-endpoint warning and selects the Tour through the shared Place path', () => { + const { planner } = renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: true }) + + expect(screen.getAllByText('tours.dayRoute.endpointUnknown')).toHaveLength(1) + fireEvent.click(screen.getByText('Assigned Tour')) + expect(planner.handlePlaceClick).toHaveBeenCalledWith(901, 91) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('tour-planner')).not.toBeInTheDocument() + }) + + it('does not show the warning for valid status or with Tours disabled', () => { + const validRow: PlanRow = { ...row, invalidTour: false } + const first = renderTimeline({ rows: [validRow], merged: [item], upNext: null }, { toursEnabled: true }) + expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument() + first.unmount() + + renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false }) + expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument() + }) + }) + describe('per-segment travel mode', () => { /** The timeline only offers the mode menu in edit mode, where many buttons exist. */ const renderEditing = () => renderTimeline({}, {}, { mode: 'edit' }) diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx index 2c189a1c83..f68a09de90 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { BedDouble, MapPin } from 'lucide-react' import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types' import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel' +import type { TourListItem } from '@trek/shared' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { BudgetItem, Reservation, Trip } from '../../../../src/types' import { useAuthStore } from '../../../../src/store/authStore' @@ -187,7 +188,7 @@ const JOURNEY = { } as unknown as Reservation function renderHost(plannerOverrides: Partial = {}, shellOverrides: Partial = {}) { - const planner = buildPlanner(plannerOverrides) + const planner = buildPlanner({ isTourPlace: vi.fn(() => false), ...plannerOverrides }) const shell = buildShell(shellOverrides) render() return { planner, shell } @@ -430,6 +431,44 @@ describe('MTripSheets', () => { expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1') }) + it.each([[false, false], [true, false], [false, true], [true, true]])( + 'keeps the mobile Tour host View/Attach-only with place_edit=%s day_edit=%s', (placeEdit, dayEdit) => { + const mobilePlace = { + id: 42, trip_id: 1, name: 'Mobile ridge', lat: 48, lng: 11, + route_geometry: '[[48,11,500],[48.01,11.01,510]]', route_color: '#ff0000', + } as never + const mobileTour = { + place_id: 42, name: 'Mobile ridge', tour_type: 'hike', distance: 1, + elevation_gain: 10, elevation_loss: 0, duration: null, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: false, caution: false, + } as TourListItem + const readOnlyFile = { + id: 19, trip_id: 1, place_id: 42, filename: 'ridge.gpx', original_name: 'ridge.gpx', + file_size: 32, mime_type: 'application/gpx+xml', url: '/api/files/19', created_at: '2026-01-01', + } + const can = vi.fn((permission: string) => permission === 'place_edit' ? placeEdit : permission === 'day_edit' && dayEdit) + renderHost({ + selectedPlace: mobilePlace, + selectedTour: mobileTour, + selectedDayId: 7, + days: [{ id: 7, trip_id: 1, day_number: 1, title: 'Day one' } as never], + files: [readOnlyFile as never], + can: can as TripPlanner['can'], + canUploadFiles: true, + }) + + expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit track colour' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '1 files' })) + expect(screen.getByText('ridge.gpx')).toBeInTheDocument() + expect(Boolean(screen.queryByRole('button', { name: /Add to day/i }))).toBe(dayEdit) + }, + ) + it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => { const { planner } = renderHost({ deletePlaceId: 101 }) expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete') @@ -440,6 +479,17 @@ describe('MTripSheets', () => { expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null) }) + it('shows permanent Tour deletion language in the mobile confirmation', () => { + const { planner } = renderHost({ deletePlaceId: 101, isTourPlace: vi.fn(() => true) }) + const confirm = screen.getByTestId('stub-confirm') + + expect(confirm).toHaveAttribute('data-confirm', 'tours.delete.confirmAction') + expect(confirm).toHaveTextContent('tours.delete.confirmBody') + expect(confirm).not.toHaveTextContent('Undo') + fireEvent.click(screen.getByText('confirm delete')) + expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1) + }) + it('FE-MOB-SHOST-029: a night booked at the place is said before the yes, and only then', () => { // The server takes the night down with the place, and the booking and the // expense with the night. The planner builds the sentence; the sheet has to diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts index ea2f4873cf..ff8872b352 100644 --- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts +++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts @@ -9,7 +9,7 @@ import { getDisplayTimeForDay, getMergedItems, getTransportForDay, type MergedIt import { buildDayRouteRuns, hotelBookendOf, type DayRoutePoint } from '../../../../src/components/Map/dayRoutePlan' import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories' import type { - Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn, + Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn, } from '../../../../src/types' // FE-MOB-PTLM-001 to FE-MOB-PTLM-060 @@ -56,6 +56,23 @@ const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.or const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n }) const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r }) +function tourAssignment(id: number, order: number, geometry: [number, number][], currentGeometry = JSON.stringify(geometry)): Assignment { + const [lat, lng] = geometry[0] + const tourPlace = buildPlace({ id: id + 100, name: `Tour ${id}`, lat, lng, route_geometry: currentGeometry }) + return buildAssignment({ id, day_id: 2, order_index: order, place_id: tourPlace.id, place: tourPlace, + tour_place_id: tourPlace.id, tour_route_geometry: JSON.stringify(geometry) }) +} + +function tourRows(assignments: Assignment[], routeSegments: RouteSegment[], toursEnabled = true) { + return buildPlanRows({ + merged: assignments.map(placeItem), reservations: [], routeSegments, dayId: 2, toursEnabled, + places: assignments.flatMap(assignment => assignment.place ? [assignment.place as Place] : []), + }) +} + +const connectors = (rows: ReturnType) => + rows.flatMap(row => row.kind === 'conn' ? [row.seg] : []) + function accommodation(overrides: Partial): Accommodation { return { id: 1, @@ -269,6 +286,100 @@ describe('planTimelineModel — buildPlanRows', () => { // Only the first Museum → Park hop finds the single matching leg. expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1) }) + + it('uses only Tour boundary connectors and leaves saved geometry unrouted', () => { + const before = assignment(31, 0, place(301, 'Before', 48.1, 11.1)) + const tour = tourAssignment(32, 1, [[48.103, 11.103], [48.11, 11.11], [48.12, 11.12]]) + const after = assignment(33, 2, place(303, 'After', 48.13, 11.13)) + const incoming = seg([48.1, 11.1], [48.103, 11.103]) + const outgoing = seg([48.12, 11.12], [48.13, 11.13]) + const automaticTourRoute = seg([48.103, 11.103], [48.12, 11.12]) + const rows = tourRows([before, tour, after], [incoming, outgoing, automaticTourRoute]) + expect(connectors(rows)).toEqual([incoming, outgoing]) + expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place']) + expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, tour.id]) + }) + + it('uses the current Place projection when the saved assignment geometry has not refreshed', () => { + const before = assignment(35, 0, place(351, 'Before', 48.1, 11.1)) + const tour = tourAssignment(36, 1, [[48.103, 11.103], [48.12, 11.12]], JSON.stringify([[48.105, 11.105], [48.125, 11.125]])) + const after = assignment(37, 2, place(353, 'After', 48.13, 11.13)) + const currentIncoming = seg([48.1, 11.1], [48.105, 11.105]) + const currentOutgoing = seg([48.125, 11.125], [48.13, 11.13]) + const staleIncoming = seg([48.1, 11.1], [48.103, 11.103]) + expect(connectors(tourRows([before, tour, after], [currentIncoming, currentOutgoing, staleIncoming]))) + .toEqual([currentIncoming, currentOutgoing]) + }) + + it('connects first, last and consecutive Tours only at valid boundaries', () => { + const before = assignment(41, 0, place(401, 'Before', 48.1, 11.1)) + const first = tourAssignment(42, 1, [[48.11, 11.11], [48.12, 11.12]]) + const second = tourAssignment(43, 2, [[48.13, 11.13], [48.14, 11.14]]) + const after = assignment(44, 3, place(404, 'After', 48.15, 11.15)) + const segments = [ + seg([48.1, 11.1], [48.11, 11.11]), + seg([48.12, 11.12], [48.13, 11.13]), + seg([48.14, 11.14], [48.15, 11.15]), + ] + const firstToAfter = seg([48.12, 11.12], [48.15, 11.15]) + expect(connectors(tourRows([first, after], [firstToAfter]))).toEqual([firstToAfter]) + expect(connectors(tourRows([before, first], [segments[0]]))).toEqual([segments[0]]) + const rows = tourRows([before, first, second, after], segments) + expect(connectors(rows)).toEqual(segments) + expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, first.id, second.id]) + }) + + it('connects a loop Tour at its shared boundary without routing through its geometry', () => { + const before = assignment(51, 0, place(501, 'Before', 48.1, 11.1)) + const loop = tourAssignment(52, 1, [[48.11, 11.11], [48.12, 11.12], [48.11, 11.11]]) + const after = assignment(53, 2, place(503, 'After', 48.13, 11.13)) + const incoming = seg([48.1, 11.1], [48.11, 11.11]) + const outgoing = seg([48.11, 11.11], [48.13, 11.13]) + const inside = seg([48.11, 11.11], [48.11, 11.11]) + expect(connectors(tourRows([before, loop, after], [incoming, outgoing, inside]))).toEqual([incoming, outgoing]) + }) + + it('keeps excluded Tours visible and bridges surrounding Places as the central route builder does', () => { + const before = assignment(61, 0, place(601, 'Before', 48.1, 11.1)) + const tour = { ...tourAssignment(62, 1, [[48.11, 11.11], [48.12, 11.12]]), route_excluded: true } + const after = assignment(63, 2, place(603, 'After', 48.13, 11.13)) + const bridge = seg([48.1, 11.1], [48.13, 11.13]) + const rows = tourRows([before, tour, after], [bridge]) + expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'place']) + expect(connectors(rows)).toEqual([bridge]) + expect(rows.some(row => row.kind === 'place' && row.assignment.id === tour.id)).toBe(true) + }) + + it('exposes invalid-Tour warning state and never invents an outgoing endpoint', () => { + const before = assignment(71, 0, place(701, 'Before', 48.1, 11.1)) + const tour = tourAssignment(72, 1, [[48.11, 11.11], [48.12, 11.12]], 'not valid geometry') + const after = assignment(73, 2, place(703, 'After', 48.13, 11.13)) + const incoming = seg([48.1, 11.1], [48.11, 11.11]) + const falseOutgoing = seg([48.11, 11.11], [48.13, 11.13]) + const rows = tourRows([before, tour, after], [incoming, falseOutgoing]) + expect(connectors(rows)).toEqual([incoming]) + expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true }) + + const noPlaceAnchor = { ...tour, place: buildPlace({ id: tour.place!.id, name: 'Tour without anchor', lat: null, lng: null, route_geometry: 'not valid geometry' }) } + const noAnchorRows = tourRows([before, noPlaceAnchor, after], [seg([48.1, 11.1], [48.13, 11.13])]) + expect(connectors(noAnchorRows)).toEqual([]) + expect(noAnchorRows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true }) + }) + + it('keeps Tours-off routing on the legacy single Place anchor', () => { + const before = assignment(81, 0, place(801, 'Before', 48.1, 11.1)) + const tour = tourAssignment(82, 1, [[48.11, 11.11], [48.12, 11.12]]) + const after = assignment(83, 2, place(803, 'After', 48.13, 11.13)) + const rows = tourRows([before, tour, after], [ + seg([48.1, 11.1], [48.11, 11.11]), + seg([48.11, 11.11], [48.13, 11.13]), + ], false) + expect(connectors(rows).map(item => [item.from, item.to])).toEqual([ + [[48.1, 11.1], [48.11, 11.11]], + [[48.11, 11.11], [48.13, 11.13]], + ]) + expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: false }) + }) }) describe('planTimelineModel — hotel chips and legs', () => { diff --git a/client/tests/unit/slices/assignmentsSlice.test.ts b/client/tests/unit/slices/assignmentsSlice.test.ts index 4a3c62cca1..eed11a9f54 100644 --- a/client/tests/unit/slices/assignmentsSlice.test.ts +++ b/client/tests/unit/slices/assignmentsSlice.test.ts @@ -247,15 +247,19 @@ describe('assignmentsSlice', () => { }); it('FE-ASSIGN-004b: removeAssignment success removes from store', async () => { - const place = buildPlace({ id: 10, trip_id: 1 }); + const place = buildPlace({ id: 10, trip_id: 1, tour_place_id: 10, route_geometry: '[[1,2],[3,4]]' }); const assignment = buildAssignment({ id: 100, day_id: 1, place }); + const otherDayAssignment = buildAssignment({ id: 101, day_id: 2, place }); seedStore(useTripStore, { - assignments: { '1': [assignment] }, + places: [place], + assignments: { '1': [assignment], '2': [otherDayAssignment] }, }); await useTripStore.getState().removeAssignment(1, 1, 100); expect(useTripStore.getState().assignments['1']).toHaveLength(0); + expect(useTripStore.getState().places).toContainEqual(expect.objectContaining({ id: place.id, route_geometry: place.route_geometry })); + expect(useTripStore.getState().assignments['2']).toEqual([otherDayAssignment]); }); }); diff --git a/client/vite.config.js b/client/vite.config.js index a14484031a..833c50c78f 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -141,6 +141,17 @@ export default defineConfig(({ mode }) => ({ cacheableResponse: { statuses: [0, 200] }, }, }, + { + // Tours-only OpenTopoMap raster tiles. Same bounded cache as the + // other Leaflet sources; opaque tile responses are valid here. + urlPattern: /^https:\/\/(?:[a-c]\.)?tile\.opentopomap\.org\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'map-tiles', + expiration: { maxEntries: 1000, maxAgeSeconds: 60 * 60 * 24 * 30 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, { // Amap road and satellite presets, served from the shard hosts under // is.autonavi.com (src/constants/mapDefaults.ts). Without a rule here diff --git a/server/src/addons.ts b/server/src/addons.ts index 4337eec319..c1a12cc39f 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -12,6 +12,7 @@ export const ADDON_IDS = { LLM_PARSING: 'llm_parsing', COLLECTIONS: 'collections', ROADTRIP: 'roadtrip', + TOURS: 'tours', } as const; export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS]; diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index e3859062f2..b20e4d807e 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -979,3 +979,19 @@ export function coalesceOverrideWhileSame(platform: Platform, value: number | nu return unsupported(platform); } + +// --------------------------------------------------------------------------- +// Tours (#2586): `DayAssignmentsRepository`'s assignment projection carries a +// place's route geometry only when the place is a Tour, so the day list never +// hauls an ordinary track's geometry along with every stop. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN IS NOT NULL THEN END`, for use as a + * `.select()` value (chain `.as(aliasName)` at the call site). The missing + * ELSE is deliberate: a null test column yields SQL NULL. + */ +export function caseWhenNotNull(platform: Platform, testRef: string, thenRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`CASE WHEN ${column(testRef)} IS NOT NULL THEN ${column(thenRef)} END`); + return unsupported(platform); +} diff --git a/server/src/db/entities/Places.entity.ts b/server/src/db/entities/Places.entity.ts index 2f867ab74f..6bb49853a4 100644 --- a/server/src/db/entities/Places.entity.ts +++ b/server/src/db/entities/Places.entity.ts @@ -14,6 +14,7 @@ import { PlaceRegions } from './PlaceRegions.entity'; import { Reservations } from './Reservations.entity'; import { RoadtripDayTracks } from './RoadtripDayTracks.entity'; import { Tags } from './Tags.entity'; +import { Tours } from './Tours.entity'; import { TripFiles } from './TripFiles.entity'; import { Trips } from './Trips.entity'; @@ -67,6 +68,7 @@ export class Places { place_regions: Ref | null = null; reservations_collection = new Collection(this); roadtrip_day_tracks_collection = new Collection(this); + tours: Ref | null = null; trip_files_collection = new Collection(this); } @@ -122,6 +124,7 @@ export const PlacesSchema = defineEntity({ place_regions: () => p.oneToOne(PlaceRegions).ref().mappedBy('place').hidden(), reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(), roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(), + tours: () => p.oneToOne(Tours).ref().mappedBy('place').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(), }, }); diff --git a/server/src/db/entities/TourTypes.entity.ts b/server/src/db/entities/TourTypes.entity.ts new file mode 100644 index 0000000000..abf6573b52 --- /dev/null +++ b/server/src/db/entities/TourTypes.entity.ts @@ -0,0 +1,33 @@ +import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { TourTypesRepository } from '../repositories/TourTypes.repository'; +import { Tours } from './Tours.entity'; + +export class TourTypes { + [EntityRepositoryType]?: TourTypesRepository; + [PrimaryKeyProp]?: 'key'; + key?: string | null; + label_key!: string; + icon!: string; + color!: string; + routing_profile?: string | null; + is_sport: number & Opt = 1; + enabled: number & Opt = 1; + sort_order: number & Opt = 0; + tours_collection = new Collection(this); +} + +export const TourTypesSchema = defineEntity({ + class: TourTypes, + repository: () => TourTypesRepository, + properties: { + key: p.text().primary().nullable(), + label_key: p.text(), + icon: p.text(), + color: p.text(), + routing_profile: p.text().nullable(), + is_sport: p.integer().default(1), + enabled: p.integer().default(1), + sort_order: p.integer().default(0), + tours_collection: () => p.oneToMany(Tours).mappedBy('tourTypeRef').hidden(), + }, +}); diff --git a/server/src/db/entities/TourWaypoints.entity.ts b/server/src/db/entities/TourWaypoints.entity.ts new file mode 100644 index 0000000000..0f6a6ab802 --- /dev/null +++ b/server/src/db/entities/TourWaypoints.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TourWaypointsRepository } from '../repositories/TourWaypoints.repository'; +import { Tours } from './Tours.entity'; + +export class TourWaypoints { + [EntityRepositoryType]?: TourWaypointsRepository; + id!: number & Opt; + place!: Ref; + place_id!: number; + lat!: number; + lng!: number; + role!: string; + sequence!: number; +} + +export const TourWaypointsSchema = defineEntity({ + class: TourWaypoints, + repository: () => TourWaypointsRepository, + uniques: [{ properties: ['place', 'sequence'] }], + properties: { + id: p.integer().primary(), + place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(), + place_id: p.integer().persist(false), + lat: p.double(), + lng: p.double(), + role: p.text(), + sequence: p.integer(), + }, +}); diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts new file mode 100644 index 0000000000..beedbaad70 --- /dev/null +++ b/server/src/db/entities/Tours.entity.ts @@ -0,0 +1,47 @@ +import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ToursRepository } from '../repositories/Tours.repository'; +import { Places } from './Places.entity'; +import { TourTypes } from './TourTypes.entity'; +import { TourWaypoints } from './TourWaypoints.entity'; + +export class Tours { + [EntityRepositoryType]?: ToursRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + tour_type!: string; + distance?: number | null; + elevation_gain?: number | null; + elevation_loss?: number | null; + duration?: number | null; + difficulty?: string | null; + wanderer_ref?: string | null; + match_confidence?: number | null; + tour_group_id?: number | null; + created_at?: string | null; + max_hiking_difficulty: number & Opt = 2; + tourTypeRef!: Ref; + tour_waypoints_collection = new Collection(this); +} + +export const ToursSchema = defineEntity({ + class: Tours, + repository: () => ToursRepository, + properties: { + place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + tour_type: p.text().persist(false).index('idx_tours_tour_type'), + distance: p.double().nullable(), + elevation_gain: p.double().nullable(), + elevation_loss: p.double().nullable(), + duration: p.double().nullable(), + difficulty: p.text().nullable(), + wanderer_ref: p.text().nullable(), + match_confidence: p.double().nullable(), + tour_group_id: p.integer().nullable(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + max_hiking_difficulty: p.integer().default(2), + tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), + tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(), + }, +}); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index adde2a392b..60958f950f 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -101,6 +101,9 @@ import { ShareTokensSchema } from './ShareTokens.entity'; import { TagsSchema } from './Tags.entity'; import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity'; import { TodoItemsSchema } from './TodoItems.entity'; +import { TourTypesSchema } from './TourTypes.entity'; +import { TourWaypointsSchema } from './TourWaypoints.entity'; +import { ToursSchema } from './Tours.entity'; import { TrekPhotoCacheMetaSchema } from './TrekPhotoCacheMeta.entity'; import { TrekPhotosSchema } from './TrekPhotos.entity'; import { TripAlbumLinksSchema } from './TripAlbumLinks.entity'; @@ -230,6 +233,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ TagsSchema, TodoCategoryAssigneesSchema, TodoItemsSchema, + TourTypesSchema, + TourWaypointsSchema, + ToursSchema, TrekPhotoCacheMetaSchema, TrekPhotosSchema, TripAlbumLinksSchema, diff --git a/server/src/db/migrations/Migration20200101042000_tours.ts b/server/src/db/migrations/Migration20200101042000_tours.ts new file mode 100644 index 0000000000..585a65e511 --- /dev/null +++ b/server/src/db/migrations/Migration20200101042000_tours.ts @@ -0,0 +1,71 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Tours: a facet on `places` for hikes and other single-day routes. + * + * `tours` is keyed on the owning place, so deleting the place drops the facet + * and, through it, the ordered `tour_waypoints` the route editor saved. + * `tour_types` is the controlled vocabulary the facet points at; the `hike` + * row is reference data that FK needs, so it is written here rather than left + * to a seeder. + * + * Every statement is guarded: a test instance that ran the pre-ORM Tours + * branch already holds these tables and its rows must survive. That branch + * also created `idx_tour_waypoints_place`, which duplicates the autoindex + * behind `UNIQUE(place_id, sequence)`, so it is dropped to converge on the + * fresh-install shape. + */ +export class Migration20200101042000_tours extends Migration { + override name = 'Migration20200101042000_tours'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_types ( + key TEXT PRIMARY KEY, + label_key TEXT NOT NULL, + icon TEXT NOT NULL, + color TEXT NOT NULL, + routing_profile TEXT, + is_sport INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + this.addSql(` + INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) + VALUES ('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tours ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + tour_type TEXT NOT NULL REFERENCES tour_types(key), + distance REAL, + elevation_gain REAL, + elevation_loss REAL, + duration REAL, + difficulty TEXT, + wanderer_ref TEXT, + match_confidence REAL, + tour_group_id INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6) + ) + `); + this.addSql('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)'); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_waypoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, + lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), + lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180), + role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), + sequence INTEGER NOT NULL CHECK(sequence >= 0), + UNIQUE(place_id, sequence) + ) + `); + this.addSql('DROP INDEX IF EXISTS idx_tour_waypoints_place'); + } +} diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts index ca893d5c15..c34afb8330 100644 --- a/server/src/db/repositories/DayAssignments.repository.ts +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -1,6 +1,6 @@ import type { Platform } from '@mikro-orm/core'; import type { DayAssignments } from '../entities/DayAssignments.entity'; -import { coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions'; +import { caseWhenNotNull, coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions'; import { type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; @@ -16,9 +16,12 @@ import { TrekRepository } from './_shared/trek-repository'; * p.duration_minutes, p.notes as place_notes, * p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, * p.website, p.phone, p.stop_type, p.fill_percent, + * t.place_id AS tour_place_id, + * CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry, * c.name as category_name, c.color as category_color, c.icon as category_icon * FROM day_assignments da * JOIN places p ON da.place_id = p.id + * LEFT JOIN tours t ON t.place_id = p.id * LEFT JOIN categories c ON p.category_id = c.id * WHERE * [ORDER BY da.order_index ASC, da.created_at ASC] @@ -58,6 +61,10 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow { phone: string | null; stop_type: string | null; fill_percent: number | null; + /** The place's id when it is a Tour (its `tours` facet row), else null. */ + tour_place_id: number | null; + /** The place's `route_geometry`, but only for a Tour; null for every other place. */ + tour_route_geometry: string | null; category_name: string | null; category_color: string | null; category_icon: string | null; @@ -66,11 +73,13 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow { /** * {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) — * {@link AssignmentWithPlaceRow} minus the six columns a public share link - * must not leak. + * must not leak, and minus the two Tour facet columns the share projection + * never selected. */ export type SharePublicAssignmentRow = Omit< AssignmentWithPlaceRow, 'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent' + | 'tour_place_id' | 'tour_route_geometry' >; /** @@ -266,6 +275,10 @@ export class DayAssignmentsRepository extends TrekRepository { 'p.phone', 'p.stop_type', 'p.fill_percent', + // `place_id` on `Tours` is the `persist(false)` twin of its primary-key + // relation, the same trap as `category_id` above, so `columnRef` again. + columnRef(platform, 't.place_id').as('tour_place_id'), + caseWhenNotNull(platform, 't.place_id', 'p.route_geometry').as('tour_route_geometry'), 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', @@ -280,6 +293,7 @@ export class DayAssignmentsRepository extends TrekRepository { const platform = this.getEntityManager().getPlatform(); const row = await this.qb('da') .join('da.place', 'p') + .leftJoin('p.tours', 't') .leftJoin('p.category', 'c') .select(this.assignmentWithPlaceSelect(platform)) .where({ 'da.id': id }) @@ -296,6 +310,7 @@ export class DayAssignmentsRepository extends TrekRepository { const platform = this.getEntityManager().getPlatform(); return await this.qb('da') .join('da.place', 'p') + .leftJoin('p.tours', 't') .leftJoin('p.category', 'c') .select(this.assignmentWithPlaceSelect(platform)) .where({ 'da.day': day_id }) @@ -313,6 +328,7 @@ export class DayAssignmentsRepository extends TrekRepository { const platform = this.getEntityManager().getPlatform(); return await this.qb('da') .join('da.place', 'p') + .leftJoin('p.tours', 't') .leftJoin('p.category', 'c') .select(this.assignmentWithPlaceSelect(platform)) .where({ 'da.day': { $in: day_ids } }) @@ -516,6 +532,20 @@ export class DayAssignmentsRepository extends TrekRepository { return row?.day_id; } + /** + * `moveAssignment`'s Tour check: `SELECT place_id FROM day_assignments + * WHERE id = ?`, `columnRef` for the same `persist(false)` reason as + * {@link getDayId} above. + */ + async getPlaceId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .select([columnRef(platform, 'da.place_id').as('place_id')]) + .where({ id }) + .execute<{ place_id: number } | undefined>('get', false); + return row?.place_id; + } + /** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */ async moveToDay(id: number, day_id: number, order_index: number): Promise { await this.nativeUpdate({ id }, { day: day_id, order_index }); diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index 3cb1695eb3..668ffb1d7d 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -59,6 +59,8 @@ export interface PlaceWithCategoryRow extends PlaceRow { export interface PlaceListRow extends PlaceWithCategoryRow { country_code: string | null; region_name: string | null; + /** The place's own id when it is a Tour (it has a `tours` facet row), else null. */ + tour_place_id: number | null; } /** {@link PlacesRepository.findActiveTripFile}'s narrow `trip_files` shape. */ @@ -298,6 +300,22 @@ export class PlacesRepository extends TrekRepository { .execute<{ google_place_id: string | null; image_url: string | null } | undefined>('get', false); } + /** + * `SELECT 1 FROM tours WHERE place_id = ?`, read through the place's + * `tours` facet so the Places domain answers it without the Tours + * repository. `remove`/`removeMany` call it inside their delete + * transaction to report which deleted places were Tours; the facet row and + * its waypoints go with the place through `ON DELETE CASCADE`. + */ + async isTour(id: number): Promise { + const row = await this.qb('p') + .join('p.tours', 't') + .select(['p.id']) + .where({ 'p.id': id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + /** PL19/PL21 — `DELETE FROM places WHERE id = ?`, unscoped: every caller proved trip access first. */ async deleteById(id: number): Promise { await this.nativeDelete({ id }); @@ -645,12 +663,18 @@ export class PlacesRepository extends TrekRepository { tag?: string; assignment?: 'all' | 'unassigned' | 'assigned'; }): Promise { + const platform = this.getEntityManager().getPlatform(); const qb = this.qb('p') .leftJoin('p.category', 'c') // #2537: the atlas' cached region rides along (`LEFT JOIN place_regions pr ON pr.place_id = p.id`). .leftJoin('p.place_regions', 'pr') + // Tours: `LEFT JOIN tours t ON t.place_id = p.id` marks the Tour-backed + // places. `columnRef`, because the bare `t.place_id` twin is + // persist(false) and selects nothing. + .leftJoin('p.tours', 't') .select([ - 'p.*', 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + 'p.*', columnRef(platform, 't.place_id').as('tour_place_id'), + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', 'pr.country_code as country_code', 'pr.region_name as region_name', ], true) .where('p.trip_id = ?', [trip_id]); @@ -1301,6 +1325,50 @@ export class PlacesRepository extends TrekRepository { .execute(); return rows as PublicApiAssignedPlaceRow[]; } + + /** + * TO10 (`ToursService.createTour`) — `INSERT INTO places (trip_id, name, + * lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, 'walking', + * ?)`. The place a drawn tour lives on starts at the route's first point; + * every other column keeps its default. Returns the generated id. + */ + async insertTourPlace(input: { trip_id: number; name: string; lat: number; lng: number; route_geometry: string }): Promise { + return await this.insert({ + trip: input.trip_id, + name: input.name, + lat: input.lat, + lng: input.lng, + transport_mode: 'walking', + route_geometry: input.route_geometry, + }); + } + + /** + * TO11 (`ToursService.updateTour`) — `UPDATE places SET name = ?, lat = ?, + * lng = ?, transport_mode = 'walking', route_geometry = ?, updated_at = + * CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. Returns whether a place + * of that trip was updated, so a tour deleted or moved between the check + * and the write answers 404 instead of writing nothing. + */ + async updateTourRoute( + id: number, + trip_id: number, + input: { name: string; lat: number; lng: number; route_geometry: string }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + const updated = await this.nativeUpdate( + { id, trip: trip_id }, + { + name: input.name, + lat: input.lat, + lng: input.lng, + transport_mode: 'walking', + route_geometry: input.route_geometry, + updated_at: currentTimestamp(platform), + }, + ); + return updated > 0; + } } /** The `places` columns {@link PlacesRepository.listAssignedForPublicApi} selects, typed off {@link PlaceRow}. */ diff --git a/server/src/db/repositories/TourTypes.repository.ts b/server/src/db/repositories/TourTypes.repository.ts new file mode 100644 index 0000000000..49c5c93147 --- /dev/null +++ b/server/src/db/repositories/TourTypes.repository.ts @@ -0,0 +1,9 @@ +import type { TourTypes } from '../entities/TourTypes.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `tour_types` — the controlled vocabulary `tours.tour_type` points at. Only + * `hike` exists, written by the migration that creates the table; nothing + * reads the catalogue yet, so this repository has no methods of its own. + */ +export class TourTypesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts new file mode 100644 index 0000000000..b5112ab448 --- /dev/null +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -0,0 +1,70 @@ +import type { TourWaypoints } from '../entities/TourWaypoints.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** One saved routing control point of a tour, in route order. */ +export interface TourWaypointRow { + lat: number; + lng: number; + role: 'start' | 'via' | 'end'; + sequence: number; +} + +/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */ +export interface TourWaypointWithPlaceRow extends TourWaypointRow { + place_id: number; +} + +interface TourWaypointsKyselyDB { + tour_waypoints: { id: number; place_id: number; lat: number; lng: number; role: TourWaypointRow['role']; sequence: number }; + places: { id: number; trip_id: number }; +} + +/** + * `tour_waypoints` — the start, via and end points the route editor saved + * for a tour, so reopening it restores the controls rather than only the + * derived line. Rows hang off `tours(place_id)` and go with the tour. + */ +export class TourWaypointsRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ + async listForPlace(place_id: number): Promise { + return await this.db() + .selectFrom('tour_waypoints') + .select(['lat', 'lng', 'role', 'sequence']) + .where('place_id', '=', place_id) + .orderBy('sequence', 'asc') + .execute(); + } + + /** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */ + async listForTrip(trip_id: number): Promise { + return await this.db() + .selectFrom('tour_waypoints as tw') + .innerJoin('places as p', 'p.id', 'tw.place_id') + .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence']) + .where('p.trip_id', '=', trip_id) + .orderBy('tw.place_id', 'asc') + .orderBy('tw.sequence', 'asc') + .execute(); + } + + /** + * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) + * VALUES (...)` for each point. The caller owns the transaction: a create + * writes the tour first, an edit clears the old points first. + */ + async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise { + if (waypoints.length === 0) return; + await this.insertMany( + waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })), + ); + } + + /** TW4 — `DELETE FROM tour_waypoints WHERE place_id = ?`. */ + async deleteForPlace(place_id: number): Promise { + await this.nativeDelete({ place: place_id }); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts new file mode 100644 index 0000000000..7e3b34acfb --- /dev/null +++ b/server/src/db/repositories/Tours.repository.ts @@ -0,0 +1,221 @@ +import type { AssertRowKeys } from './_shared/rows'; +import type { Tours } from '../entities/Tours.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A tour as the Tours list and detail read it: the `tours` facet joined with + * its owning place's name, plus two read-model flags (`planned`: the place + * sits on at least one day; `has_waypoints`: the route editor saved control + * points for it). SQLite hands both flags back as 0/1. + */ +export interface TourListRow { + place_id: number; + name: string; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + difficulty: string | null; + wanderer_ref: string | null; + match_confidence: number | null; + tour_group_id: number | null; + max_hiking_difficulty: number; + planned: number; + has_waypoints: number; +} + +/** Every `tours` column, as the trip copy reads and re-inserts it. */ +export interface TourRow { + place_id: number; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + difficulty: string | null; + wanderer_ref: string | null; + match_confidence: number | null; + tour_group_id: number | null; + created_at: string | null; + max_hiking_difficulty: number; +} +const _tourRowKeys: AssertRowKeys = true; + +/** What a create or a GPX import writes; the unset metadata columns stay NULL. */ +export interface TourInsert { + place_id: number; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + match_confidence: number | null; + max_hiking_difficulty: number; +} + +/** What a route edit replaces on an existing tour. */ +export type TourUpdate = Omit; + +interface ToursKyselyDB { + tours: TourRow; + places: { id: number; trip_id: number; name: string }; + day_assignments: { id: number; day_id: number; place_id: number }; + tour_waypoints: { id: number; place_id: number }; +} + +/** + * `tours` — the facet that makes a place a tour (#2586). Keyed on the owning + * place, so it carries no trip of its own: every trip-scoped statement here + * joins `places` for the predicate. Deleting the place cascades the row and + * its `tour_waypoints`. + * + * Kysely throughout: the list projection needs two correlated `EXISTS` + * subqueries, and the trip-scoped update and the day check join tables the + * entity has no relation path to. + */ +export class ToursRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** The shared SELECT of {@link listForTrip} and {@link findInTrip}. */ + private listQuery() { + return this.db() + .selectFrom('tours as t') + .innerJoin('places as p', 'p.id', 't.place_id') + .select((eb) => [ + 'p.id as place_id', + 'p.name as name', + 't.tour_type as tour_type', + 't.distance as distance', + 't.elevation_gain as elevation_gain', + 't.elevation_loss as elevation_loss', + 't.duration as duration', + 't.difficulty as difficulty', + 't.wanderer_ref as wanderer_ref', + 't.match_confidence as match_confidence', + 't.tour_group_id as tour_group_id', + 't.max_hiking_difficulty as max_hiking_difficulty', + eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')).as('planned'), + eb.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id')).as('has_waypoints'), + ]); + } + + /** + * TO1 — `SELECT p.id AS place_id, p.name, t.*, EXISTS(day_assignments) AS + * planned, EXISTS(tour_waypoints) AS has_waypoints FROM tours t JOIN places + * p ON p.id = t.place_id WHERE p.trip_id = ? ORDER BY t.created_at DESC`, + * newest first. The place id breaks ties inside one CURRENT_TIMESTAMP second. + */ + async listForTrip(trip_id: number): Promise { + const rows = await this.listQuery() + .where('p.trip_id', '=', trip_id) + .orderBy('t.created_at', 'desc') + .orderBy('t.place_id', 'desc') + .execute(); + return rows.map(toListRow); + } + + /** TO2 — {@link listForTrip}'s projection for one place, `undefined` unless it is a tour of that trip. */ + async findInTrip(trip_id: number, place_id: number): Promise { + const row = await this.listQuery().where('p.trip_id', '=', trip_id).where('p.id', '=', place_id).executeTakeFirst(); + return row ? toListRow(row) : undefined; + } + + /** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */ + async existsForPlace(place_id: number): Promise { + const row = await this.db().selectFrom('tours').select('place_id').where('place_id', '=', place_id).executeTakeFirst(); + return !!row; + } + + /** TO4 — `SELECT place_id FROM tours WHERE place_id IN (...)`: which of these places are tours. */ + async listTourPlaceIds(place_ids: number[]): Promise { + if (place_ids.length === 0) return []; + const rows = await this.db().selectFrom('tours').select('place_id').where('place_id', 'in', place_ids).execute(); + return rows.map((r) => r.place_id); + } + + /** + * TO5 — `SELECT 1 FROM day_assignments da JOIN tours t ON t.place_id = + * da.place_id WHERE da.day_id = ? AND da.place_id = ? [AND da.id != ?]`. + * + * A tour fills its day, so one day holds it at most once. Create and move + * both ask this inside the transaction that writes the assignment; move + * passes the assignment being moved so it does not collide with itself. + * False for an ordinary place, which may sit on a day any number of times. + */ + async isOnDay(day_id: number, place_id: number, except_assignment_id?: number): Promise { + let query = this.db() + .selectFrom('day_assignments as da') + .innerJoin('tours as t', 't.place_id', 'da.place_id') + .select('da.id') + .where('da.day_id', '=', day_id) + .where('da.place_id', '=', place_id); + if (except_assignment_id !== undefined) query = query.where('da.id', '!=', except_assignment_id); + return !!(await query.executeTakeFirst()); + } + + /** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */ + async listRowsForTrip(trip_id: number): Promise { + return await this.db() + .selectFrom('tours as t') + .innerJoin('places as p', 'p.id', 't.place_id') + .selectAll('t') + .where('p.trip_id', '=', trip_id) + .orderBy('t.place_id', 'asc') + .execute(); + } + + /** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */ + async insertTour(input: TourInsert): Promise { + await this.insert({ + place: input.place_id, + tourTypeRef: input.tour_type, + distance: input.distance, + elevation_gain: input.elevation_gain, + elevation_loss: input.elevation_loss, + duration: input.duration, + match_confidence: input.match_confidence, + max_hiking_difficulty: input.max_hiking_difficulty, + }); + } + + /** + * TO8 — a copied tour keeps every column, `created_at` included, so the + * copy lists its tours in the source's order. + */ + async insertCopy(place_id: number, row: Omit): Promise { + await this.db() + .insertInto('tours') + .values({ ...row, place_id }) + .execute(); + } + + /** + * TO9 — `UPDATE tours SET tour_type = ?, distance = ?, ... WHERE place_id = + * ? AND place_id IN (SELECT id FROM places WHERE trip_id = ?)`. Returns + * whether a row of that trip was updated. + */ + async updateInTrip(trip_id: number, place_id: number, input: TourUpdate): Promise { + const result = await this.db() + .updateTable('tours') + .set({ + tour_type: input.tour_type, + distance: input.distance, + elevation_gain: input.elevation_gain, + elevation_loss: input.elevation_loss, + duration: input.duration, + match_confidence: input.match_confidence, + max_hiking_difficulty: input.max_hiking_difficulty, + }) + .where('place_id', '=', place_id) + .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } +} + +function toListRow & { planned: unknown; has_waypoints: unknown }>(r: R): TourListRow { + return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) }; +} diff --git a/server/src/db/seeders/AddonSeeder.ts b/server/src/db/seeders/AddonSeeder.ts index cb7488365e..5f35c06739 100644 --- a/server/src/db/seeders/AddonSeeder.ts +++ b/server/src/db/seeders/AddonSeeder.ts @@ -93,6 +93,15 @@ const DEFAULT_ADDONS: AddonRow[] = [ enabled: 0, sort_order: 7, }, + { + id: 'tours', + name: 'Tours', + description: 'Hikes and other single-day tours that follow a route', + type: 'trip', + icon: 'Mountain', + enabled: 0, + sort_order: 8, + }, { id: 'journey', name: 'Journey', diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index e71ac145ae..21c872a898 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -272,6 +272,10 @@ export function applyGlobalMiddleware( // document and never reaches the Service Worker that would cache it // (#2180). routing.openstreetmap.de below is a different host. "https://tile.openstreetmap.de", "https://tiles.stadiamaps.com", + // OpenTopoMap is the key-free raster layer offered only by the Tours + // planner. Leaflet uses its a/b/c shards; the apex is named too so a + // future unsharded template does not repeat the OSM CSP gap above. + "https://tile.opentopomap.org", "https://*.tile.opentopomap.org", // The imagery host, for the same reason and one more. Leaflet fetches a tile // as an , which img-src's blanket `https:` waves through, so the satellite // view worked on Leaflet with this host missing. A GL map reads the raster diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e026fde4f8..f05cb26407 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -76,6 +76,7 @@ import { StorageModule } from './storage/storage.module'; import { SystemNoticesModule } from './system-notices/system-notices.module'; import { TagsModule } from './tags/tags.module'; import { TodoModule } from './todo/todo.module'; +import { ToursModule } from './tours/tours.module'; import { TransitModule } from './transit/transit.module'; import { TripInviteModule } from './trip-invite/trip-invite.module'; import { TripsModule } from './trips/trips.module'; @@ -129,6 +130,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; AssignmentsModule, PlacesModule, RoadtripModule, + ToursModule, TripsModule, CollabModule, FilesModule, diff --git a/server/src/nest/assignments/assignments-domain.module.ts b/server/src/nest/assignments/assignments-domain.module.ts index 2653030509..1ebeb34f2f 100644 --- a/server/src/nest/assignments/assignments-domain.module.ts +++ b/server/src/nest/assignments/assignments-domain.module.ts @@ -12,6 +12,7 @@ import { Places } from '../../db/entities/Places.entity'; import { TripMembers } from '../../db/entities/TripMembers.entity'; import { RoadtripVias } from '../../db/entities/RoadtripVias.entity'; import { Trips } from '../../db/entities/Trips.entity'; +import { Tours } from '../../db/entities/Tours.entity'; /** * The assignments SERVICE, split from the controller/MCP/RPC surfaces (the @@ -30,7 +31,8 @@ import { Trips } from '../../db/entities/Trips.entity'; * added by Plan 3d Task 1 for AS20–AS23) — the entity classes only, not the * `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the * loop this module's docstring already avoids stays avoided (`DaysModule`'s - * own precedent for pulling in `Trips` the same way). + * own precedent for pulling in `Trips` the same way). `Tours` backs the + * one-Tour-per-day check create and move share. */ @Module({ imports: [ @@ -38,7 +40,7 @@ import { Trips } from '../../db/entities/Trips.entity'; QueryHelpersModule, JourneyDomainModule, RealtimeModule, - MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers, RoadtripVias, Trips]), + MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers, RoadtripVias, Trips, Tours]), ], providers: [AssignmentsService], exports: [AssignmentsService], diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 564fd1f18c..048defbb16 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@mikro-orm/nestjs'; import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared'; import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip'; @@ -25,6 +25,8 @@ import { TripMembers } from '../../db/entities/TripMembers.entity'; import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository'; import { RoadtripVias } from '../../db/entities/RoadtripVias.entity'; import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository'; +import { Tours } from '../../db/entities/Tours.entity'; +import type { ToursRepository } from '../../db/repositories/Tours.repository'; type Trip = TripAccess; @@ -111,6 +113,7 @@ export class AssignmentsService { @InjectRepository(Places) private readonly placesRepo: PlacesRepository, @InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository, @InjectRepository(RoadtripVias) private readonly roadtripViasRepo: RoadtripViasRepository, + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, ) {} async verifyTripAccess(tripId: string | number, userId: number) { @@ -257,6 +260,7 @@ export class AssignmentsService { const placeIdNum = toRowId(placeId)!; const insertedId = await this.uow.transactional(async () => { + await this.assertTourFreeOnDay(dayIdNum, placeIdNum); // AS6 — the explicit null check (not `||`) is load-bearing: a stored 0 // order_index must survive, which `maxOrder || -1` would not. const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum); @@ -353,13 +357,29 @@ export class AssignmentsService { // about (or race on) where the assignment was. const oldDayId = await this.uow.transactional(async () => { const dayId = await this.dayAssignmentsRepo.getDayId(idNum); // AS13 - await this.dayAssignmentsRepo.moveToDay(idNum, toRowId(newDayId)!, orderIndex ?? 0); // AS14 + const targetDayId = toRowId(newDayId)!; + const placeId = await this.dayAssignmentsRepo.getPlaceId(idNum); + if (placeId !== undefined) await this.assertTourFreeOnDay(targetDayId, placeId, idNum); + await this.dayAssignmentsRepo.moveToDay(idNum, targetDayId, orderIndex ?? 0); // AS14 return dayId; }); const updated = await this.getAssignmentWithPlace(idNum); return { assignment: updated, oldDayId }; } + /** + * A Tour fills its day, so a day holds it at most once. Both ways a stop + * lands on a day (create and move) call this inside the transaction that + * writes it, so two concurrent requests cannot both pass the check. Move + * passes the assignment it moves so a reorder within the same day does + * not collide with itself. Ordinary places are never refused. + */ + private async assertTourFreeOnDay(dayId: number, placeId: number, exceptAssignmentId?: number): Promise { + if (await this.toursRepo.isOnDay(dayId, placeId, exceptAssignmentId)) { + throw new ConflictException('Tour is already assigned to this day'); + } + } + /** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */ async getParticipants(assignmentId: string | number) { return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!); diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index 712f32ddd6..0db85e675f 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -44,6 +44,8 @@ export function formatAssignmentWithPlace(a: AssignmentWithPlaceRow, tags: Parti accommodation_id: a.accommodation_id ?? null, participants: participants || [], created_at: a.created_at, + tour_place_id: a.tour_place_id ?? null, + tour_route_geometry: a.tour_route_geometry ?? null, place: { id: a.place_id, name: a.place_name, diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 819ea92317..372df8ee6b 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -197,6 +197,8 @@ export class DaysService { // a booking wrote, and this copy is one of the paths that feed it. accommodation_id: a.accommodation_id ?? null, created_at: a.created_at, + tour_place_id: a.tour_place_id ?? null, + tour_route_geometry: a.tour_route_geometry ?? null, place: { id: a.place_id, name: a.place_name, diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index e5d55e4bcf..cef0be793e 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -317,7 +317,7 @@ export class PlacesController { await this.requireEdit(trip, user); const { ids } = body; if (ids.length === 0) { - return { deleted: [], count: 0 }; + return { deleted: [], count: 0, tourPlaceIds: [] }; } // Scope the ids to the trip before the hook: onPlaceDeleted keys on the // place id alone, so an id from another trip would detach that trip's @@ -328,7 +328,7 @@ export class PlacesController { for (const id of scoped) await this.places.onDeleted(id); // Read the linked expenses before the delete — afterwards the link is gone (#1298). const expenseIds = await this.places.linkedExpenseIds(tripId, scoped); - const { deleted, cancelled } = await this.places.removeMany(tripId, ids); + const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.removeMany(tripId, ids); for (const id of deleted) { this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId); } @@ -346,7 +346,7 @@ export class PlacesController { for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) { this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined); } - return { deleted, count: deleted.length }; + return { deleted, count: deleted.length, tourPlaceIds: deletedTourPlaceIds }; } @Post('bulk-update') @@ -530,7 +530,7 @@ export class PlacesController { } await this.places.onDeleted(Number(id)); const expenseIds = await this.places.linkedExpenseIds(tripId, [id]); - const { deleted, cancelled } = await this.places.remove(tripId, id); + const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.remove(tripId, id); if (!deleted) { throw new HttpException({ error: 'Place not found' }, 404); } @@ -545,6 +545,6 @@ export class PlacesController { for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) { this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined); } - return { success: true }; + return { success: true, tourPlaceIds: deletedTourPlaceIds }; } } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index a0132b9b2c..4ed36a0738 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -284,7 +284,7 @@ export class PlacesMcp { @Tool({ name: 'delete_place', - description: 'Delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. Warn the user before calling this on a hotel with a booking: it cannot be undone.', + description: 'Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. Warn the user before calling: this cannot be undone.', inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive(), @@ -462,7 +462,7 @@ export class PlacesMcp { @Tool({ name: 'bulk_delete_places', - description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Warn the user before calling this: it cannot be undone.', + description: 'Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. Warn the user before calling: this cannot be undone.', inputSchema: { tripId: z.number().int().positive(), placeIds: z.array(z.number().int().positive()).min(1).max(200), diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index f0643dd91e..a80b5b6aae 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -93,6 +93,15 @@ type Trip = TripAccess; type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null }; +/** One place parsed out of a GPX file, not yet persisted. */ +export interface PreparedGpxPlace { + name: string; + lat: number; + lng: number; + description: string | null; + routeGeometry?: string; +} + /** Fields accepted when creating a place. */ export interface PlaceCreateInput { name: string; description?: string; lat?: number; lng?: number; address?: string; @@ -636,12 +645,12 @@ export class PlacesService { } } - async remove(tripId: string, placeId: string): Promise<{ deleted: boolean; cancelled: CancelledStays }> { + async remove(tripId: string, placeId: string): Promise<{ deleted: boolean; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> { const cancelled = noCancelledStays(); // `toRowId` first (Task 3 review H1, absorbed here): the write below // (`deleteById`) must use the SAME id this gate's existence read used. const id = toRowId(placeId); - if (id === null) return { deleted: false, cancelled }; + if (id === null) return { deleted: false, deletedTourPlaceIds: [], cancelled }; // Rule 21 (H1): the trip id gets the same `toRowId` treatment, parsed // once here and reused for the gate AND every raw survivor below // (`cancelStaysAt`'s PL16, the PL18 `budget_items` DELETE) — @@ -651,14 +660,18 @@ export class PlacesService { // linked expense and stay orphaned (H1, live: verified with a linked // expense and a stay, both survived with `place_id: null`). const tid = toRowId(tripId); - if (tid === null) return { deleted: false, cancelled }; + if (tid === null) return { deleted: false, deletedTourPlaceIds: [], cancelled }; // PL17 — the reclaim-candidate projection, read before the delete. const place = await this.placesRepo.reclaimInputs(id, tid); - if (!place) return { deleted: false, cancelled }; + if (!place) return { deleted: false, deletedTourPlaceIds: [], cancelled }; + let wasTour = false; // The linked expense goes with the place, the same way a booking takes its // expense with it (#1298). One transaction, so a place can never survive // half-detached from its money. await this.uow.transactional(async () => { + // A Tour's facet row and waypoints go with the place (ON DELETE + // CASCADE); the caller still has to tell the client which ones did. + wasTour = await this.placesRepo.isTour(id); await this.cancelStaysAt(tid, id, cancelled); // PL18 — Plan 3e Task 2, converted: `BudgetItemsRepository.deleteForPlace`. await this.budgetItemsRepo.deleteForPlace(tid, id); @@ -667,12 +680,12 @@ export class PlacesService { }); await reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url); await this.reclaimPlaceImage(place.image_url); - return { deleted: true, cancelled }; + return { deleted: true, deletedTourPlaceIds: wasTour ? [id] : [], cancelled }; } - async removeMany(tripId: string, ids: number[]): Promise<{ deleted: number[]; cancelled: CancelledStays }> { + async removeMany(tripId: string, ids: number[]): Promise<{ deleted: number[]; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> { const cancelled = noCancelledStays(); - if (ids.length === 0) return { deleted: [], cancelled }; + if (ids.length === 0) return { deleted: [], deletedTourPlaceIds: [], cancelled }; // Rule 21 (H1): the trip id gets the same `toRowId` treatment as // `remove()`'s, parsed once and reused for every id in the loop below. // Task 9 fix wave (A-8): the claim this comment used to make — "the @@ -686,8 +699,9 @@ export class PlacesService { // any non-canonical trip id before `removeMany` is ever reached, so this // `tid === null` branch only fires for a caller that skipped the gate. const tid = toRowId(tripId); - if (tid === null) return { deleted: [], cancelled }; + if (tid === null) return { deleted: [], deletedTourPlaceIds: [], cancelled }; const deleted: number[] = []; + const deletedTourPlaceIds: number[] = []; const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; await this.uow.transactional(async () => { for (const id of ids) { @@ -697,6 +711,7 @@ export class PlacesService { // gate needed, the H1 class of bug is a route-string problem. const row = await this.placesRepo.reclaimInputs(id, tid); if (!row) continue; + if (await this.placesRepo.isTour(id)) deletedTourPlaceIds.push(id); await this.cancelStaysAt(tid, id, cancelled); // PL22 — Plan 3e Task 2, converted: `BudgetItemsRepository.deleteForPlace`. await this.budgetItemsRepo.deleteForPlace(tid, id); @@ -711,7 +726,7 @@ export class PlacesService { await reclaimPhotoCache(this.photoCache, row.google_place_id, row.image_url); await this.reclaimPlaceImage(row.image_url); } - return { deleted, cancelled }; + return { deleted, deletedTourPlaceIds, cancelled }; } /** @@ -897,11 +912,22 @@ export class PlacesService { } private async importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): Promise { + const rows = this.prepareGpxRows(fileBuffer, opts); + if (!rows.length) return null; + return await this.uow.transactional(() => this.persistGpxRows(tripId, rows)); + } + + /** + * Parses a GPX file into the places an import would create, without writing + * anything. The Tours import reuses it to build its own selection before + * handing the rows to {@link importPreparedGpx}. + */ + prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): PreparedGpxPlace[] { const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts; const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; - if (!gpx) return null; + if (!gpx) return []; const str = (v: unknown) => (v != null ? String(v).trim() : null); const num = (v: unknown) => { const n = Number.parseFloat(String(v)); return Number.isNaN(n) ? null : n; }; @@ -920,8 +946,7 @@ export class PlacesService { return geoSeq === 1 ? base : `${base} ${geoSeq}`; }; - type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }; - const waypoints: WaypointEntry[] = []; + const waypoints: PreparedGpxPlace[] = []; // 1) Parse elements (named waypoints / POIs) if (importWaypoints) { @@ -966,8 +991,25 @@ export class PlacesService { } } - if (waypoints.length === 0) return null; + return waypoints; + } + /** + * Persists rows from {@link prepareGpxRows} and colours their tracks, both in + * one transaction. Called from inside the Tours import's own transaction, + * where this becomes a savepoint, so the places and their tour facets + * commit or roll back together. + */ + async importPreparedGpx(tripId: string, rows: PreparedGpxPlace[]): Promise { + return await this.uow.transactional(async () => { + const result = await this.persistGpxRows(tripId, rows); + await this.colorizeImportedTracks(tripId, result); + return result; + }); + } + + /** Inserts prepared GPX rows, skipping duplicates. The caller owns the transaction. */ + private async persistGpxRows(tripId: string, waypoints: PreparedGpxPlace[]): Promise { // Rule 21 / M1 (Task 9 fix wave): non-null asserted, not `?? -1` — the // controller's `requireTrip` (`verifyTripAccess`) already parsed and // gated this SAME `tripId` with `toRowId` before `importGpx` was ever @@ -985,48 +1027,46 @@ export class PlacesService { // column the legacy INSERT omitted takes the same value the DB column // default would have produced (`duration_minutes: 60`, everything else // `null`) — parity by stored row, not by SQL text. Re-selected via - // `findWithTagsAndRatings` INSIDE the same transaction the insert ran - // in, so the read sees the still-uncommitted row. - await this.uow.transactional(async () => { - for (const wp of waypoints) { - if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { - skipped++; - continue; - } - const placeId = await this.placesRepo.insertPlace({ - trip_id: tid, - name: wp.name, - description: wp.description, - lat: wp.lat, - lng: wp.lng, - address: null, - category_id: null, - price: null, - currency: null, - place_time: null, - end_time: null, - duration_minutes: 60, - notes: null, - image_url: null, - google_place_id: null, - google_ftid: null, - osm_id: null, - amap_poi_id: null, - website: null, - phone: null, - transport_mode: 'walking', - route_geometry: wp.routeGeometry || null, - route_color: null, - stop_type: null, - fill_percent: null, - email: null, - opening_hours: null, - }); - const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; - created.push(place); - trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); + // `findWithTagsAndRatings` INSIDE the caller's transaction, so the read + // sees the still-uncommitted row. + for (const wp of waypoints) { + if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { + skipped++; + continue; } - }); + const placeId = await this.placesRepo.insertPlace({ + trip_id: tid, + name: wp.name, + description: wp.description, + lat: wp.lat, + lng: wp.lng, + address: null, + category_id: null, + price: null, + currency: null, + place_time: null, + end_time: null, + duration_minutes: 60, + notes: null, + image_url: null, + google_place_id: null, + google_ftid: null, + osm_id: null, + amap_poi_id: null, + website: null, + phone: null, + transport_mode: 'walking', + route_geometry: wp.routeGeometry || null, + route_color: null, + stop_type: null, + fill_percent: null, + email: null, + opening_hours: null, + }); + const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; + created.push(place); + trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); + } return { places: created, count: created.length, skipped }; } diff --git a/server/src/nest/tours/dto/tour-create.dto.ts b/server/src/nest/tours/dto/tour-create.dto.ts new file mode 100644 index 0000000000..e5f994b38b --- /dev/null +++ b/server/src/nest/tours/dto/tour-create.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from 'nestjs-zod'; +import { tourCreateRequestSchema } from '@trek/shared'; + +export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {} \ No newline at end of file diff --git a/server/src/nest/tours/tours-import.controller.ts b/server/src/nest/tours/tours-import.controller.ts new file mode 100644 index 0000000000..6e9642c4b9 --- /dev/null +++ b/server/src/nest/tours/tours-import.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Headers, HttpException, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { User } from '../../types'; +import { ADDON_IDS } from '../../addons'; +import { AddonsService } from '../addons/addons.service'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PlacesService } from '../places/places.service'; +import { ToursService } from './tours.service'; + +const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }; + +/** + * POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import. + * + * A controller of its own because it must not sit behind ToursController's + * AddonGuard and TripAccessGuard: a guard answers before multer has read the + * multipart body, and the client then sees ECONNRESET instead of the 404 or + * 403. The handler runs the same checks in the same order once the upload is + * drained, with the guards' own bodies: addon off 404, no trip access 404, + * no 'place_edit' 403. PlacesController.importGpx is the sibling. + */ +@Controller('api/trips/:tripId/tours') +@UseGuards(JwtAuthGuard) +export class ToursImportController { + constructor( + private readonly tours: ToursService, + private readonly places: PlacesService, + private readonly addons: AddonsService, + ) {} + + @Post('import/gpx') + @UseInterceptors(FileInterceptor('file', UPLOAD)) + async importGpx( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Headers('x-socket-id') socketId?: string, + ) { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.TOURS))) { + throw new HttpException({ error: 'Tours addon is not enabled' }, 404); + } + const trip = await this.places.verifyTripAccess(tripId, user.id); + if (!trip) { + throw new HttpException({ error: 'Trip not found' }, 404); + } + if (!(await this.places.canEdit(trip, user))) { + throw new HttpException({ error: 'No permission' }, 403); + } + if (!file) { + throw new HttpException({ error: 'No file uploaded' }, 400); + } + const result = await this.tours.importGpxAsTour(tripId, file.buffer, file.originalname, socketId); + if (!result) { + throw new HttpException({ error: 'No track or route found in GPX file' }, 400); + } + return result; + } +} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts new file mode 100644 index 0000000000..a72f06e52b --- /dev/null +++ b/server/src/nest/tours/tours.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Headers, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { ToursService } from './tours.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { ADDON_IDS } from '../../addons'; +import { TourCreateDto } from './dto/tour-create.dto'; + +/** + * /api/trips/:tripId/tours: the Tours facet, read and route edits. + * TripAccessGuard resolves + * :tripId (404 for no access); writes require 'place_edit'. Assignments stay + * on the existing day_edit endpoints. The multipart GPX import lives in + * ToursImportController, which checks the same rules in its handler. + */ +@Controller('api/trips/:tripId/tours') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class ToursController { + constructor(private readonly tours: ToursService) {} + + @Get() + async list(@Param('tripId') tripId: string) { + return { tours: await this.tours.listTours(tripId) }; + } + + @Get(':placeId') + async detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) { + return await this.tours.getTour(tripId, placeId); + } + + @RequirePermission('place_edit') + @Post() + async create( + @Param('tripId') tripId: string, + @Body() body: TourCreateDto, + @Headers('x-socket-id') socketId?: string, + ) { + return await this.tours.createTour(tripId, body, socketId); + } + + @RequirePermission('place_edit') + @Put(':placeId') + async update( + @Param('tripId') tripId: string, + @Param('placeId') placeId: string, + @Body() body: TourCreateDto, + @Headers('x-socket-id') socketId?: string, + ) { + return await this.tours.updateTour(tripId, placeId, body, socketId); + } +} diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts new file mode 100644 index 0000000000..55291722e5 --- /dev/null +++ b/server/src/nest/tours/tours.helpers.ts @@ -0,0 +1,91 @@ +/** + * Pure geometry-metrics helpers for the Tours domain, separate from GPX parsing. + * PlacesService prepares and persists imported places; these helpers derive + * metrics from the resulting [[lat,lng,ele?]] geometry. + */ + +/** One decoded `route_geometry` point: `[lat, lng]` or `[lat, lng, ele]`. */ +export type GeometryPoint = [number, number] | [number, number, number]; + +const EARTH_RADIUS_KM = 6371; + +function toRad(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** Great-circle distance between two points, in km. */ +function haversineKm(a: GeometryPoint, b: GeometryPoint): number { + const [lat1, lng1] = a; + const [lat2, lng2] = b; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(s))); +} + +export interface TourMetrics { + /** Sum of segment haversine distances, in km. */ + distanceKm: number; + /** Sum of positive elevation deltas, in metres. Null when the track carries no elevation. */ + elevationGainM: number | null; + /** Sum of negative elevation deltas (as a positive number), in metres. Null when no elevation. */ + elevationLossM: number | null; + /** Whether every point in the geometry carried a 3rd (elevation) value. */ + hasElevation: boolean; +} + +/** + * Derives distance/elevation from a parsed `route_geometry` array. Never throws — + * A track with fewer than 2 points, or with missing/mixed + * elevation, still yields a usable (if partial) result rather than blocking import. + */ +export function computeTourMetrics(points: GeometryPoint[]): TourMetrics { + if (points.length < 2) { + return { distanceKm: 0, elevationGainM: null, elevationLossM: null, hasElevation: false }; + } + const hasElevation = points.every(p => p.length === 3 && Number.isFinite(p[2])); + + let distanceKm = 0; + let gain = 0; + let loss = 0; + for (let i = 1; i < points.length; i++) { + distanceKm += haversineKm(points[i - 1], points[i]); + if (hasElevation) { + const delta = (points[i][2] as number) - (points[i - 1][2] as number); + if (delta > 0) gain += delta; + else loss += -delta; + } + } + + return { + distanceKm, + elevationGainM: hasElevation ? gain : null, + elevationLossM: hasElevation ? loss : null, + hasElevation, + }; +} + +/** + * Parses a `places.route_geometry` JSON string into typed points. Returns an empty + * array for null/malformed input rather than throwing — a place created by the + * GPX importer without geometry (shouldn't happen for routes/tracks, but never + * trust stored JSON blindly) simply yields a zero-metric tour. + */ +export function parseRouteGeometry(routeGeometry: string | null | undefined): GeometryPoint[] { + if (!routeGeometry) return []; + try { + const parsed = JSON.parse(routeGeometry); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (p): p is GeometryPoint => + Array.isArray(p) && (p.length === 2 || p.length === 3) && p.every((n: unknown) => typeof n === 'number'), + ); + } catch { + return []; + } +} + +/** Confidence threshold: below this, the tour imports with a caution flag. */ +export const LOW_CONFIDENCE_THRESHOLD = 0.5; diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts new file mode 100644 index 0000000000..16bf958c92 --- /dev/null +++ b/server/src/nest/tours/tours.module.ts @@ -0,0 +1,32 @@ +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; +import { Places } from '../../db/entities/Places.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; +import { ToursController } from './tours.controller'; +import { ToursImportController } from './tours-import.controller'; +import { ToursService } from './tours.service'; +import { PlacesModule } from '../places/places.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; +import { AddonsModule } from '../addons/addons.module'; + +/** + * Tours domain: an isolated bounded context that imports PlacesModule to reuse + * GPX preparation and persistence through PlacesService. The tour SQL lives in + * the Tours and TourWaypoints repositories; the owning place's route columns go + * through PlacesRepository, registered here because forFeature only reaches + * this module's own providers. + */ +@Module({ + imports: [ + PlacesModule, + PermissionsModule, + AuthModule, + AddonsModule, + MikroOrmModule.forFeature([Tours, TourWaypoints, Places]), + ], + controllers: [ToursController, ToursImportController], + providers: [ToursService], +}) +export class ToursModule {} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts new file mode 100644 index 0000000000..b064fd64d3 --- /dev/null +++ b/server/src/nest/tours/tours.service.ts @@ -0,0 +1,249 @@ +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { TourCreateRequest, TourCreateResponse, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared'; +import { Places } from '../../db/entities/Places.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; +import type { TourListRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository'; +import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository'; +import { toRowId } from '../common/row-id'; +import { UnitOfWork } from '../database/unit-of-work'; +import { PlacesService } from '../places/places.service'; +import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers'; + +export interface ImportGpxAsTourResult { + tours: TourListItem[]; + caution: boolean; + skipped: number; +} + +/** + * Tours domain: the `tours` facet table (place_id PK/FK) carries tour-specific + * metadata while existing places and day assignments are reused. + * GPX parsing and persistence are reused through PlacesService.prepareGpxRows + * and importPreparedGpx. Places and facet rows are persisted in one transaction, + * with tour metrics derived from each place's route_geometry. + */ +@Injectable() +export class ToursService { + private readonly logger = new Logger(ToursService.name); + + constructor( + private readonly uow: UnitOfWork, + private readonly places: PlacesService, + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, + @InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository, + @InjectRepository(Places) private readonly placesRepo: PlacesRepository, + ) {} + + private toItem(r: TourListRow): TourListItem { + return { + place_id: r.place_id, + name: r.name, + tour_type: r.tour_type as TourListItem['tour_type'], + distance: r.distance, + elevation_gain: r.elevation_gain, + elevation_loss: r.elevation_loss, + duration: r.duration, + difficulty: r.difficulty, + wanderer_ref: r.wanderer_ref, + match_confidence: r.match_confidence, + tour_group_id: r.tour_group_id, + max_hiking_difficulty: r.max_hiking_difficulty ?? 2, + planned: Boolean(r.planned), + caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD, + has_waypoints: Boolean(r.has_waypoints), + }; + } + + /** The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. */ + private routeFields(input: TourCreateRequest): TourUpdate { + const metrics = computeTourMetrics(input.route_geometry as GeometryPoint[]); + return { + tour_type: input.tour_type, + distance: metrics.distanceKm, + elevation_gain: metrics.elevationGainM, + elevation_loss: metrics.elevationLossM, + duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), + match_confidence: 1, + max_hiking_difficulty: input.max_hiking_difficulty, + }; + } + + /** All tours (the facet + owning place) for a trip, newest first. */ + async listTours(tripId: string): Promise { + const tid = toRowId(tripId); + if (tid === null) return []; + const rows = await this.toursRepo.listForTrip(tid); + return rows.map(r => this.toItem(r)); + } + + /** One saved tour plus the persisted routing controls needed by the editor. */ + async getTour(tripId: string, placeId: string): Promise { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + const row = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined; + if (!row) throw new NotFoundException('Tour not found'); + + let waypoints: TourWaypoint[] = await this.waypointsRepo.listForPlace(row.place_id); + // GPX tours created before tour_waypoints existed still belong in this + // all-tours rail. A read-only endpoint must not backfill the database, so + // expose their saved geometry endpoints as controls; the first edit/save + // replaces them with normal persisted tour_waypoints transactionally. + if (waypoints.length < 2) { + const place = await this.placesRepo.findInTrip(row.place_id, tid!); + const geometry = parseRouteGeometry(place?.route_geometry); + if (geometry.length >= 2) { + const start = geometry[0]; + const end = geometry[geometry.length - 1]; + waypoints = [ + { lat: start[0], lng: start[1], role: 'start', sequence: 0 }, + { lat: end[0], lng: end[1], role: 'end', sequence: 1 }, + ]; + } + } + + return { tour: this.toItem(row), waypoints }; + } + + /** Create the owning Place, Tours facet, and ordered control points as one write. */ + async createTour(tripId: string, input: TourCreateRequest, socketId?: string): Promise { + // The controller's TripAccessGuard already resolved this trip id. + const tid = toRowId(tripId)!; + const start = input.route_geometry[0]; + const fields = this.routeFields(input); + + const placeId = await this.uow.transactional(async () => { + const id = await this.placesRepo.insertTourPlace({ + trip_id: tid, name: input.name, lat: start[0], lng: start[1], route_geometry: JSON.stringify(input.route_geometry), + }); + await this.toursRepo.insertTour({ place_id: id, ...fields }); + await this.waypointsRepo.insertForPlace(id, input.waypoints); + return id; + }); + + const row = await this.toursRepo.findInTrip(tid, placeId); + const place = await this.placesRepo.findWithTagsAndRatings(placeId); + if (!row || !place) throw new Error('Created tour could not be loaded'); + const waypoints = await this.waypointsRepo.listForPlace(placeId); + + // Only announce after every row committed. The originating client reloads + // explicitly because socket-id exclusion intentionally suppresses its echo. + this.broadcastToursChanged(tripId, [placeId], socketId); + try { + this.places.broadcast(tripId, 'place:created', { place }, socketId); + } catch { + this.logger.warn(`Committed Tour ${placeId}: place notification failed`); + } + return { tour: this.toItem(row), waypoints }; + } + + /** Replace a saved tour's derived route and routing controls as one write. */ + async updateTour(tripId: string, placeId: string, input: TourCreateRequest, socketId?: string): Promise { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + if (tid === null || pid === null) throw new NotFoundException('Tour not found'); + const start = input.route_geometry[0]; + const fields = this.routeFields(input); + + await this.uow.transactional(async () => { + // The trip predicate is the cross-trip boundary. It runs inside the write + // so an id from another accessible trip cannot be moved into the current + // one, and a place deleted meanwhile answers 404 instead of failing a + // waypoint insert on its foreign key. + if (!(await this.toursRepo.findInTrip(tid, pid))) throw new NotFoundException('Tour not found'); + const placeUpdated = await this.placesRepo.updateTourRoute(pid, tid, { + name: input.name, lat: start[0], lng: start[1], route_geometry: JSON.stringify(input.route_geometry), + }); + if (!placeUpdated) throw new NotFoundException('Tour not found'); + if (!(await this.toursRepo.updateInTrip(tid, pid, fields))) throw new NotFoundException('Tour not found'); + await this.waypointsRepo.deleteForPlace(pid); + await this.waypointsRepo.insertForPlace(pid, input.waypoints); + }); + + const result = await this.getTour(tripId, placeId); + const place = await this.placesRepo.findWithTagsAndRatings(pid); + if (!place) throw new Error('Updated tour could not be loaded'); + this.broadcastToursChanged(tripId, [pid], socketId); + try { + this.places.broadcast(tripId, 'place:updated', { place }, socketId); + } catch { + this.logger.warn(`Committed Tour ${placeId}: place notification failed`); + } + return result; + } + + private broadcastToursChanged(tripId: string, placeIds: number[], socketId?: string): void { + try { + this.places.broadcast(tripId, 'tours:changed', { placeIds }, socketId); + } catch { + this.logger.warn(`Committed Tours change for trip ${tripId}: realtime invalidation failed`); + } + } + + /** + * The tours-mode GPX import prepares rows through PlacesService.prepareGpxRows + * with waypoints excluded. PlacesService.importPreparedGpx persists the places + * in the same transaction as their `tours` facet rows, whose metrics are + * derived from route_geometry. + * + * Missing or implausible metrics do not block import: + * a track without elevation still imports, just flagged with a low + * match_confidence so the client can surface a "with caution" toast. + */ + async importGpxAsTour(tripId: string, fileBuffer: Buffer, defaultName?: string, socketId?: string): Promise { + const rows = this.places.prepareGpxRows(fileBuffer, { + importWaypoints: false, importRoutes: true, importTracks: true, defaultName, + }); + if (rows.length === 0) return null; + + const tours: TourListItem[] = []; + const result = await this.uow.transactional(async () => { + // A nested transactional, so the places and their facets commit together. + const imported = await this.places.importPreparedGpx(tripId, rows); + for (const place of imported.places) { + const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry)); + const matchConfidence = metrics.hasElevation ? 1 : 0.3; + await this.toursRepo.insertTour({ + place_id: place.id, + tour_type: 'hike', + distance: metrics.distanceKm, + elevation_gain: metrics.elevationGainM, + elevation_loss: metrics.elevationLossM, + duration: null, + match_confidence: matchConfidence, + max_hiking_difficulty: 2, + }); + tours.push(this.toItem({ + place_id: place.id, + name: place.name, + tour_type: 'hike', + distance: metrics.distanceKm, + elevation_gain: metrics.elevationGainM, + elevation_loss: metrics.elevationLossM, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: matchConfidence, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: 0, + has_waypoints: 0, + })); + } + return imported; + }); + + if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped }; + this.broadcastToursChanged(tripId, result.places.map(place => place.id), socketId); + for (const place of result.places) { + try { + this.places.broadcast(tripId, 'place:created', { place }, socketId); + } catch { + this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`); + } + } + return { tours, caution: tours.some(t => t.caution), skipped: result.skipped }; + } +} diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 8207a0a5f6..2e67d8dfc4 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -23,6 +23,9 @@ import { BudgetCategoryOrder } from '../../db/entities/BudgetCategoryOrder.entit import { PackingBags } from '../../db/entities/PackingBags.entity'; import { PackingItems } from '../../db/entities/PackingItems.entity'; import { TodoItems } from '../../db/entities/TodoItems.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; +import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository'; import { MAX_TRIP_DAYS, planDayGrid, @@ -322,6 +325,16 @@ export class TripsService { return this.em.getRepository(TodoItems); } + // Tours (#2586): a Tour's facet row and its route waypoints, copied with + // the place they hang off. Same `this.em.getRepository(...)` pattern. + private get toursRepo() { + return this.em.getRepository(Tours); + } + + private get tourWaypointsRepo() { + return this.em.getRepository(TourWaypoints); + } + async canAccessTrip(tripId: string | number, userId: number) { const access = await this.em.getRepository(Trips).findAccessible(tripId, userId); return access as { user_id: number } | null | undefined; @@ -649,8 +662,9 @@ export class TripsService { // ── Copy / duplicate ───────────────────────────────────────────────────── /** - * Duplicates a trip (all days, places, assignments, accommodations, reservations, - * budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`. + * Duplicates a trip (all days, places with their Tour facets and waypoints, + * assignments, accommodations, reservations, budget, packing bags/items, day + * notes) into a new trip owned by `newOwnerId`. * Cross-links are remapped to the copied rows (reservation↔budget item, * reservation↔accommodation) and split data travels with the copy * (budget_item_members/payers incl. paid flags, assignment_participants). @@ -735,6 +749,31 @@ export class TripsService { if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47 } + // A Tour is a place plus its `tours` facet and the waypoints its route was + // planned through. Without them the copy holds an ordinary place with a + // line on it: no longer listed as a Tour, and its route can't be edited + // because the points it was drawn from are gone. The facet row goes first, + // since the waypoints reference it. + const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId)); + const copiedTours = new Set(); + for (const { place_id, ...tour } of oldTours) { + const newPlaceId = placeMap.get(place_id); + if (!newPlaceId) continue; + await this.toursRepo.insertCopy(newPlaceId, tour); + copiedTours.add(place_id); + } + + const waypointsByPlace = new Map(); + for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) { + if (!copiedTours.has(place_id)) continue; + const list = waypointsByPlace.get(place_id) ?? []; + list.push(waypoint); + waypointsByPlace.set(place_id, list); + } + for (const [placeId, waypoints] of waypointsByPlace) { + await this.tourWaypointsRepo.insertForPlace(placeMap.get(placeId)!, waypoints); + } + const oldAssignments = await this.dayAssignmentsRepo.listAllForTrip(sourceTripId); // TP48 const assignmentMap = new Map(); for (const a of oldAssignments) { diff --git a/server/src/types.ts b/server/src/types.ts index 0df65fc8ae..ed6ef240f6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -371,6 +371,8 @@ export interface AssignmentRow extends DayAssignment { stop_type: string | null; /** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */ fill_percent: number | null; + tour_place_id?: number | null; + tour_route_geometry?: string | null; category_name: string | null; category_color: string | null; category_icon: string | null; diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 4c9c3e905a..8b1743055c 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -113,6 +113,47 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { }); }); + it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => { + const legacyGeometry = JSON.stringify([[48, 11, 600], [48.01, 11.02, 650]]); + const tourGeometry = JSON.stringify([[48.02, 11.03, 700], [48.03, 11.04, 750]]); + db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (3, 5, ?, ?), (4, 5, ?, ?)') + .run('Legacy track', legacyGeometry, 'Tour', tourGeometry); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run(); + const assignmentIds = [ + seedAssignment(3, 2, 0), + seedAssignment(3, 3, 1), + seedAssignment(3, 4, 2), + seedAssignment(3, 4, 3), + ]; + + const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + const assignments = res.body.assignments as Array<{ + id: number; + place_id: number; + order_index: number; + tour_place_id: number | null; + tour_route_geometry: string | null; + }>; + expect(assignments).toHaveLength(4); + expect(assignments.map(a => a.id)).toEqual(assignmentIds); + expect(assignments.map(a => a.order_index)).toEqual([0, 1, 2, 3]); + expect(assignments[0]).toMatchObject({ place_id: 2, tour_place_id: null, tour_route_geometry: null }); + expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({ route_geometry: legacyGeometry }); + expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined(); + expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 }); + expect(assignments[1]).toMatchObject({ place_id: 3, tour_place_id: null, tour_route_geometry: null }); + expect(assignments.slice(2).map(a => ({ + place_id: a.place_id, + tour_place_id: a.tour_place_id, + tour_route_geometry: a.tour_route_geometry, + }))).toEqual([ + { place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry }, + { place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry }, + ]); + }); + it('201 create, 404 place', async () => { reconcileTripSkeletons.mockClear(); const ok = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 }); @@ -126,6 +167,38 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { expect(miss.body).toEqual({ error: 'Place not found' }); }); + it('prevents duplicate Tour/day assignments through concurrent REST requests', async () => { + const tourPlaceId = 20; + db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Ridge walk'); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId); + try { + const createTourAssignment = () => request(server) + .post('/api/trips/5/days/3/assignments') + .set('Cookie', sessionCookie(1)) + .send({ place_id: tourPlaceId }); + const results = await Promise.all([createTourAssignment(), createTourAssignment()]); + + expect(results.map(result => result.status).sort()).toEqual([201, 409]); + expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = 3 AND place_id = ?').get(tourPlaceId)).toEqual({ n: 1 }); + + const otherDay = await request(server).post('/api/trips/5/days/4/assignments') + .set('Cookie', sessionCookie(1)).send({ place_id: tourPlaceId }); + expect(otherDay.status).toBe(201); + expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlaceId)).toEqual({ n: 2 }); + + const firstOrdinary = await request(server).post('/api/trips/5/days/3/assignments') + .set('Cookie', sessionCookie(1)).send({ place_id: 2 }); + const secondOrdinary = await request(server).post('/api/trips/5/days/3/assignments') + .set('Cookie', sessionCookie(1)).send({ place_id: 2 }); + expect(firstOrdinary.status).toBe(201); + expect(secondOrdinary.status).toBe(201); + } finally { + db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId); + db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId); + db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId); + } + }); + it('200 delete assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); const id = seedAssignment(); @@ -183,6 +256,34 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); + it('409 moving a Tour onto a day that already holds it, the row stays put; a reorder within its day still works', async () => { + const tourPlaceId = 21; + db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Lake loop'); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId); + try { + seedAssignment(4, tourPlaceId, 0); + const moving = seedAssignment(3, tourPlaceId, 0); + const conflict = await request(server) + .put(`/api/trips/5/assignments/${moving}/move`) + .set('Cookie', sessionCookie(1)) + .send({ new_day_id: 4, order_index: 1 }); + expect(conflict.status).toBe(409); + expect(conflict.body).toEqual({ error: 'Tour is already assigned to this day' }); + expect(db.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(moving)).toEqual({ day_id: 3, order_index: 0 }); + + const reorder = await request(server) + .put(`/api/trips/5/assignments/${moving}/move`) + .set('Cookie', sessionCookie(1)) + .send({ new_day_id: 3, order_index: 2 }); + expect(reorder.status).toBe(200); + expect(reorder.body.assignment).toMatchObject({ id: moving, day_id: 3, order_index: 2, tour_place_id: tourPlaceId }); + } finally { + db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId); + db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId); + db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId); + } + }); + it('200 notes roundtrip: create with note, PUT edits it, GET list shows the new value (#2163)', async () => { const create = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)) .send({ place_id: 2, notes: 'Book the 10:00 timed entry' }); diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index b7e671ab3d..61910558fe 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -79,6 +79,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( beforeEach(() => { db.prepare('DELETE FROM day_notes').run(); + db.prepare('DELETE FROM day_assignments').run(); checkPermission.mockReturnValue(true); }); @@ -97,6 +98,45 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( expect(res.body.days[0]).toMatchObject({ id: 3, trip_id: 5, day_number: 1, assignments: [], notes_items: [] }); }); + it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => { + const legacyGeometry = JSON.stringify([[48, 11, 600], [48.01, 11.02, 650]]); + const tourGeometry = JSON.stringify([[48.02, 11.03, 700], [48.03, 11.04, 750]]); + db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (2, 5, ?, ?), (3, 5, ?, ?), (4, 5, ?, ?)') + .run('Ordinary', null, 'Legacy track', legacyGeometry, 'Tour', tourGeometry); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run(); + const assignmentIds = [2, 3, 4, 4].map((placeId, orderIndex) => Number( + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, ?)') + .run(placeId, orderIndex).lastInsertRowid, + )); + + const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + const assignments = res.body.days[0].assignments as Array<{ + id: number; + order_index: number; + tour_place_id: number | null; + tour_route_geometry: string | null; + place: { id: number }; + }>; + expect(assignments).toHaveLength(4); + expect(assignments.map(a => a.id)).toEqual(assignmentIds); + expect(assignments.map(a => a.order_index)).toEqual([0, 1, 2, 3]); + expect(assignments[0]).toMatchObject({ place: { id: 2 }, tour_place_id: null, tour_route_geometry: null }); + expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({ route_geometry: legacyGeometry }); + expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined(); + expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 }); + expect(assignments[1]).toMatchObject({ place: { id: 3 }, tour_place_id: null, tour_route_geometry: null }); + expect(assignments.slice(2).map(a => ({ + place_id: a.place.id, + tour_place_id: a.tour_place_id, + tour_route_geometry: a.tour_route_geometry, + }))).toEqual([ + { place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry }, + { place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry }, + ]); + }); + it('201 create day (real insert, auto day_number), 404 trip when not accessible', async () => { const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' }); expect(ok.status).toBe(201); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 2aa708ddb6..24b4cb1238 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -116,6 +116,18 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { expect(res.body.places[0]).toMatchObject({ id: 1, name: 'Spot', trip_id: 5, tags: [], ratings: [] }); }); + it('marks Tour-backed Places without changing ordinary Place rows', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'Plain'), (2, 5, 'Tour')").run(); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (2, 'hike')").run(); + + const all = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); + expect(all.status).toBe(200); + expect(all.body.places).toHaveLength(2); + const tourPlaceIdById = new Map(all.body.places.map((place: { id: number; tour_place_id: number | null }) => [place.id, place.tour_place_id])); + expect(tourPlaceIdById.get(1)).toBeNull(); + expect(tourPlaceIdById.get(2)).toBe(2); + }); + it('200 list scoped to the trip', async () => { db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Mine')").run(); db.prepare("INSERT INTO places (trip_id, name) VALUES (6, 'Theirs')").run(); @@ -144,7 +156,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { db.prepare("INSERT INTO places (id, trip_id, name) VALUES (3, 6, 'Foreign')").run(); const ok = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [1, 2, 3] }); expect(ok.status).toBe(200); - expect(ok.body).toEqual({ deleted: [1, 2], count: 2 }); + expect(ok.body).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [] }); // The foreign trip's place is untouched. expect(db.prepare('SELECT id FROM places ORDER BY id').all()).toEqual([{ id: 3 }]); @@ -271,7 +283,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { const ok = await request(server).delete('/api/trips/5/places/9').set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); - expect(ok.body).toEqual({ success: true }); + expect(ok.body).toEqual({ success: true, tourPlaceIds: [] }); expect(db.prepare('SELECT id FROM places WHERE id = 9').get()).toBeUndefined(); const foreign = await request(server).delete('/api/trips/5/places/10').set('Cookie', sessionCookie(1)); @@ -279,6 +291,20 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { expect(foreign.body).toEqual({ error: 'Place not found' }); }); + it('DELETE :id and bulk-delete report the Tour place ids they removed', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (20, 5, 'Tour A'), (21, 5, 'Tour B'), (22, 5, 'Plain')").run(); + db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (20, 'hike'), (21, 'hike')").run(); + + const single = await request(server).delete('/api/trips/5/places/20').set('Cookie', sessionCookie(1)); + expect(single.status).toBe(200); + expect(single.body).toEqual({ success: true, tourPlaceIds: [20] }); + expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 20').get()).toBeUndefined(); + + const bulk = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [21, 22] }); + expect(bulk.status).toBe(200); + expect(bulk.body).toEqual({ deleted: [21, 22], count: 2, tourPlaceIds: [21] }); + }); + it('DELETE :id takes the expense linked to the place with it (#1298)', async () => { db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Louvre')").run(); db.prepare("INSERT INTO budget_items (id, trip_id, name, total_price, place_id) VALUES (44, 5, 'Tickets', 34, 12)").run(); diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts new file mode 100644 index 0000000000..d309edf41b --- /dev/null +++ b/server/tests/e2e/tours.e2e.test.ts @@ -0,0 +1,443 @@ +/** + * Tours module e2e: every /api/trips/:tripId/tours endpoint through the real + * JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated and + * seeded temp SQLite db (createSnapshotTestDb()). ToursService, PlacesService, + * AssignmentsService and TripsService run their real SQL; only the permission + * check (a container spy), the journey hooks and the WebSocket broadcast are + * stubbed. + * + * Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the + * permission spy denies 'place_edit', user 3 has no access to either trip. + * AssignmentsModule and TripsModule are mounted next to ToursModule so the + * one-tour-per-day 409 on move and the trip copy run through their real routes. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { Test } from '@nestjs/testing'; +import { sessionCookie } from './harness'; + +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); +const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../src/websocket', () => ({ broadcast })); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); + +import { db } from '../../src/db/database'; + +const { journeyHooks } = vi.hoisted(() => ({ + journeyHooks: { + reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), + onPlaceCreated: vi.fn().mockResolvedValue(undefined), + onPlaceUpdated: vi.fn().mockResolvedValue(undefined), + onPlaceDeleted: vi.fn().mockResolvedValue(undefined), + }, +})); +import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { BudgetService } from '../../src/nest/budget/budget.service'; +import { ToursModule } from '../../src/nest/tours/tours.module'; +import { AssignmentsModule } from '../../src/nest/assignments/assignments.module'; +import { TripsModule } from '../../src/nest/trips/trips.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { createTestMikroOrmModule } from '../helpers/test-orm'; + +let checkPermission: MockInstance; + +const OWNER = 1; +const VIEWER = 2; +const OUTSIDER = 3; + +const GEOMETRY = [[47, 11, 600], [47.01, 11.01, 700], [47.02, 11.02, 650]]; +const tourBody = (overrides: Record = {}) => ({ + name: 'Ridge walk', + route_geometry: GEOMETRY, + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, + ], + max_hiking_difficulty: 3, + duration_seconds: 3600, + ...overrides, +}); + +const TRACK_GPX = ` +Alpine loop + 100 + 150 + 120 +`; +const FLAT_GPX = ` +Flat path + +`; +// Large enough that a guard rejecting before multer drained the body would +// surface as a connection reset instead of a JSON response. +const BIG_GPX = TRACK_GPX.replace('', ``); + +describe('Tours e2e (real guards + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + RealtimeModule, + ToursModule, + AssignmentsModule, + TripsModule, + ], + }) + .overrideProvider(JourneyDomainService) + .useValue(journeyHooks) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + const setAddon = (enabled: boolean) => + db.prepare("UPDATE addons SET enabled = ? WHERE id = 'tours'").run(enabled ? 1 : 0); + + const createViaApi = async (tripId = 5, body: Record = tourBody()) => { + const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body); + expect(res.status).toBe(201); + return res.body.tour.place_id as number; + }; + + const importGpx = (userId: number, xml: string | null, tripId = 5) => { + const req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(userId)); + return xml === null ? req.field('note', 'none') : req.attach('file', Buffer.from(xml), { filename: 'track.gpx', contentType: 'application/gpx+xml' }); + }; + + beforeAll(async () => { + db.prepare(`INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES + (1, 'owner', 'owner@example.test', 'x', 'user', 0), + (2, 'viewer', 'viewer@example.test', 'x', 'user', 0), + (3, 'outsider', 'outsider@example.test', 'x', 'user', 0)`).run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Alps'), (6, 1, 'Other')").run(); + db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run(); + db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1), (4, 5, 2)').run(); + app = await build(); + checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); + vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]); + server = app.getHttpServer(); + }); + + beforeEach(() => { + // The viewer is a trip member whose 'place_edit' is withheld; every other + // action and user passes, so the owner can create, move and copy. + checkPermission.mockImplementation((action: string, _role: string, _owner: number, userId: number) => + !(action === 'place_edit' && userId === VIEWER)); + setAddon(true); + broadcast.mockClear(); + db.prepare('DELETE FROM day_assignments').run(); + db.prepare('DELETE FROM places').run(); + db.prepare('DELETE FROM trips WHERE id NOT IN (5, 6)').run(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('addon and access gates', () => { + it('401 without a cookie', async () => { + expect((await request(server).get('/api/trips/5/tours')).status).toBe(401); + }); + + it('404 on every route while the addon is disabled', async () => { + const placeId = await createViaApi(); + setAddon(false); + const off = { error: 'Tours addon is not enabled' }; + const cookie = sessionCookie(OWNER); + const responses = [ + await request(server).get('/api/trips/5/tours').set('Cookie', cookie), + await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie), + await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()), + await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody({ name: 'Renamed' })), + await importGpx(OWNER, BIG_GPX), + ]; + for (const res of responses) { + expect(res.status).toBe(404); + expect(res.body).toEqual(off); + } + expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' }); + }); + + it('404 Trip not found for a user without access to the trip', async () => { + const placeId = await createViaApi(); + const cookie = sessionCookie(OUTSIDER); + const responses = [ + await request(server).get('/api/trips/5/tours').set('Cookie', cookie), + await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie), + await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()), + await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody()), + await importGpx(OUTSIDER, BIG_GPX), + await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)), + ]; + for (const res of responses) { + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + } + expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + }); + + it('a member without place_edit can read but gets 403 on every write', async () => { + const placeId = await createViaApi(); + const cookie = sessionCookie(VIEWER); + + const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie); + expect(list.status).toBe(200); + expect(list.body.tours).toHaveLength(1); + expect((await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie)).status).toBe(200); + + const writes = [ + await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()), + await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody({ name: 'Hijacked' })), + await importGpx(VIEWER, BIG_GPX), + ]; + for (const res of writes) { + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'No permission' }); + } + expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' }); + }); + }); + + describe('POST and GET', () => { + it('201 creates the place, the facet and its waypoints, then lists and reads them back', async () => { + const res = await request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .set('X-Socket-Id', 'sock-1') + .send(tourBody()); + expect(res.status).toBe(201); + const placeId = res.body.tour.place_id as number; + expect(res.body.tour).toMatchObject({ + place_id: placeId, name: 'Ridge walk', tour_type: 'hike', duration: 60, match_confidence: 1, + max_hiking_difficulty: 3, planned: false, caution: false, has_waypoints: true, + }); + expect(res.body.tour.distance).toBeGreaterThan(0); + expect(res.body.tour.elevation_gain).toBe(100); + expect(res.body.tour.elevation_loss).toBe(50); + expect(res.body.waypoints).toEqual(tourBody().waypoints); + + expect(db.prepare('SELECT trip_id, lat, lng, transport_mode, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({ + trip_id: 5, lat: 47, lng: 11, transport_mode: 'walking', route_geometry: JSON.stringify(GEOMETRY), + }); + const events = broadcast.mock.calls.map(c => c[1]); + expect(events).toEqual(expect.arrayContaining(['tours:changed', 'place:created'])); + expect(broadcast.mock.calls.every(c => c[3] === 'sock-1')).toBe(true); + + const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)); + expect(list.status).toBe(200); + expect(list.body.tours).toEqual([res.body.tour]); + + const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)); + expect(detail.status).toBe(200); + expect(detail.body).toEqual(res.body); + }); + + it('lists newest first and reports planned once a day holds the tour', async () => { + const first = await createViaApi(5, tourBody({ name: 'First' })); + const second = await createViaApi(5, tourBody({ name: 'Second' })); + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, 0)').run(first); + const res = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)); + expect(res.body.tours.map((t: { place_id: number; planned: boolean }) => [t.place_id, t.planned])).toEqual([ + [second, false], + [first, true], + ]); + }); + + it('400 for an invalid body, nothing written', async () => { + const cookie = sessionCookie(OWNER); + const badRole = await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody({ + waypoints: [{ lat: 47, lng: 11, role: 'via', sequence: 0 }, { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }], + })); + expect(badRole.status).toBe(400); + const shortRoute = await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody({ route_geometry: [[47, 11, 600]] })); + expect(shortRoute.status).toBe(400); + const badDifficulty = await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody({ max_hiking_difficulty: 7 })); + expect(badDifficulty.status).toBe(400); + expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 }); + }); + + it('GET 404 for a tour of another trip, a plain place and a non-numeric id', async () => { + const foreign = await createViaApi(6); + const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid); + for (const id of [foreign, plain, 'abc', 999999]) { + const res = await request(server).get(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Tour not found' }); + } + const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)); + expect(list.body.tours).toEqual([]); + }); + + it('GET falls back to the geometry endpoints for a tour without saved waypoints', async () => { + const res = await importGpx(OWNER, TRACK_GPX); + expect(res.status).toBe(201); + const placeId = res.body.tours[0].place_id as number; + const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)); + expect(detail.status).toBe(200); + expect(detail.body.tour).toMatchObject({ place_id: placeId, has_waypoints: false }); + expect(detail.body.waypoints).toEqual([ + { lat: 49, lng: 12, role: 'start', sequence: 0 }, + { lat: 49.02, lng: 12.02, role: 'end', sequence: 1 }, + ]); + expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); + }); + }); + + describe('PUT', () => { + it('200 replaces the route, metrics and waypoints', async () => { + const placeId = await createViaApi(); + const geometry = [[46, 10, 1000], [46.01, 10.01, 1200], [46.02, 10.02, 1100]]; + const waypoints = [ + { lat: 46, lng: 10, role: 'start', sequence: 0 }, + { lat: 46.01, lng: 10.01, role: 'via', sequence: 1 }, + { lat: 46.02, lng: 10.02, role: 'end', sequence: 2 }, + ]; + const res = await request(server) + .put(`/api/trips/5/tours/${placeId}`) + .set('Cookie', sessionCookie(OWNER)) + .send(tourBody({ name: 'Summit', route_geometry: geometry, waypoints, max_hiking_difficulty: 5, duration_seconds: null })); + expect(res.status).toBe(200); + expect(res.body.tour).toMatchObject({ + place_id: placeId, name: 'Summit', elevation_gain: 200, elevation_loss: 100, duration: null, max_hiking_difficulty: 5, + }); + expect(res.body.waypoints).toEqual(waypoints); + expect(db.prepare('SELECT name, lat, lng, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({ + name: 'Summit', lat: 46, lng: 10, route_geometry: JSON.stringify(geometry), + }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 }); + expect(broadcast.mock.calls.map(c => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated'])); + }); + + it('404 for a tour of another trip, a plain place and a non-numeric id, nothing changes', async () => { + const foreign = await createViaApi(6); + const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid); + for (const id of [foreign, plain, 'abc']) { + const res = await request(server).put(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER)).send(tourBody({ name: 'Moved' })); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Tour not found' }); + } + expect(db.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(foreign)).toEqual({ trip_id: 6, name: 'Ridge walk' }); + expect(db.prepare('SELECT name, route_geometry FROM places WHERE id = ?').get(plain)).toEqual({ name: 'Cafe', route_geometry: null }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(plain)).toEqual({ n: 0 }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(foreign)).toEqual({ n: 2 }); + }); + }); + + describe('POST import/gpx', () => { + it('201 imports each track as a hike tour', async () => { + const res = await importGpx(OWNER, TRACK_GPX); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ caution: false, skipped: 0 }); + expect(res.body.tours).toHaveLength(1); + const tour = res.body.tours[0]; + expect(tour).toMatchObject({ name: 'Alpine loop', tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2, has_waypoints: false }); + expect(db.prepare('SELECT trip_id FROM places WHERE id = ?').get(tour.place_id)).toEqual({ trip_id: 5 }); + expect(db.prepare('SELECT tour_type FROM tours WHERE place_id = ?').get(tour.place_id)).toEqual({ tour_type: 'hike' }); + expect(broadcast.mock.calls.map(c => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:created'])); + }); + + it('flags a track without elevation with caution', async () => { + const res = await importGpx(OWNER, FLAT_GPX); + expect(res.status).toBe(201); + expect(res.body.caution).toBe(true); + expect(res.body.tours[0]).toMatchObject({ match_confidence: 0.3, caution: true }); + }); + + it('a second import of the same track is skipped as a duplicate', async () => { + expect((await importGpx(OWNER, TRACK_GPX)).status).toBe(201); + const again = await importGpx(OWNER, TRACK_GPX); + expect(again.status).toBe(201); + expect(again.body).toEqual({ tours: [], caution: false, skipped: 1 }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + }); + + it('400 without a file, and for a file with no track or route', async () => { + const missing = await importGpx(OWNER, null); + expect(missing.status).toBe(400); + expect(missing.body).toEqual({ error: 'No file uploaded' }); + for (const xml of ['', '', '', 'plain text']) { + const res = await importGpx(OWNER, xml); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'No track or route found in GPX file' }); + } + expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 }); + }); + }); + + describe('other domains', () => { + it('409 moving a tour onto a day that already holds it, on create and on move', async () => { + const placeId = await createViaApi(); + const cookie = sessionCookie(OWNER); + const onDay4 = await request(server).post('/api/trips/5/days/4/assignments').set('Cookie', cookie).send({ place_id: placeId }); + expect(onDay4.status).toBe(201); + const onDay3 = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', cookie).send({ place_id: placeId }); + expect(onDay3.status).toBe(201); + + const twice = await request(server).post('/api/trips/5/days/4/assignments').set('Cookie', cookie).send({ place_id: placeId }); + expect(twice.status).toBe(409); + expect(twice.body).toEqual({ error: 'Tour is already assigned to this day' }); + + const moving = onDay3.body.assignment.id as number; + const move = await request(server).put(`/api/trips/5/assignments/${moving}/move`).set('Cookie', cookie).send({ new_day_id: 4, order_index: 1 }); + expect(move.status).toBe(409); + expect(move.body).toEqual({ error: 'Tour is already assigned to this day' }); + expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(moving)).toEqual({ day_id: 3 }); + expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(placeId)).toEqual({ n: 2 }); + }); + + it('copying the trip carries its tours and waypoints onto the new places', async () => { + const placeId = await createViaApi(5, tourBody({ + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.01, lng: 11.01, role: 'via', sequence: 1 }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 2 }, + ], + })); + const res = await request(server).post('/api/trips/5/copy').set('Cookie', sessionCookie(OWNER)).send({ title: 'Alps again' }); + expect(res.status).toBe(201); + const copyId = res.body.trip.id as number; + + const list = await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER)); + expect(list.status).toBe(200); + expect(list.body.tours).toHaveLength(1); + const copied = list.body.tours[0]; + expect(copied.place_id).not.toBe(placeId); + const source = (await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body; + const { place_id: _sourceId, ...sourceRest } = source.tour; + const { place_id: _copyId, ...copyRest } = copied; + expect(copyRest).toEqual(sourceRest); + + const detail = await request(server).get(`/api/trips/${copyId}/tours/${copied.place_id}`).set('Cookie', sessionCookie(OWNER)); + expect(detail.body.waypoints).toEqual(source.waypoints); + expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 }); + }); + + it('deleting the place removes its tour and waypoints and reports the tour id', async () => { + const placeId = await createViaApi(); + const res = await request(server).delete(`/api/trips/5/places/${placeId}`).set('Cookie', sessionCookie(OWNER)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, tourPlaceIds: [placeId] }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); + expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); + }); + }); +}); diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index 7d88a6c33c..5c78967d29 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -19,6 +19,7 @@ import { createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from './journey-repos'; +import { createTestToursRepo } from './tours-repos'; /** * AccommodationsService over a test connection. @@ -66,6 +67,7 @@ export async function accommodationsOver(conn: Database): Promise { await createTestPlacesRepo(db), await createTestTripMembersRepo(db), await createTestRoadtripViasRepo(db), + await createTestToursRepo(db), ); const accommodationsService = new AccommodationsService( permissionsService, realtimeService, assignmentsService, await createTestUnitOfWork(db), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index e3785b4828..b3d4de9550 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -140,6 +140,7 @@ import { } from './journey-repos'; import { createTestJourneyShareTokensRepo } from './journey-share-repos'; import { noGoogleQuota } from './google-quota'; +import { createTestToursRepo } from './tours-repos'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -262,6 +263,7 @@ export async function createPluginRpcHostFactory(db: Database.Database): Promise await createTestPlacesRepo(db), await createTestTripMembersRepo(db), await createTestRoadtripViasRepo(db), + await createTestToursRepo(db), ); const membership = new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)); const notifications = await makeNotificationsService(db, realtime); diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 3ed5a99c0d..2ddcc4221c 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -51,6 +51,9 @@ const KEEP_TABLES = new Set([ 'photo_provider_fields', 'document_providers', 'document_provider_fields', + // Reference data the migration that creates it writes (`tours.tour_type` + // has an FK on it); nothing re-seeds it, so a reset must keep it. + 'tour_types', // Migration bookkeeping: clearing these would make the next boot replay the // whole history over a schema that already has it. 'schema_version', diff --git a/server/tests/helpers/tours-repos.ts b/server/tests/helpers/tours-repos.ts new file mode 100644 index 0000000000..e9ab4724a2 --- /dev/null +++ b/server/tests/helpers/tours-repos.ts @@ -0,0 +1,38 @@ +import type Database from 'better-sqlite3'; +import { sharedTestOrm } from './test-uow'; +import { Tours } from '../../src/db/entities/Tours.entity'; +import type { ToursRepository } from '../../src/db/repositories/Tours.repository'; +import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity'; +import type { TourWaypointsRepository } from '../../src/db/repositories/TourWaypoints.repository'; + +/** + * Tours test-only repository factories, bound to a suite's own better-sqlite3 + * handle through the same memoised `sharedTestOrm` `test-uow.ts` exports, so + * a repository built here and a `UnitOfWork` from `createTestUnitOfWork` + * resolve the same `EntityManager` (the `todo-repos.ts` precedent). + */ +export function createTestToursRepo(db: Database.Database): Promise { + return sharedTestOrm(db).then((t) => t.repo(Tours)); +} + +export function createTestTourWaypointsRepo(db: Database.Database): Promise { + return sharedTestOrm(db).then((t) => t.repo(TourWaypoints)); +} + +/** A `tours` row for an existing place, with the defaults a GPX import writes. */ +export function createTour( + db: Database.Database, + placeId: number, + overrides: Partial<{ distance: number | null; match_confidence: number | null; max_hiking_difficulty: number; created_at: string }> = {}, +): void { + db.prepare( + `INSERT INTO tours (place_id, tour_type, distance, match_confidence, max_hiking_difficulty, created_at) + VALUES (?, 'hike', ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`, + ).run( + placeId, + overrides.distance ?? null, + overrides.match_confidence ?? null, + overrides.max_hiking_difficulty ?? 2, + overrides.created_at ?? null, + ); +} diff --git a/server/tests/integration/journey-place-order.test.ts b/server/tests/integration/journey-place-order.test.ts index ec1a8efbc6..9ddbaeeb4c 100644 --- a/server/tests/integration/journey-place-order.test.ts +++ b/server/tests/integration/journey-place-order.test.ts @@ -90,7 +90,7 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade', .delete(`/api/trips/${trip.id}/places/${place.id}`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(res.body).toEqual({ success: true }); + expect(res.body).toEqual({ success: true, tourPlaceIds: [] }); // The place is gone. expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeUndefined(); diff --git a/server/tests/unit/db/dialect/sql-functions.test.ts b/server/tests/unit/db/dialect/sql-functions.test.ts index 2ca714059d..66304d4ec1 100644 --- a/server/tests/unit/db/dialect/sql-functions.test.ts +++ b/server/tests/unit/db/dialect/sql-functions.test.ts @@ -9,6 +9,7 @@ import { Users } from '../../../../src/db/entities/Users.entity'; import { absDifference, caseWhenEquals, + caseWhenNotNull, castInteger, castIntegerKysely, coalesce, @@ -601,6 +602,28 @@ describe('sql-functions (sqlite)', () => { expect(() => caseWhenEquals(foreign, 'u.id', 1, 'owner', 'member')).toThrow(/no implementation for platform FakePlatform/); }); + // Tours (#2586): the assignment projection's `tour_route_geometry` column. + it('SQLF-098: caseWhenNotNull yields the THEN column only where the test column is not null', async () => { + const { user: a } = createUser(testDb); + const { user: b } = createUser(testDb); + testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Shown', a.id); + testDb.prepare('UPDATE users SET display_name = NULL WHERE id = ?').run(b.id); + const platform = t.em.getPlatform(); + + const rows = await t.em.createQueryBuilder(Users, 'u') + .select(['u.id', caseWhenNotNull(platform, 'u.display_name', 'u.username').as('picked')]) + .where({ id: { $in: [a.id, b.id] } }) + .orderBy({ id: 'asc' }) + .execute('all', false); + expect(rows).toEqual([{ id: a.id, picked: a.username }, { id: b.id, picked: null }]); + }); + + it('SQLF-099: an unknown platform fails closed for caseWhenNotNull', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + expect(() => caseWhenNotNull(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/); + }); + // Task 1 fix review M1 (`PlaceShadowPicksRepository.countBySource`, PS5): // `countAll()`'s alias cannot be ordered by (`.orderBy({ count: 'desc' })` // throws "not existing property" — verified in SQLF-024's comment above); diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index 7705074855..d308ce3884 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -26,16 +26,17 @@ describe('ALL_ENTITIES', () => { }); /** - * Plan 2 Task 3: every one of the 127 entities the migrated schema + * Plan 2 Task 3: every one of the 130 entities the migrated schema * describes (120 rewritten + the 5 document-sync tables added this task, - * + push_subscriptions and google_api_usage from legacy steps 245 and 254) + * + push_subscriptions and google_api_usage from legacy steps 245 and 254, + * + tour_types, tours and tour_waypoints from the Tours migration) * is registered, and no two share a class name (a silent duplicate would * shadow one of them in `orm.getMetadata()`, exactly the kind of drift * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 127 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(127); + it('ENT-003: exactly 130 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(130); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index 344e1fc5a3..aec3173b42 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -14,17 +14,18 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; * `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and * `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for * the mechanism). This file pins the OUTPUT of that rule against the real, - * committed entities — every one of the 127, not a sample — and reproduces + * committed entities — every one of the 130, not a sample — and reproduces * the reviewer's own insert probe so a future regenerate/hand-edit that * reintroduces a `NaN` initialiser fails loudly here, not silently at a * caller's first `em.create()`. */ /** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */ // 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key. -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 430; +// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence. +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 440; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { - it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 127 entities', () => { + it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 130 entities', () => { const failures: string[] = []; for (const schema of ALL_ENTITIES) { const Ctor = schema.class as new () => Record; diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 7f7295b475..d16c95ba0c 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -28,7 +28,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations'); const BASELINE = 'Migration20200101000000_baseline_schema'; /** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */ -const UNNUMBERED = [BASELINE, 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null']; +const UNNUMBERED = [ + BASELINE, + 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null', + 'Migration20200101042000_tours', +]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. const LEGACY_FINAL_STEP = 258; diff --git a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts index e9bc66e84a..120c089f74 100644 --- a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts @@ -8,6 +8,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createCategory, createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories'; +import { createTour } from '../../../helpers/tours-repos'; import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entity'; import type { DayAssignmentsRepository } from '../../../../src/db/repositories/DayAssignments.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; @@ -35,9 +36,11 @@ function legacyProjectionRow(id: number): unknown { COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent, + t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id + LEFT JOIN tours t ON t.place_id = p.id LEFT JOIN categories c ON p.category_id = c.id WHERE da.id = ? `).get(id); @@ -92,6 +95,30 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => { expect(await assignments.findWithPlaceAndCategory(999999)).toBeNull(); }); + it('ASSIGNPLACEREPO-009: the Tour facet columns are set for a Tour and null for a plain place, even one with a route', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' }); + const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' }); + testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id); + testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id); + createTour(testDb, tourPlace.id); + const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 }); + const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 }); + + const one = await assignments.findWithPlaceAndCategory(tour.id); + expect(one).toStrictEqual(legacyProjectionRow(tour.id)); + expect(one).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.1,11.1]]' }); + expect(await assignments.findWithPlaceAndCategory(track.id)).toMatchObject({ tour_place_id: null, tour_route_geometry: null }); + + const facets = (rows: { tour_place_id: number | null; tour_route_geometry: string | null }[]) => + rows.map((r) => [r.tour_place_id, r.tour_route_geometry]); + const expected = [[tourPlace.id, '[[47,11],[47.1,11.1]]'], [null, null]]; + expect(facets(await assignments.listForDay(day.id))).toEqual(expected); + expect(facets(await assignments.listWithPlaceAndCategory([day.id]))).toEqual(expected); + }); + it('ASSIGNPLACEREPO-004: place_time/end_time COALESCE — the assignment\'s own time wins, the place\'s time is the fallback', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -373,6 +400,16 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order expect(await assignments.getDayId(999999)).toBeUndefined(); }); + it('ASSIGNREPO-042 (getPlaceId): the place_id of the assignment, undefined for a missing id', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const a = createDayAssignment(testDb, day.id, place.id); + expect(await assignments.getPlaceId(a.id)).toBe(place.id); + expect(await assignments.getPlaceId(999999)).toBeUndefined(); + }); + it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/db/repositories/Places.repository.test.ts b/server/tests/unit/db/repositories/Places.repository.test.ts index 9aee409c3f..893a972711 100644 --- a/server/tests/unit/db/repositories/Places.repository.test.ts +++ b/server/tests/unit/db/repositories/Places.repository.test.ts @@ -11,6 +11,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { addTripMember, createCategory, createDay, createDayAssignment, createPlace, createTag, createTrip, createUser } from '../../../helpers/factories'; +import { createTour } from '../../../helpers/tours-repos'; import { Places } from '../../../../src/db/entities/Places.entity'; import type { PlacesRepository } from '../../../../src/db/repositories/Places.repository'; @@ -662,9 +663,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil filters: { searchPattern?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, ): unknown[] { let query = ` - SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon, + SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon, pr.country_code as country_code, pr.region_name as region_name FROM places p + LEFT JOIN tours t ON t.place_id = p.id LEFT JOIN categories c ON p.category_id = c.id LEFT JOIN place_regions pr ON pr.place_id = p.id WHERE p.trip_id = ? @@ -712,8 +714,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil const p3 = createPlace(testDb, trip.id, { name: 'Louvre' }); testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(p3.id, tag.id); createDayAssignment(testDb, day.id, p3.id); - // p4: no category, no tag, unassigned, does NOT match the search term. - createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + // p4: no category, no tag, unassigned, does NOT match the search term, + // and a Tour, so tour_place_id is non-null on exactly one row. + const p4 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + createTour(testDb, p4.id); // Another trip's place, named to collide with the search term — must // never appear in either statement's output (both scope by trip_id). createPlace(testDb, otherTrip.id, { name: 'Central Perk' }); @@ -1451,3 +1455,60 @@ describe('PlacesRepository — share.service.ts SH12 read', () => { expect(await places.listPublicForShare(trip.id)).toEqual([]); }); }); + +describe('PlacesRepository.insertTourPlace (TO10) / updateTourRoute (TO11)', () => { + it('PLACEREPO-TOUR-001: insertTourPlace writes the route place as a walking place and leaves the rest at its defaults', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = await places.insertTourPlace({ trip_id: trip.id, name: 'Ridge walk', lat: 47, lng: 11, route_geometry: '[[47,11],[47.1,11.1]]' }); + + expect(await places.findInTrip(id, trip.id)).toMatchObject({ + trip_id: trip.id, name: 'Ridge walk', lat: 47, lng: 11, transport_mode: 'walking', + route_geometry: '[[47,11],[47.1,11.1]]', duration_minutes: 60, reservation_status: 'none', category_id: null, + }); + }); + + it('PLACEREPO-TOUR-002: updateTourRoute rewrites the route of a place of that trip, stamps updated_at, and refuses another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 }); + testDb.prepare("UPDATE places SET transport_mode = 'driving', updated_at = NULL WHERE id = ?").run(place.id); + const route = { name: 'New', lat: 47, lng: 11, route_geometry: '[[47,11],[47.2,11.2]]' }; + + expect(await places.updateTourRoute(place.id, other.id, route)).toBe(false); + expect(await places.findInTrip(place.id, trip.id)).toMatchObject({ name: 'Old', transport_mode: 'driving', updated_at: null }); + + expect(await places.updateTourRoute(place.id, trip.id, route)).toBe(true); + const row = await places.findInTrip(place.id, trip.id); + expect(row).toMatchObject({ ...route, transport_mode: 'walking' }); + expect(row?.updated_at).not.toBeNull(); + }); +}); + +describe('PlacesRepository.isTour / listForTrip tour_place_id', () => { + it('PLACEREPO-TOUR-003: isTour is true only for a place with a tours row', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' }); + const plain = createPlace(testDb, trip.id, { name: 'Cafe' }); + createTour(testDb, tour.id); + + expect(await places.isTour(tour.id)).toBe(true); + expect(await places.isTour(plain.id)).toBe(false); + expect(await places.isTour(999_999)).toBe(false); + }); + + it('PLACEREPO-TOUR-004: listForTrip carries the place id as tour_place_id for Tours and null otherwise', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' }); + const plain = createPlace(testDb, trip.id, { name: 'Cafe' }); + createTour(testDb, tour.id); + + const rows = await places.listForTrip(String(trip.id), {}); + const byId = new Map(rows.map((r) => [r.id, r.tour_place_id])); + expect(byId.get(tour.id)).toBe(tour.id); + expect(byId.get(plain.id)).toBeNull(); + }); +}); diff --git a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts new file mode 100644 index 0000000000..e4a90bc644 --- /dev/null +++ b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts @@ -0,0 +1,78 @@ +/** + * `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3 + * (`insertForPlace`) and TW4 (`deleteForPlace`). + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createPlace, createTrip, createUser } from '../../../helpers/factories'; +import { createTour } from '../../../helpers/tours-repos'; +import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity'; +import type { TourWaypointRow, TourWaypointsRepository } from '../../../../src/db/repositories/TourWaypoints.repository'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let repo: TourWaypointsRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + repo = t.repo(TourWaypoints); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +const ROUTE: TourWaypointRow[] = [ + { lat: 47.1, lng: 11.1, role: 'end', sequence: 2 }, + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.05, lng: 11.05, role: 'via', sequence: 1 }, +]; + +function tourInTrip() { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + createTour(testDb, place.id); + return { trip, place }; +} + +describe('TourWaypointsRepository', () => { + it('TWREPO-001: insertForPlace writes every point and listForPlace reads them back in route order', async () => { + const { place } = tourInTrip(); + await repo.insertForPlace(place.id, ROUTE); + expect(await repo.listForPlace(place.id)).toEqual([...ROUTE].sort((a, b) => a.sequence - b.sequence)); + }); + + it('TWREPO-002: insertForPlace with no points writes nothing', async () => { + const { place } = tourInTrip(); + await repo.insertForPlace(place.id, []); + expect(await repo.listForPlace(place.id)).toEqual([]); + }); + + it('TWREPO-003: deleteForPlace clears one tour and leaves another alone', async () => { + const first = tourInTrip(); + const second = tourInTrip(); + await repo.insertForPlace(first.place.id, ROUTE); + await repo.insertForPlace(second.place.id, ROUTE); + + await repo.deleteForPlace(first.place.id); + expect(await repo.listForPlace(first.place.id)).toEqual([]); + expect(await repo.listForPlace(second.place.id)).toHaveLength(3); + }); + + it('TWREPO-004: listForTrip returns every waypoint of the trip with its place, ordered by place and sequence', async () => { + const { trip, place } = tourInTrip(); + const other = tourInTrip(); + const second = createPlace(testDb, trip.id); + createTour(testDb, second.id); + await repo.insertForPlace(second.id, ROUTE.slice(0, 2)); + await repo.insertForPlace(place.id, ROUTE); + await repo.insertForPlace(other.place.id, ROUTE); + + const rows = await repo.listForTrip(trip.id); + expect(rows.map((r) => [r.place_id, r.sequence])).toEqual([ + [place.id, 0], [place.id, 1], [place.id, 2], [second.id, 0], [second.id, 2], + ]); + expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 }); + }); +}); diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts new file mode 100644 index 0000000000..7241346451 --- /dev/null +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -0,0 +1,167 @@ +/** + * `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes + * places and assignments ask (TO3/TO4/TO5), the trip copy read and write + * (TO6/TO8), and the create/edit writes (TO7/TO9). + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories'; +import { createTour } from '../../../helpers/tours-repos'; +import { Tours } from '../../../../src/db/entities/Tours.entity'; +import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let repo: ToursRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + repo = t.repo(Tours); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +function fixture() { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + return { trip, other }; +} + +function addWaypoint(placeId: number, sequence: number): void { + testDb.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)").run(placeId, sequence); +} + +const METRICS = { + tour_type: 'hike', + distance: 8.25, + elevation_gain: 420, + elevation_loss: 380, + duration: 150, + match_confidence: 1, + max_hiking_difficulty: 3, +}; + +describe('ToursRepository reads', () => { + it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => { + const { trip, other } = fixture(); + const older = createPlace(testDb, trip.id, { name: 'Older' }); + const newer = createPlace(testDb, trip.id, { name: 'Newer' }); + const plain = createPlace(testDb, trip.id, { name: 'Not a tour' }); + const foreign = createPlace(testDb, other.id, { name: 'Foreign' }); + createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 }); + createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 }); + createTour(testDb, foreign.id); + createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id); + addWaypoint(newer.id, 0); + + expect(await repo.listForTrip(trip.id)).toEqual([ + { + place_id: newer.id, name: 'Newer', tour_type: 'hike', distance: null, elevation_gain: null, elevation_loss: null, + duration: null, difficulty: null, wanderer_ref: null, match_confidence: null, tour_group_id: null, + max_hiking_difficulty: 5, planned: 0, has_waypoints: 1, + }, + { + place_id: older.id, name: 'Older', tour_type: 'hike', distance: 5, elevation_gain: null, elevation_loss: null, + duration: null, difficulty: null, wanderer_ref: null, match_confidence: 0.3, tour_group_id: null, + max_hiking_difficulty: 2, planned: 1, has_waypoints: 0, + }, + ]); + expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id); + }); + + it('TOURREPO-002: listForTrip breaks a created_at tie by the newer place', async () => { + const { trip } = fixture(); + const a = createPlace(testDb, trip.id); + const b = createPlace(testDb, trip.id); + createTour(testDb, a.id, { created_at: '2026-01-01 10:00:00' }); + createTour(testDb, b.id, { created_at: '2026-01-01 10:00:00' }); + expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).toEqual([b.id, a.id]); + }); + + it('TOURREPO-003: findInTrip returns the row for its own trip and undefined across trips or for a plain place', async () => { + const { trip, other } = fixture(); + const tour = createPlace(testDb, trip.id, { name: 'Ridge' }); + const plain = createPlace(testDb, trip.id); + createTour(testDb, tour.id); + + expect(await repo.findInTrip(trip.id, tour.id)).toMatchObject({ place_id: tour.id, name: 'Ridge', planned: 0, has_waypoints: 0 }); + expect(await repo.findInTrip(other.id, tour.id)).toBeUndefined(); + expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined(); + }); + + it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => { + const { trip } = fixture(); + const tour = createPlace(testDb, trip.id); + const plain = createPlace(testDb, trip.id); + createTour(testDb, tour.id); + + expect(await repo.existsForPlace(tour.id)).toBe(true); + expect(await repo.existsForPlace(plain.id)).toBe(false); + expect(await repo.listTourPlaceIds([plain.id, tour.id])).toEqual([tour.id]); + expect(await repo.listTourPlaceIds([])).toEqual([]); + }); + + it('TOURREPO-005: isOnDay is true only for a tour already on that day, and ignores the assignment being moved', async () => { + const { trip } = fixture(); + const day1 = createDay(testDb, trip.id); + const day2 = createDay(testDb, trip.id); + const tour = createPlace(testDb, trip.id); + const plain = createPlace(testDb, trip.id); + createTour(testDb, tour.id); + const onDay1 = createDayAssignment(testDb, day1.id, tour.id); + createDayAssignment(testDb, day1.id, plain.id); + + expect(await repo.isOnDay(day1.id, tour.id)).toBe(true); + expect(await repo.isOnDay(day2.id, tour.id)).toBe(false); + expect(await repo.isOnDay(day1.id, plain.id)).toBe(false); + expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false); + }); +}); + +describe('ToursRepository writes', () => { + it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => { + const { trip } = fixture(); + const place = createPlace(testDb, trip.id, { name: 'Drawn' }); + await repo.insertTour({ place_id: place.id, ...METRICS }); + + expect(await repo.findInTrip(trip.id, place.id)).toEqual({ + place_id: place.id, name: 'Drawn', ...METRICS, difficulty: null, wanderer_ref: null, tour_group_id: null, + planned: 0, has_waypoints: 0, + }); + }); + + it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => { + const { trip, other } = fixture(); + const place = createPlace(testDb, trip.id); + createTour(testDb, place.id); + + expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 }); + + expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS); + }); + + it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => { + const { trip, other } = fixture(); + const source = createPlace(testDb, trip.id); + createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 }); + testDb.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 9 WHERE place_id = ?").run(source.id); + + const rows = await repo.listRowsForTrip(trip.id); + expect(rows).toEqual([{ + place_id: source.id, tour_type: 'hike', distance: 3.5, elevation_gain: null, elevation_loss: null, duration: null, + difficulty: 'T3', wanderer_ref: 'w-1', match_confidence: null, tour_group_id: 9, created_at: '2026-03-04 05:06:07', + max_hiking_difficulty: 4, + }]); + expect(await repo.listRowsForTrip(other.id)).toEqual([]); + + const copy = createPlace(testDb, other.id); + const { place_id: _sourceId, ...columns } = rows[0]; + await repo.insertCopy(copy.id, columns); + expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]); + }); +}); diff --git a/server/tests/unit/db/tours-migration.test.ts b/server/tests/unit/db/tours-migration.test.ts new file mode 100644 index 0000000000..f3bbc31a8d --- /dev/null +++ b/server/tests/unit/db/tours-migration.test.ts @@ -0,0 +1,139 @@ +/** + * `Migration20200101042000_tours`: the Tours tables on a fresh install, and on + * a test instance that already carries them from the pre-ORM Tours branch. + * + * Migrate to the step immediately before it, optionally lay down the + * branch's own tables with rows in them, apply just this migration, assert. + */ +import { describe, expect, it } from 'vitest'; +import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step'; +import type { MikroORM } from '@mikro-orm/sqlite'; + +const TARGET = 'Migration20200101042000_tours'; + +async function ormBeforeTarget(): Promise { + const orm = await createMigrationOrm(); + const names = await pendingNames(orm); + const idx = names.indexOf(TARGET); + expect(idx).toBeGreaterThan(0); + await migrateTo(orm, names[idx - 1]); + return orm; +} + +async function indexNames(orm: MikroORM, table: string): Promise { + const rows = await rawQuery<{ name: string }>(orm, `SELECT name FROM pragma_index_list('${table}') ORDER BY name`); + return rows.map((r) => r.name); +} + +async function seedTripWithPlace(orm: MikroORM): Promise { + await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')"); + await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')"); + await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')"); +} + +describe('Tours migration', () => { + it('TOURMIG-001: a fresh install gets the three tables, the hike type and no redundant waypoint index', async () => { + const orm = await ormBeforeTarget(); + try { + await migrateTo(orm, TARGET); + + expect(await rawQuery(orm, 'SELECT key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order FROM tour_types')).toEqual([ + { key: 'hike', label_key: 'tourTypes.hike', icon: 'Mountain', color: '#16a34a', routing_profile: 'pedestrian', is_sport: 1, enabled: 1, sort_order: 0 }, + ]); + + const tourColumns = await rawQuery<{ name: string; notnull: number; dflt_value: string | null; pk: number }>( + orm, + "SELECT name, \"notnull\", dflt_value, pk FROM pragma_table_info('tours') ORDER BY cid", + ); + expect(tourColumns.map((c) => c.name)).toEqual([ + 'place_id', 'tour_type', 'distance', 'elevation_gain', 'elevation_loss', 'duration', 'difficulty', + 'wanderer_ref', 'match_confidence', 'tour_group_id', 'created_at', 'max_hiking_difficulty', + ]); + expect(tourColumns.find((c) => c.name === 'max_hiking_difficulty')).toMatchObject({ notnull: 1, dflt_value: '2' }); + expect(tourColumns.find((c) => c.name === 'place_id')).toMatchObject({ pk: 1 }); + + expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type'); + expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place'); + + const fks = await rawQuery<{ table: string; from: string; to: string; on_delete: string }>( + orm, + "SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list('tours') UNION ALL SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list('tour_waypoints') ORDER BY 1, 2", + ); + expect(fks).toEqual([ + { table: 'places', from: 'place_id', to: 'id', on_delete: 'CASCADE' }, + { table: 'tour_types', from: 'tour_type', to: 'key', on_delete: 'NO ACTION' }, + { table: 'tours', from: 'place_id', to: 'place_id', on_delete: 'CASCADE' }, + ]); + } finally { + await orm.close(true); + } + }, 30000); + + it('TOURMIG-002: the CHECKs refuse out-of-range values and a place delete cascades through tours to its waypoints', async () => { + const orm = await ormBeforeTarget(); + try { + await migrateTo(orm, TARGET); + await rawExec(orm, 'PRAGMA foreign_keys = ON'); + await seedTripWithPlace(orm); + + await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (100, 'hike', 7)")).rejects.toThrow(/CHECK/); + await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'kayak')")).rejects.toThrow(/FOREIGN KEY/); + await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'hike')"); + expect(await rawQuery(orm, 'SELECT max_hiking_difficulty FROM tours')).toEqual([{ max_hiking_difficulty: 2 }]); + + await expect(rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 91, 0, 'start', 0)")).rejects.toThrow(/CHECK/); + await expect(rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 0, 0, 'detour', 0)")).rejects.toThrow(/CHECK/); + await rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)"); + await expect(rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'via', 1)")).rejects.toThrow(/UNIQUE/); + + await rawExec(orm, 'DELETE FROM places WHERE id = 100'); + expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tours')).toEqual([{ n: 0 }]); + expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tour_waypoints')).toEqual([{ n: 0 }]); + } finally { + await orm.close(true); + } + }, 30000); + + it('TOURMIG-003: on an instance that already has the pre-ORM Tours tables it keeps every row and drops only the redundant index', async () => { + const orm = await ormBeforeTarget(); + try { + // The pre-ORM branch's final shape, rows and all, with its custom hike row. + await rawExec(orm, `CREATE TABLE tour_types ( + key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT, + is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`); + await rawExec(orm, "INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#000000', 'pedestrian')"); + await rawExec(orm, `CREATE TABLE tours ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL, + duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6))`); + await rawExec(orm, 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)'); + await rawExec(orm, `CREATE TABLE tour_waypoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, + lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180), + role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), sequence INTEGER NOT NULL CHECK(sequence >= 0), + UNIQUE(place_id, sequence))`); + await rawExec(orm, 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)'); + await seedTripWithPlace(orm); + await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 4)"); + await rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)"); + + await migrateTo(orm, TARGET); + + expect(await rawQuery(orm, 'SELECT key, color FROM tour_types')).toEqual([{ key: 'hike', color: '#000000' }]); + expect(await rawQuery(orm, 'SELECT place_id, tour_type, distance, max_hiking_difficulty FROM tours')).toEqual([ + { place_id: 100, tour_type: 'hike', distance: 12.5, max_hiking_difficulty: 4 }, + ]); + expect(await rawQuery(orm, 'SELECT role, sequence FROM tour_waypoints ORDER BY sequence')).toEqual([ + { role: 'start', sequence: 0 }, + { role: 'end', sequence: 1 }, + ]); + expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type'); + expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place'); + } finally { + await orm.close(true); + } + }, 30000); +}); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 04fb5797f9..481453f26c 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -45,6 +45,12 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com'); }); + it('allows the OpenTopoMap apex and tile shards used by the Tours planner', async () => { + const sources = await connectSrcSources(); + expect(sources).toContain('https://tile.opentopomap.org'); + expect(sources).toContain('https://*.tile.opentopomap.org'); + }); + it('keeps the routing host, which is a different host and covers nothing here', async () => { // routing.openstreetmap.de was on the list all along and looks close enough // to hide the gap: a CSP source matches a host, not a suffix of one. @@ -138,8 +144,8 @@ describe('forced-HTTPS redirect', () => { it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => { process.env.FORCE_HTTPS = 'true'; - process.env.APP_URL = 'https://trip.pakulat.org'; - expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips'); + process.env.APP_URL = 'https://trip.example.invalid'; + expect(await redirectLocation()).toBe('https://trip.example.invalid/trips'); }); it('falls back to the request host when APP_URL is unset', async () => { @@ -158,7 +164,7 @@ describe('forced-HTTPS redirect', () => { it('leaves an already-secure request alone, and never redirects the health probe', async () => { process.env.FORCE_HTTPS = 'true'; - process.env.APP_URL = 'https://trip.pakulat.org'; + process.env.APP_URL = 'https://trip.example.invalid'; const app = express(); applyGlobalMiddleware(app); app.get('/trips', (_req, res) => res.json({ ok: true })); @@ -184,7 +190,7 @@ describe('routingCspOrigins', () => { }); it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => { - expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']); + expect(routingCspOrigins(['http://192.0.2.1:5000'])).toEqual(['http://192.0.2.1:5000']); }); it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => { diff --git a/server/tests/unit/nest/assignments.controller.test.ts b/server/tests/unit/nest/assignments.controller.test.ts index 7f5a70704d..db87ceb272 100644 --- a/server/tests/unit/nest/assignments.controller.test.ts +++ b/server/tests/unit/nest/assignments.controller.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { HttpException } from '@nestjs/common'; +import { ConflictException, HttpException } from '@nestjs/common'; import { DayAssignmentsController, AssignmentOpsController } from '../../../src/nest/assignments/assignments.controller'; import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import type { User } from '../../../src/types'; @@ -47,6 +47,18 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock'); expect(reconcile).toHaveBeenCalledWith('5', 'sock'); }); + + it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => { + const broadcast = vi.fn(); + const reconcile = vi.fn(); + const createAssignment = vi.fn().mockRejectedValue(new ConflictException('Tour is already assigned to this day')); + const controller = new DayAssignmentsController(svc({ createAssignment, broadcast, reconcile } as Partial)); + + expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({ status: 409 }); + expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined); + expect(broadcast).not.toHaveBeenCalled(); + expect(reconcile).not.toHaveBeenCalled(); + }); }); it('PUT /reorder 404 day, else reorders + broadcasts', async () => { diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 2a5bf1ca88..0adc0ad285 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -10,6 +10,7 @@ * logic is exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { ConflictException } from '@nestjs/common'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -63,6 +64,7 @@ import { createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; +import { createTestToursRepo, createTour } from '../../helpers/tours-repos'; let svc: AssignmentsService; beforeAll(async () => { @@ -90,6 +92,7 @@ beforeAll(async () => { await createTestPlacesRepo(testDb), await createTestTripMembersRepo(testDb), await createTestRoadtripViasRepo(testDb), + await createTestToursRepo(testDb), ); }); @@ -119,6 +122,12 @@ function fixture() { return { user, trip, day, place }; } +/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */ +function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') { + testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId); + createTour(testDb, placeId); +} + // ── verifyTripAccess / canEdit ──────────────────────────────────────────────── describe('verifyTripAccess', () => { @@ -267,6 +276,60 @@ describe('createAssignment', () => { expect((await a!).place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]); void trip; }); + + it('ASG-SVC-031: rejects a duplicate Tour assignment on the same day but allows another day and ordinary Place duplicates', async () => { + const { trip, day, place: tourPlace } = fixture(); + const secondDay = createDay(testDb, trip.id); + const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }); + addTourFacet(tourPlace.id); + + const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id); + await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException); + await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow('Tour is already assigned to this day'); + const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id); + expect(firstTourAssignment?.day_id).toBe(day.id); + expect(otherDayTourAssignment?.day_id).toBe(secondDay.id); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({ n: 2 }); + + await svc.createAssignment(day.id, ordinaryPlace.id); + await svc.createAssignment(day.id, ordinaryPlace.id); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id)).toEqual({ n: 2 }); + }); + + it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => { + const { day, place } = fixture(); + addTourFacet(place.id); + + const results = await Promise.allSettled([ + svc.createAssignment(day.id, place.id), + svc.createAssignment(day.id, place.id), + ]); + + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toBeInstanceOf(ConflictException); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?').get(day.id, place.id)).toEqual({ n: 1 }); + }); + + it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => { + const { trip, day, place: tourPlace } = fixture(); + const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }); + // Geometry on a plain place (an imported track) must not leak into the Tour fields. + testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id); + addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]'); + + const tour = await svc.createAssignment(day.id, tourPlace.id); + const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id); + expect(tour).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.5,11.5]]' }); + expect(ordinary).toMatchObject({ tour_place_id: null, tour_route_geometry: null }); + + const list = await svc.listDayAssignments(day.id); + expect(list.map(a => [a.tour_place_id, a.tour_route_geometry])).toEqual([ + [tourPlace.id, '[[47,11],[47.5,11.5]]'], + [null, null], + ]); + }); }); // ── listDayAssignments ──────────────────────────────────────────────────────── @@ -372,6 +435,30 @@ describe('moveAssignment', () => { expect((await svc.moveAssignment(a.id, day.id, null)).assignment!.order_index).toBe(0); expect((await svc.moveAssignment(a.id, target.id, 0)).assignment!.order_index).toBe(0); }); + + it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => { + const { trip, day, place } = fixture(); + const target = createDay(testDb, trip.id); + addTourFacet(place.id); + createDayAssignment(testDb, target.id, place.id); + const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 }); + + await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException); + expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ day_id: day.id, order_index: 3 }); + }); + + it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => { + const { trip, day, place } = fixture(); + const target = createDay(testDb, trip.id); + addTourFacet(place.id); + const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); + expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 }); + + const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }); + createDayAssignment(testDb, target.id, ordinary.id); + const second = createDayAssignment(testDb, day.id, ordinary.id); + expect((await svc.moveAssignment(second.id, target.id, 1)).assignment).toMatchObject({ day_id: target.id }); + }); }); // ── participants ────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/common/rowShape.test.ts b/server/tests/unit/nest/common/rowShape.test.ts index 1c3ed1a17a..8d22870d80 100644 --- a/server/tests/unit/nest/common/rowShape.test.ts +++ b/server/tests/unit/nest/common/rowShape.test.ts @@ -54,6 +54,8 @@ function makeRow(overrides: Partial = {}): AssignmentWit phone: '+33 1 2345 6789', stop_type: null, fill_percent: null, + tour_place_id: null, + tour_route_geometry: null, ...overrides, }; } @@ -110,6 +112,13 @@ describe('formatAssignmentWithPlace', () => { expect(result.place.category).toBeNull(); }); + it('keeps the Tour facet fields at the assignment level, null for an ordinary place', () => { + expect(formatAssignmentWithPlace(makeRow(), [], [])).toMatchObject({ tour_place_id: null, tour_route_geometry: null }); + const geometry = '[[48.1,11.5],[48.2,11.6]]'; + const tour = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_route_geometry: geometry }), [], []); + expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry }); + }); + it('passes tags and participants through untouched', () => { const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants); expect(result.place.tags).toEqual(sampleTags); diff --git a/server/tests/unit/nest/day-removal.service.test.ts b/server/tests/unit/nest/day-removal.service.test.ts index e13c9e687c..7748b3d2e2 100644 --- a/server/tests/unit/nest/day-removal.service.test.ts +++ b/server/tests/unit/nest/day-removal.service.test.ts @@ -55,6 +55,7 @@ import { createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; +import { createTestToursRepo } from '../../helpers/tours-repos'; let days: DaysService; let journey: JourneyDomainService; @@ -97,6 +98,7 @@ beforeAll(async () => { await createTestPlacesRepo(testDb), await createTestTripMembersRepo(testDb), await createTestRoadtripViasRepo(testDb), + await createTestToursRepo(testDb), ); accommodations = new AccommodationsService( permissions, realtime, assignments, await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index a1907b4b59..d0dc4933a0 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -51,6 +51,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, createDayNote, createTag, createReservation } from '../../helpers/factories'; +import { createTour } from '../../helpers/tours-repos'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DaysService, DayReorderError, DayAppendError, NO_DATES_MESSAGE, addDays } from '../../../src/nest/days/days.service'; // Was days.bridge, deleted with the other three that had no consumer outside the @@ -125,6 +126,24 @@ describe('verifyTripAccess', () => { // ── getAssignmentsForDay ────────────────────────────────────────────────────── describe('getAssignmentsForDay', () => { + it('DAY-SVC-TOUR-001: projects only facet-backed Tour geometry in both day read paths', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const tour = createPlace(testDb, trip.id, { name: 'Hike', lat: 48.1, lng: 11.1 }); + const track = createPlace(testDb, trip.id, { name: 'Legacy track', lat: 48.2, lng: 11.2 }); + const geometry = JSON.stringify([[48.1, 11.1], [48.3, 11.3]]); + testDb.prepare('UPDATE places SET route_geometry = ? WHERE id IN (?, ?)').run(geometry, tour.id, track.id); + createTour(testDb, tour.id); + createDayAssignment(testDb, day.id, tour.id, { order_index: 0 }); + createDayAssignment(testDb, day.id, track.id, { order_index: 1 }); + + for (const assignments of [await svc.getAssignmentsForDay(day.id), (await svc.list(trip.id)).days[0].assignments]) { + expect(assignments?.[0]).toMatchObject({ tour_place_id: tour.id, tour_route_geometry: geometry }); + expect(assignments?.[1]).toMatchObject({ tour_place_id: null, tour_route_geometry: null }); + } + }); + it('DAY-SVC-003 — returns empty array when day has no assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 90b580663d..dac2cea9e0 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -1,7 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { legacyDatabaseAccess } = vi.hoisted(() => ({ + legacyDatabaseAccess: vi.fn((property: string | symbol): never => { + throw new Error(`Unexpected legacy database access: ${String(property)}`); + }), +})); + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'test-only-inert-key', + JWT_SECRET: 'test-only-inert-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: new Proxy({}, { + get: (_target, property: string | symbol) => legacyDatabaseAccess(property), + }), +})); + import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { HttpException } from '@nestjs/common'; import { PlacesController } from '../../../src/nest/places/places.controller'; +import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard'; import type { PlacesService } from '../../../src/nest/places/places.service'; import type { StorageService } from '../../../src/nest/storage/storage.service'; import type { User } from '../../../src/types'; @@ -35,6 +54,10 @@ async function thrownAsync(fn: () => Promise): Promise<{ status: number beforeEach(() => vi.spyOn(console, 'error').mockImplementation(() => {})); describe('PlacesController (parity with the legacy /api/trips/:tripId/places route)', () => { + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + // The trip 404 for this handler is TripAccessGuard's now (see // trip-access.guard.test.ts and the places e2e), so it is no longer reachable // by calling the method directly. @@ -248,14 +271,21 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // element before the handler runs. it('returns empty for an empty list without touching the service', async () => { const removeMany = vi.fn(); - expect(await new PlacesController(svc({ removeMany } as Partial), new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 }); + expect(await new PlacesController(svc({ removeMany } as Partial), new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0, tourPlaceIds: [] }); + expect(removeMany).not.toHaveBeenCalled(); + }); + it('requires place_edit before a bulk deletion can reach the service', async () => { + const removeMany = vi.fn(); + const s = svc({ canEdit: vi.fn().mockReturnValue(false), removeMany } as Partial); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1] }))) + .toEqual({ status: 403, body: { error: 'No permission' } }); expect(removeMany).not.toHaveBeenCalled(); }); it('deletes, fires hooks + broadcasts per deleted id', async () => { - const removeMany = vi.fn().mockReturnValue({ deleted: [1, 2], cancelled: { reservationIds: [], budgetItemIds: [] } }); const onDeleted = vi.fn(); const broadcast = vi.fn(); + const removeMany = vi.fn().mockReturnValue({ deleted: [1, 2], deletedTourPlaceIds: [2], cancelled: { reservationIds: [], budgetItemIds: [] } }); const onDeleted = vi.fn(); const broadcast = vi.fn(); const scopedIds = vi.fn().mockReturnValue([1, 2]); const s = svc({ removeMany, onDeleted, broadcast, scopedIds } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2 }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [2] }); expect(onDeleted).toHaveBeenCalledTimes(2); expect(broadcast).toHaveBeenCalledTimes(2); }); @@ -287,7 +317,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); const removeMany = vi.fn(async () => { order.push('delete'); - return { deleted: [1], cancelled: { reservationIds: [], budgetItemIds: [] } }; + return { deleted: [1], deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } }; }); const s = svc({ removeMany, onDeleted, scopedIds: vi.fn().mockReturnValue([1]), broadcast: vi.fn() } as Partial); await new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1] }); @@ -489,8 +519,12 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(await thrownAsync(() => new PlacesController(svc({ remove, onDeleted } as Partial), new RuntimeEnvService(), storageStub).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); expect(onDeleted).toHaveBeenCalledWith(9); expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]); - const s = svc({ remove: vi.fn().mockReturnValue({ deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } }), broadcast: vi.fn() } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({ success: true }); + const s = svc({ remove: vi.fn().mockReturnValue({ deleted: true, deletedTourPlaceIds: [9], cancelled: { reservationIds: [], budgetItemIds: [] } }), broadcast: vi.fn() } as Partial); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({ success: true, tourPlaceIds: [9] }); + }); + + it('requires place_edit for single Place/Tour deletion', () => { + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit'); }); // invocationCallOrder in the case above only proves the hook was CALLED @@ -506,7 +540,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); const remove = vi.fn(async () => { order.push('delete'); - return { deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } }; + return { deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } }; }); const s = svc({ remove, onDeleted, broadcast: vi.fn() } as Partial); await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9'); @@ -597,7 +631,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); }); }); - describe('PUT /:id/image/from-file (#1242)', () => { it('maps every refusal to its status and broadcasts a success', async () => { const make = (result: unknown, extra: Partial = {}) => diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 25838c8f0a..530469f93b 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -187,6 +187,19 @@ describe('list', () => { expect(places).toHaveLength(2); }); + it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tour = createPlace(testDb, trip.id, { name: 'Facet-backed tour' }) as any; + const place = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any; + testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id); + + const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map(item => [item.id, item])); + + expect(byId.get(tour.id)).toMatchObject({ tour_place_id: tour.id }); + expect(byId.get(place.id)).toMatchObject({ tour_place_id: null }); + }); + it('PLACE-SVC-003 — does not return places from other trips', async () => { const { user } = createUser(testDb); const t1 = createTrip(testDb, user.id); @@ -647,6 +660,42 @@ describe('remove', () => { expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]); }); + it('PLACE-SVC-TOURS-001 — reports Tour deletion and cascades only its facet, waypoints, assignments, and links', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any; + const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any; + const keptPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any; + for (const place of [tour, keptTour]) { + testDb.prepare(`INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (?, 'hike', 4)`).run(place.id); + testDb.prepare(`INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 48, 11, 'start', 0)`).run(place.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)').run(day.id, place.id); + } + const tourFileId = Number(testDb.prepare(` + INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'tour.gpx', 'tour.gpx') + `).run(trip.id, tour.id).lastInsertRowid); + const keptFileId = Number(testDb.prepare(` + INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'kept.gpx', 'kept.gpx') + `).run(trip.id, keptTour.id).lastInsertRowid); + testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(tourFileId, tour.id); + + const result = await svc.remove(String(trip.id), String(tour.id)); + + expect(result).toMatchObject({ deleted: true, deletedTourPlaceIds: [tour.id] }); + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(tour.id)).toBeUndefined(); + expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(tour.id)).toBeUndefined(); + expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(tour.id)).toEqual([]); + expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(tour.id)).toEqual([]); + expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(tourFileId)).toMatchObject({ id: tourFileId, place_id: null }); + expect(testDb.prepare('SELECT id FROM file_links WHERE file_id = ?').all(tourFileId)).toEqual([]); + expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy(); + expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(keptTour.id)).toHaveLength(1); + expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(keptTour.id)).toHaveLength(1); + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptPlace.id)).toBeTruthy(); + expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(keptFileId)).toMatchObject({ id: keptFileId, place_id: keptTour.id }); + }); + it('PLACE-SVC-019e — a place with no booking is untouched by that', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -752,6 +801,24 @@ describe('remove', () => { // ── removeMany ──────────────────────────────────────────────────────────────── describe('removeMany', () => { + it('PLACE-SVC-TOURS-003 — mixed bulk deletion reports only the deleted Tour facets', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any; + const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any; + const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any; + for (const place of [tour, keptTour]) { + testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(place.id); + } + + const result = await svc.removeMany(String(trip.id), [ordinary.id, tour.id]); + + expect(result.deleted).toEqual([ordinary.id, tour.id]); + expect(result.deletedTourPlaceIds).toEqual([tour.id]); + expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy(); + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptTour.id)).toBeTruthy(); + }); + it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/tours.controller.test.ts b/server/tests/unit/nest/tours.controller.test.ts new file mode 100644 index 0000000000..d550232f52 --- /dev/null +++ b/server/tests/unit/nest/tours.controller.test.ts @@ -0,0 +1,228 @@ +/** + * ToursController and ToursImportController: route metadata, delegation to + * ToursService, and the GPX import's handler-level checks. The import is not + * guarded (a guard answers before multer drains the body and the client sees + * ECONNRESET), so its handler has to reproduce the guards' answers itself, in + * the same order and with the same bodies (parity with AddonGuard and + * TripAccessGuard). + */ +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; +import { HttpException, RequestMethod } from '@nestjs/common'; +import { GUARDS_METADATA, HTTP_CODE_METADATA, INTERCEPTORS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; + +const { legacyDatabaseAccess } = vi.hoisted(() => ({ + legacyDatabaseAccess: vi.fn((property: string | symbol): never => { + throw new Error(`Unexpected legacy database access: ${String(property)}`); + }), +})); + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'test-only-inert-key', + JWT_SECRET: 'test-only-inert-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: new Proxy({}, { + get: (_target, property: string | symbol) => legacyDatabaseAccess(property), + }), +})); +vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} })); +vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} })); +vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} })); +vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} })); +vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); + +import type { TourCreateRequest } from '@trek/shared'; +import { ToursController } from '../../../src/nest/tours/tours.controller'; +import { ToursImportController } from '../../../src/nest/tours/tours-import.controller'; +import type { ToursService } from '../../../src/nest/tours/tours.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import type { User } from '../../../src/types'; + +const request: TourCreateRequest = { + name: 'Ridge walk', + tour_type: 'hike' as const, + route_geometry: [[48, 11, 600], [48.02, 11.04, 630]] as [number, number, number][], + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end' as const, sequence: 1 }, + ], + max_hiking_difficulty: 2, + duration_seconds: 3600, +}; + +const user = { id: 2, role: 'user', email: 'u@example.test' } as User; +const trip = { id: 7, user_id: 1 }; +const file = { buffer: Buffer.from(''), originalname: 'ridge.gpx' } as Express.Multer.File; + +async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected throw'); +} + +function importFixture(o: { addon?: boolean; trip?: typeof trip | undefined; canEdit?: boolean; result?: unknown } = {}) { + const addons = { isAddonEnabled: vi.fn(async () => o.addon ?? true) }; + const places = { + verifyTripAccess: vi.fn(async () => ('trip' in o ? o.trip : trip)), + canEdit: vi.fn(async () => o.canEdit ?? true), + }; + const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 }; + const tours = { importGpxAsTour: vi.fn(async () => result) }; + const controller = new ToursImportController( + tours as unknown as ToursService, + places as unknown as PlacesService, + addons as unknown as AddonsService, + ); + return { addons, places, tours, controller, result }; +} + +describe('ToursController planner routes', () => { + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + + it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => { + expect(Reflect.getMetadata(PATH_METADATA, ToursController)).toBe('api/trips/:tripId/tours'); + expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]); + expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' }); + }); + + it('TOURS-CTL-001b: routes, methods and write permissions', () => { + const routes = [ + ['list', RequestMethod.GET, '/', undefined], + ['detail', RequestMethod.GET, ':placeId', undefined], + ['create', RequestMethod.POST, '/', 'place_edit'], + ['update', RequestMethod.PUT, ':placeId', 'place_edit'], + ] as const; + for (const [handler, method, path, permission] of routes) { + const fn = ToursController.prototype[handler]; + expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method); + expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path); + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission); + } + // POST keeps Nest's default 201, as the PR's route answered. + expect(Reflect.getMetadata(HTTP_CODE_METADATA, ToursController.prototype.create)).toBeUndefined(); + }); + + it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(await controller.create('7', request, 'socket-1')).toBe(result); + expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1'); + }); + + it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(await controller.detail('7', '1')).toBe(result); + expect(service.getTour).toHaveBeenCalledWith('7', '1'); + }); + + it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(await controller.update('7', '1', request, 'socket-2')).toBe(result); + expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2'); + }); + + it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => { + const tours = [{ place_id: 1 }]; + const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService; + + expect(await new ToursController(service).list('7')).toEqual({ tours }); + expect(service.listTours).toHaveBeenCalledWith('7'); + }); +}); + +describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', () => { + it('TOURS-IMP-001: the multipart route carries only the JWT guard and checks the rest in the handler', () => { + expect(Reflect.getMetadata(PATH_METADATA, ToursImportController)).toBe('api/trips/:tripId/tours'); + expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]); + expect(Reflect.getMetadata(REQUIRE_ADDON, ToursImportController)).toBeUndefined(); + const handler = ToursImportController.prototype.importGpx; + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx'); + expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined(); + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined(); + expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(1); + }); + + it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => { + const { controller, tours, places, result } = importFixture(); + + expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result); + expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id); + expect(places.canEdit).toHaveBeenCalledWith(trip, user); + expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket'); + }); + + it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => { + const allSkipped = { tours: [], caution: false, skipped: 2 }; + const { controller } = importFixture({ result: allSkipped }); + expect(await controller.importGpx(user, '7', file)).toBe(allSkipped); + }); + + it('TOURS-IMP-004: addon off is the AddonGuard 404 before any trip lookup', async () => { + const { controller, places, tours, addons } = importFixture({ addon: false }); + + expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({ + status: 404, body: { error: 'Tours addon is not enabled' }, + }); + expect(addons.isAddonEnabled).toHaveBeenCalledWith('tours'); + expect(places.verifyTripAccess).not.toHaveBeenCalled(); + expect(tours.importGpxAsTour).not.toHaveBeenCalled(); + }); + + it('TOURS-IMP-005: an inaccessible trip is the TripAccessGuard 404 before any permission check', async () => { + const { controller, places, tours } = importFixture({ trip: undefined }); + + expect(await thrownAsync(() => controller.importGpx(user, '8', file))).toEqual({ + status: 404, body: { error: 'Trip not found' }, + }); + expect(places.verifyTripAccess).toHaveBeenCalledWith('8', user.id); + expect(places.canEdit).not.toHaveBeenCalled(); + expect(tours.importGpxAsTour).not.toHaveBeenCalled(); + }); + + it('TOURS-IMP-006: without place_edit it is the TripAccessGuard 403, checked before the file', async () => { + const { controller, tours } = importFixture({ canEdit: false }); + + expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({ + status: 403, body: { error: 'No permission' }, + }); + expect(tours.importGpxAsTour).not.toHaveBeenCalled(); + }); + + it('TOURS-IMP-007: 400 without a file', async () => { + const { controller, tours } = importFixture(); + + expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({ + status: 400, body: { error: 'No file uploaded' }, + }); + expect(tours.importGpxAsTour).not.toHaveBeenCalled(); + }); + + it('TOURS-IMP-008: 400 when the GPX has no track or route', async () => { + const { controller } = importFixture({ result: null }); + + expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({ + status: 400, body: { error: 'No track or route found in GPX file' }, + }); + }); +}); diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts new file mode 100644 index 0000000000..2a19b6dabe --- /dev/null +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -0,0 +1,297 @@ +/** + * The tours-mode GPX import end to end through the real PlacesService and + * ToursService over the migrated per-worker DB: the places, their track + * colours and their tours facets commit in one transaction (PlacesService's + * importPreparedGpx nests in ToursService's as a savepoint), any failure on + * the way rolls all of it back, and the realtime events go out only after the + * commit. Failures are injected with SQLite triggers on the real tables. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; +import { Logger } from '@nestjs/common'; + +vi.mock('../../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { db as testDb } from '../../../src/db/database'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; +import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; +import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { PlacesService } from '../../../src/nest/places/places.service'; +import { MapsService } from '../../../src/nest/maps/maps.service'; +import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service'; +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { gpxParser } from '../../../src/nest/places/places.helpers'; +import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; +import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTrip, createUser } from '../../helpers/factories'; +import { accommodationsOver } from '../../helpers/accommodations-service'; +import { makeStorageFixture } from '../../helpers/storage-fixture'; +import { noGoogleQuota } from '../../helpers/google-quota'; +import { + createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, + createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, + createTestCategoriesRepo, createTestTripsRepo, createTestCollectionPlacesRepo, sharedTestOrm, +} from '../../helpers/test-uow'; +import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; +import { + createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, + createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, +} from '../../helpers/journey-repos'; +import { createTestToursRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos'; + +const mixedGpx = Buffer.from(` + Ignored POI + Route description + RidgeTrack description + 100 + 150 + 120 +`); + +const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color']; + +/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */ +async function makePlacesService(): Promise { + const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService; + const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository; + const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository; + const storage = makeStorageFixture('').storage; + const orm = await sharedTestOrm(testDb); + return new PlacesService( + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), + new RealtimeService(), + new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota), + new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), + new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), storage), + photoCacheStub, + new JourneyDomainService( + new RealtimeService(), new TrekPhotoRegistrationService(orm.repo(TrekPhotos), orm.repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb), + await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb), + await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb), + await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb), + ), + storage, + await accommodationsOver(testDb), await createTestUnitOfWork(testDb), + await createTestPlacesRepo(testDb), + await createTestTagsRepo(testDb), + await createTestPlaceRatingsRepo(testDb), + await createTestTripMembersRepo(testDb), + await createTestDayAssignmentsRepo(testDb), + await createTestCategoriesRepo(testDb), + await createTestTripsRepo(testDb), + await createTestBudgetItemsRepo(testDb), + await createTestCollectionPlacesRepo(testDb), + ); +} + +let uow: UnitOfWork; +let places: PlacesService; +let tours: ToursService; +let tripId: string; +let broadcast: MockInstance; + +const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; + +beforeAll(async () => { + uow = await createTestUnitOfWork(testDb); + places = await makePlacesService(); + tours = new ToursService( + uow, places, await createTestToursRepo(testDb), await createTestTourWaypointsRepo(testDb), await createTestPlacesRepo(testDb), + ); +}); + +beforeEach(async () => { + resetTestDb(testDb); + (await sharedTestOrm(testDb)).clear(); + const { user } = createUser(testDb); + tripId = String(createTrip(testDb, user.id).id); + broadcast = vi.spyOn(places, 'broadcast').mockImplementation(() => {}); +}); + +afterEach(() => { + for (const name of TRIGGERS) testDb.exec(`DROP TRIGGER IF EXISTS ${name}`); + vi.restoreAllMocks(); +}); + +afterAll(() => { testDb.close(); }); + +describe('Tours GPX atomic persistence and postcommit publication', () => { + function expectEmpty() { + for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0); + expect(broadcast).not.toHaveBeenCalled(); + } + + it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => { + const transactional = vi.spyOn(uow, 'transactional'); + const seen: { inTransaction: boolean; tours: number }[] = []; + broadcast.mockImplementation(() => { seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') }); }); + + const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!; + + // The tours transaction, with importPreparedGpx and its colouring nested inside as savepoints. + expect(transactional.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(result.tours).toHaveLength(2); + expect(result.skipped).toBe(0); + expect(result.caution).toBe(true); + expect(result.tours[0]).toMatchObject({ name: 'walk', tour_type: 'hike', max_hiking_difficulty: 2, planned: false, has_waypoints: false, caution: true, match_confidence: 0.3 }); + expect(result.tours[1]).toMatchObject({ name: 'Ridge', elevation_gain: 50, elevation_loss: 30, caution: false, match_confidence: 1 }); + + const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as { id: number; trip_id: number; description: string; route_geometry: string; route_color: string }[]; + expect(rows.map(row => row.trip_id)).toEqual([Number(tripId), Number(tripId)]); + expect(rows.map(row => row.description)).toEqual(['Route description', 'Track description']); + expect(JSON.parse(rows[0].route_geometry)).toEqual([[48, 11], [48.01, 11.01]]); + expect(JSON.parse(rows[1].route_geometry)).toEqual([[49, 12, 100], [49.01, 12.01, 150], [49.02, 12.02, 120]]); + expect(new Set(rows.map(row => row.route_color)).size).toBe(2); + expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all()) + .toEqual(rows.map(row => ({ place_id: row.id, tour_type: 'hike' }))); + expect(count('tour_waypoints')).toBe(0); + + expect(seen).toEqual([ + { inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 }, + ]); + expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']); + expect(broadcast.mock.calls[0]).toEqual([tripId, 'tours:changed', { placeIds: rows.map(row => row.id) }, 'socket']); + for (const call of broadcast.mock.calls.slice(1)) { + expect(call[3]).toBe('socket'); + expect((call[2] as { place: { route_color: string } }).place.route_color).toBeTruthy(); + } + expect(broadcast.mock.calls.slice(1).map(call => (call[2] as { place: { id: number } }).place.id)) + .toEqual(result.tours.map(tour => tour.place_id)); + }); + + it.each([1, 2])('rolls back every carrier and facet when facet %i fails', async (ordinal) => { + testDb.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours + WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1} + BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`); + await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure'); + expectEmpty(); + }); + + it('rolls back earlier carriers when the later Place insert fails', async () => { + testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge' + BEGIN SELECT RAISE(ABORT, 'place failure'); END;`); + await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure'); + expectEmpty(); + }); + + it('rolls back carriers and earlier colors when later coloring fails', async () => { + testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge' + BEGIN SELECT RAISE(ABORT, 'color failure'); END;`); + await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure'); + expectEmpty(); + }); + + it.each(['', '', '', ''])('does not write or publish unusable input %s', async (xml) => { + const transactional = vi.spyOn(uow, 'transactional'); + expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull(); + expect(transactional).not.toHaveBeenCalled(); + expectEmpty(); + }); + + it('propagates parser failures before entering the transaction', async () => { + const transactional = vi.spyOn(uow, 'transactional'); + vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => { throw new Error('parser failure'); }); + await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure'); + expect(transactional).not.toHaveBeenCalled(); + expectEmpty(); + }); + + it('keeps a committed response and attempts remaining events if publication throws', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + broadcast.mockImplementationOnce(() => { throw new Error('transport unavailable'); }); + expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); + expect(count('tours')).toBe(2); + expect(broadcast).toHaveBeenCalledTimes(3); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('keeps committed rows and success response if a place event fails', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + broadcast.mockImplementation((_tripId, event) => { + if (event === 'place:created') throw new Error('transport unavailable'); + }); + + expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); + expect(count('places')).toBe(2); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('keeps committed rows and success response if the Tours invalidation event fails', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + broadcast.mockImplementation((_tripId, event) => { + if (event === 'tours:changed') throw new Error('invalidation unavailable'); + }); + + const result = await tours.importGpxAsTour(tripId, mixedGpx); + + expect(result?.tours).toHaveLength(2); + expect(count('places')).toBe(2); + expect(count('tours')).toBe(2); + expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('returns a successful skipped result for duplicate-only imports without publishing events', async () => { + expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); + const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all(); + const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all(); + broadcast.mockClear(); + + expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 }); + expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore); + expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('imports only the new tracks and publishes only committed rows in a mixed result', async () => { + const existing = Buffer.from(`Ridge + 100150 + `); + expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1); + broadcast.mockClear(); + + const result = await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'); + + expect(result?.tours).toHaveLength(1); + expect(result?.tours[0].name).toBe('walk'); + expect(result?.skipped).toBe(1); + expect(count('places')).toBe(2); + expect(count('tours')).toBe(2); + const walkId = result!.tours[0].place_id; + expect(broadcast.mock.calls.map(call => [call[1], call[2]])).toEqual([ + ['tours:changed', { placeIds: [walkId] }], + ['place:created', expect.objectContaining({ place: expect.objectContaining({ id: walkId, name: 'walk' }) })], + ]); + }); + + it('preserves single-track response and trip scoping', async () => { + const { user } = createUser(testDb); + const otherTripId = String(createTrip(testDb, user.id).id); + const single = Buffer.from(''); + + const result = (await tours.importGpxAsTour(otherTripId, single, 'single.gpx'))!; + + expect(result.tours).toHaveLength(1); + expect(result.tours[0].name).toBe('single'); + expect(result.caution).toBe(true); + expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]); + expect((await tours.listTours(otherTripId)).map(t => t.place_id)).toEqual([result.tours[0].place_id]); + expect(await tours.listTours(tripId)).toEqual([]); + expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created']); + }); +}); diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts new file mode 100644 index 0000000000..b515e4ed88 --- /dev/null +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -0,0 +1,273 @@ +/** + * Tours reuse the ordinary place and day-assignment endpoints for deletion and + * planning, so their permissions are those endpoints' permissions: deleting a + * tour needs 'place_edit' only, assigning or unassigning one needs 'day_edit' + * only. Run through the real TripAccessGuard with mocked collaborators (no DB). + * The last block pins ToursService's trip boundary on update against stubbed + * repositories. + */ +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; +import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { Reflector } from '@nestjs/core'; +import type { EntityManager } from '@mikro-orm/core'; +import type { TourCreateRequest } from '@trek/shared'; + +const { legacyDatabaseAccess } = vi.hoisted(() => ({ + legacyDatabaseAccess: vi.fn((property: string | symbol): never => { + throw new Error(`Unexpected legacy database access: ${String(property)}`); + }), +})); + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'test-only-inert-key', + JWT_SECRET: 'test-only-inert-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: new Proxy({}, { + get: (_target, property: string | symbol) => legacyDatabaseAccess(property), + }), +})); +vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} })); +vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} })); +vi.mock('../../../src/nest/assignments/assignments.service', () => ({ AssignmentsService: class {} })); +vi.mock('../../../src/nest/app-config/runtime-env.service', () => ({ RuntimeEnvService: class {} })); +vi.mock('../../../src/nest/storage/storage.service', () => ({ StorageService: class {} })); +vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); + +import { PlacesController } from '../../../src/nest/places/places.controller'; +import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller'; +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard'; +import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import type { StorageService } from '../../../src/nest/storage/storage.service'; +import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; +import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; +import type { ToursRepository } from '../../../src/db/repositories/Tours.repository'; +import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository'; +import type { User } from '../../../src/types'; + +const matrix = [[false, false], [true, false], [false, true], [true, true]] as const; + +function authorization(controller: typeof PlacesController | typeof DayAssignmentsController, handler: 'create' | 'remove', canEdit: boolean, canAssign: boolean, tripId = '7') { + const request = { params: { tripId }, user: { id: 2, role: 'user' } as User }; + const trip = { id: 7, user_id: 1 }; + const access = vi.fn(async (id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined); + const permission = vi.fn(async (action: string) => action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign); + const context = { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => controller.prototype[handler], + getClass: () => controller, + } as unknown as ExecutionContext; + const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager; + const guard = new TripAccessGuard(em, { checkPermission: permission } as unknown as PermissionsService, new Reflector()); + return { request, access, permission, context, guard }; +} + +async function rejected(action: () => Promise, status: number) { + await expect(action()).rejects.toMatchObject({ status }); +} + +function placesFixture() { + const places = { + get: vi.fn(async () => ({ id: 42, trip_id: 7 })), + onDeleted: vi.fn(async () => {}), + linkedExpenseIds: vi.fn(async () => []), + remove: vi.fn(async () => ({ deleted: true, deletedTourPlaceIds: [42], cancelled: { reservationIds: [], budgetItemIds: [] } })), + broadcast: vi.fn(), + }; + const controller = new PlacesController( + places as unknown as PlacesService, {} as RuntimeEnvService, {} as StorageService, + ); + return { places, controller }; +} + +function assignmentsFixture() { + const assignment = { id: 51, place_id: 42, day_id: 11 }; + const assignments = { + dayExists: vi.fn(async () => true), + placeExists: vi.fn(async () => true), + assignmentExistsInDay: vi.fn(async () => true), + createAssignment: vi.fn(async () => assignment), + deleteAssignment: vi.fn(async () => {}), + broadcast: vi.fn(), + reconcile: vi.fn(async () => {}), + }; + return { assignment, assignments, controller: new DayAssignmentsController(assignments as unknown as AssignmentsService) }; +} + +describe('Tours deletion and assignment authorization (mock-only)', () => { + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + + it('keeps existing Places deletion and day assignment endpoints wired to their exact permissions', () => { + expect(Reflect.getMetadata(PATH_METADATA, PlacesController)).toBe('api/trips/:tripId/places'); + expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController)).toEqual([JwtAuthGuard]); + expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController.prototype.remove)).toEqual([TripAccessGuard]); + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit'); + expect(Reflect.getMetadata(METHOD_METADATA, PlacesController.prototype.remove)).toBe(RequestMethod.DELETE); + expect(Reflect.getMetadata(PATH_METADATA, PlacesController.prototype.remove)).toBe(':id'); + expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController)).toBe('api/trips/:tripId/days/:dayId/assignments'); + expect(Reflect.getMetadata(GUARDS_METADATA, DayAssignmentsController)).toEqual([JwtAuthGuard, TripAccessGuard]); + for (const handler of ['create', 'remove'] as const) { + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, DayAssignmentsController.prototype[handler])).toBe('day_edit'); + } + expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.create)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.remove)).toBe(RequestMethod.DELETE); + expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController.prototype.remove)).toBe(':id'); + }); + + it.each(matrix)('edit=%s assign=%s: tour deletion requires only place_edit', async (canEdit, canAssign) => { + const auth = authorization(PlacesController, 'remove', canEdit, canAssign); + const { places, controller } = placesFixture(); + const invoke = async () => { + await auth.guard.canActivate(auth.context); + return await controller.remove(auth.request.user, '7', '42', 'socket'); + }; + if (canEdit) { + await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [42] }); + expect(places.get).toHaveBeenCalledWith('7', '42'); + expect(places.onDeleted).toHaveBeenCalledWith(42); + expect(places.remove).toHaveBeenCalledWith('7', '42'); + expect(places.broadcast).toHaveBeenCalledWith('7', 'place:deleted', { placeId: 42 }, 'socket'); + } else { + await rejected(invoke, 403); + for (const method of Object.values(places)) expect(method).not.toHaveBeenCalled(); + } + expect(auth.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true); + }); + + describe.each(['create', 'remove'] as const)('assignment %s', handler => { + it.each(matrix)('edit=%s assign=%s: requires only day_edit', async (canEdit, canAssign) => { + const auth = authorization(DayAssignmentsController, handler, canEdit, canAssign); + const { assignment, assignments, controller } = assignmentsFixture(); + const invoke = async () => { + await auth.guard.canActivate(auth.context); + return handler === 'create' + ? await controller.create(auth.request.user, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket') + : await controller.remove(auth.request.user, '7', '11', '51', 'socket'); + }; + if (canAssign) { + expect(await invoke()).toEqual(handler === 'create' ? { assignment } : { success: true }); + if (handler === 'create') { + expect(assignments.dayExists).toHaveBeenCalledWith('11', '7'); + expect(assignments.placeExists).toHaveBeenCalledWith(42, '7'); + expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour'); + expect(assignments.deleteAssignment).not.toHaveBeenCalled(); + expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:created', { assignment }, 'socket'); + } else { + expect(assignments.assignmentExistsInDay).toHaveBeenCalledWith('51', '11', '7'); + expect(assignments.deleteAssignment).toHaveBeenCalledWith('51'); + expect(assignments.createAssignment).not.toHaveBeenCalled(); + expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:deleted', { assignmentId: 51, dayId: 11 }, 'socket'); + } + expect(assignments.reconcile).toHaveBeenCalledWith('7', 'socket'); + } else { + await rejected(invoke, 403); + for (const method of Object.values(assignments)) expect(method).not.toHaveBeenCalled(); + } + expect(auth.permission).toHaveBeenCalledExactlyOnceWith('day_edit', 'user', 1, 2, true); + }); + }); + + it.each([ + [PlacesController, 'remove'], + [DayAssignmentsController, 'create'], + [DayAssignmentsController, 'remove'], + ] as const)('%s.%s rejects an inaccessible route trip before checking permissions', async (owner, handler) => { + const auth = authorization(owner, handler, true, true, '8'); + await rejected(() => auth.guard.canActivate(auth.context), 404); + expect(auth.access).toHaveBeenCalledExactlyOnceWith(8, 2); + expect(auth.permission).not.toHaveBeenCalled(); + }); +}); + +const input: TourCreateRequest = { + name: 'Ridge walk', tour_type: 'hike', max_hiking_difficulty: 2, + route_geometry: [[48, 11, 600], [48.02, 11.04, 630]], + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, + ], +}; + +/** Tour 42 belongs to `ownerTripId`; findInTrip answers only for that trip. */ +function tourRepositoryFixture(ownerTripId: number) { + const row = { + place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 3, + elevation_gain: 30, elevation_loss: 0, duration: null, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: 0, has_waypoints: 1, + }; + const uow = { transactional: vi.fn(async (fn: () => Promise): Promise => await fn()) }; + const toursRepo = { + findInTrip: vi.fn(async (tripId: number, placeId: number) => tripId === ownerTripId && placeId === 42 ? row : undefined), + updateInTrip: vi.fn(async () => true), + }; + const waypointsRepo = { + listForPlace: vi.fn(async () => input.waypoints), + deleteForPlace: vi.fn(async () => {}), + insertForPlace: vi.fn(async () => {}), + }; + const placesRepo = { + updateTourRoute: vi.fn(async () => true), + findInTrip: vi.fn(async () => undefined), + findWithTagsAndRatings: vi.fn(async () => ({ id: 42, trip_id: ownerTripId })), + }; + const places = { broadcast: vi.fn() }; + const service = new ToursService( + uow as unknown as UnitOfWork, + places as unknown as PlacesService, + toursRepo as unknown as ToursRepository, + waypointsRepo as unknown as TourWaypointsRepository, + placesRepo as unknown as PlacesRepository, + ); + return { uow, toursRepo, waypointsRepo, placesRepo, places, service }; +} + +describe('Real ToursService update trip boundary (stubbed repositories)', () => { + it('rejects a placeId belonging to another trip before any write', async () => { + const f = tourRepositoryFixture(8); + expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42); + f.toursRepo.findInTrip.mockClear(); + f.waypointsRepo.listForPlace.mockClear(); + + const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e); + + expect(error).toBeInstanceOf(NotFoundException); + expect((error as HttpException).getStatus()).toBe(404); + expect((error as Error).message).toBe('Tour not found'); + expect(f.toursRepo.findInTrip).toHaveBeenCalledExactlyOnceWith(7, 42); + for (const method of [ + f.placesRepo.updateTourRoute, f.toursRepo.updateInTrip, f.waypointsRepo.deleteForPlace, + f.waypointsRepo.insertForPlace, f.waypointsRepo.listForPlace, f.placesRepo.findWithTagsAndRatings, f.places.broadcast, + ]) { + expect(method).not.toHaveBeenCalled(); + } + }); + + it('allows a same-trip update through the transaction with trip-scoped writes', async () => { + const f = tourRepositoryFixture(7); + + expect((await f.service.updateTour('7', '42', input, 'socket')).tour.place_id).toBe(42); + + expect(f.uow.transactional).toHaveBeenCalledOnce(); + // Once inside the write, once for the response. + expect(f.toursRepo.findInTrip).toHaveBeenCalledTimes(2); + expect(f.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, { + name: input.name, lat: 48, lng: 11, route_geometry: JSON.stringify(input.route_geometry), + }); + expect(f.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, expect.objectContaining({ tour_type: 'hike', duration: null })); + expect(f.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42); + expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints); + expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42); + expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket'); + }); +}); diff --git a/server/tests/unit/nest/tours.permissions.test.ts b/server/tests/unit/nest/tours.permissions.test.ts new file mode 100644 index 0000000000..5f616d53bd --- /dev/null +++ b/server/tests/unit/nest/tours.permissions.test.ts @@ -0,0 +1,167 @@ +/** + * The Tours permission contract, run through the real guards with mocked + * collaborators (no DB): reads need trip access only, writes need + * 'place_edit' and never 'day_edit', an inaccessible trip is a 404 before any + * permission check, and the addon and JWT refusals stop a request before the + * trip is resolved. The GPX import answers the same matrix from its handler. + */ +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; +import { HttpException, type ExecutionContext } from '@nestjs/common'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { Reflector } from '@nestjs/core'; +import type { EntityManager } from '@mikro-orm/core'; + +const { legacyDatabaseAccess } = vi.hoisted(() => ({ + legacyDatabaseAccess: vi.fn((property: string | symbol): never => { + throw new Error(`Unexpected legacy database access: ${String(property)}`); + }), +})); + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'test-only-inert-key', + JWT_SECRET: 'test-only-inert-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: new Proxy({}, { + get: (_target, property: string | symbol) => legacyDatabaseAccess(property), + }), +})); +vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} })); +vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} })); +vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} })); +vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} })); +vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); + +import { ToursController } from '../../../src/nest/tours/tours.controller'; +import { ToursImportController } from '../../../src/nest/tours/tours-import.controller'; +import { TripAccessGuard, TRIP_PERMISSION_KEY, TRIP_REQUEST_KEY } from '../../../src/nest/permissions/trip-access.guard'; +import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import type { ToursService } from '../../../src/nest/tours/tours.service'; +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify'; +import type { User } from '../../../src/types'; + +const matrix = [[false, false], [true, false], [false, true], [true, true]] as const; +const user = { id: 2, role: 'user' } as User; +const trip = { id: 7, user_id: 1 }; + +/** Trip 7 is reachable for user 2; it belongs to user 1, so every check is a shared-trip check. */ +function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') { + const request = { params: { tripId }, user }; + const access = vi.fn(async (id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined); + const permission = vi.fn(async (action: string) => action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign); + const context = { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => ToursController.prototype[handler], + getClass: () => ToursController, + } as unknown as ExecutionContext; + const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager; + const guard = new TripAccessGuard(em, { checkPermission: permission } as unknown as PermissionsService, new Reflector()); + return { request, access, permission, context, guard }; +} + +/** The import handler's own checks, with the same access and permission doubles. */ +function importFixture(canEdit: boolean, canAssign: boolean) { + const permission = vi.fn(async (action: string, ..._context: unknown[]) => action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign); + const access = vi.fn(async (id: string, userId: number) => id === '7' && userId === 2 ? trip : undefined); + const places = { + verifyTripAccess: access, + canEdit: vi.fn(async (t: typeof trip, u: User) => permission('place_edit', u.role, t.user_id, u.id, t.user_id !== u.id)), + }; + const importGpxAsTour = vi.fn(async () => ({ tours: [{ place_id: 42 }], caution: false, skipped: 0 })); + const controller = new ToursImportController( + { importGpxAsTour } as unknown as ToursService, + places as unknown as PlacesService, + { isAddonEnabled: vi.fn(async () => true) } as unknown as AddonsService, + ); + return { controller, access, permission, importGpxAsTour }; +} + +async function expectStatus(action: () => Promise, status: number) { + try { + await action(); + expect.fail('Expected rejection'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(status); + } +} + +const file = { buffer: Buffer.from('gpx'), originalname: 'ridge.gpx' } as Express.Multer.File; + +describe('Tours permission contract (mock-only, no DB)', () => { + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + + it('retains addon/JWT/trip guard order and exact write metadata', () => { + expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]); + expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]); + for (const handler of ['create', 'update'] as const) { + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBe('place_edit'); + } + for (const handler of ['list', 'detail'] as const) { + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBeUndefined(); + } + }); + + it.each(matrix)('edit=%s assign=%s permits reads and only place-edit writes', async (canEdit, canAssign) => { + for (const handler of ['list', 'detail'] as const) { + const test = fixture(handler, canEdit, canAssign); + expect(await test.guard.canActivate(test.context)).toBe(true); + expect(test.permission).not.toHaveBeenCalled(); + expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(trip); + } + for (const handler of ['create', 'update'] as const) { + const test = fixture(handler, canEdit, canAssign); + if (canEdit) expect(await test.guard.canActivate(test.context)).toBe(true); + else await expectStatus(() => test.guard.canActivate(test.context), 403); + expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true); + } + }); + + it.each(matrix)('edit=%s assign=%s: the GPX import handler requires only place_edit', async (canEdit, canAssign) => { + const test = importFixture(canEdit, canAssign); + if (canEdit) { + expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] }); + expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket'); + } else { + await expectStatus(() => test.controller.importGpx(user, '7', file), 403); + expect(test.importGpxAsTour).not.toHaveBeenCalled(); + } + expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true); + }); + + it.each(['list', 'detail', 'create', 'update'] as const)('%s rejects inaccessible route trips before any write permission check', async handler => { + const test = fixture(handler, true, true, '8'); + await expectStatus(() => test.guard.canActivate(test.context), 404); + expect(test.access).toHaveBeenCalledWith(8, 2); + expect(test.permission).not.toHaveBeenCalled(); + }); + + it('importGpx rejects an inaccessible route trip before any permission check', async () => { + const test = importFixture(true, true); + await expectStatus(() => test.controller.importGpx(user, '8', file), 404); + expect(test.access).toHaveBeenCalledWith('8', 2); + expect(test.permission).not.toHaveBeenCalled(); + expect(test.importGpxAsTour).not.toHaveBeenCalled(); + }); + + it('preserves addon and JWT refusal without reaching trip resolution', async () => { + const test = fixture('create', true, true); + const addon = new AddonGuard({ isAddonEnabled: vi.fn(async () => false) } as unknown as AddonsService, new Reflector()); + await expectStatus(() => addon.canActivate(test.context), 404); + const jwt = new JwtAuthGuard({ getRepository: vi.fn() } as unknown as EntityManager); + vi.mocked(extractToken).mockReturnValue(null); + await expectStatus(() => jwt.canActivate(test.context), 401); + vi.mocked(extractToken).mockReturnValue('token'); + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null); + await expectStatus(() => jwt.canActivate(test.context), 401); + expect(test.access).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts new file mode 100644 index 0000000000..9da8adeebe --- /dev/null +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -0,0 +1,230 @@ +/** + * ToursService write contracts against stubbed repositories and a stub + * UnitOfWork (the budget.service.test.ts pattern): which repository call runs + * with which arguments, and that every write runs inside the transaction while + * the read-back and the broadcasts run after it. Rollback against a real + * SQLite database is covered by tours.service.test.ts. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotFoundException } from '@nestjs/common'; + +const { legacyDatabaseAccess } = vi.hoisted(() => ({ + legacyDatabaseAccess: vi.fn((property: string | symbol): never => { + throw new Error(`Unexpected legacy database access: ${String(property)}`); + }), +})); + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'test-only-inert-key', + JWT_SECRET: 'test-only-inert-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: new Proxy({}, { + get: (_target, property: string | symbol) => legacyDatabaseAccess(property), + }), +})); +vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} })); + +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared'; +import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; +import type { ToursRepository } from '../../../src/db/repositories/Tours.repository'; +import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository'; + +const request: TourCreateRequest = { + name: 'Ridge walk', + tour_type: 'hike', + route_geometry: [[48, 11, 600], [48.01, 11.02, 650], [48.02, 11.04, 630]], + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, + ], + max_hiking_difficulty: 2, + duration_seconds: 3600, +}; + +function makeService() { + const row = { + place_id: 42, name: request.name, tour_type: 'hike', distance: 3, + elevation_gain: 50, elevation_loss: 20, duration: 60, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: 0, has_waypoints: 1, + }; + const place = { id: 42, trip_id: 7 }; + let inTransaction = false; + /** Records whether each write ran inside the transaction. */ + const inTx = (value: T) => vi.fn(async (..._args: unknown[]) => { expect(inTransaction).toBe(true); return value; }); + + const uow = { + transactional: vi.fn(async (fn: () => Promise): Promise => { + inTransaction = true; + try { return await fn(); } finally { inTransaction = false; } + }), + }; + const toursRepo = { + findInTrip: vi.fn(async (): Promise => row), + insertTour: inTx(undefined), + updateInTrip: inTx(true), + }; + const waypointsRepo = { + listForPlace: vi.fn(async () => request.waypoints), + insertForPlace: inTx(undefined), + deleteForPlace: inTx(undefined), + }; + const placesRepo = { + insertTourPlace: inTx(42), + updateTourRoute: inTx(true), + findInTrip: vi.fn(async (): Promise<{ route_geometry: string | null } | undefined> => undefined), + findWithTagsAndRatings: vi.fn(async () => { + expect(inTransaction).toBe(false); + return place; + }), + }; + const places = { broadcast: vi.fn(() => { expect(inTransaction).toBe(false); }) }; + const service = new ToursService( + uow as unknown as UnitOfWork, + places as unknown as PlacesService, + toursRepo as unknown as ToursRepository, + waypointsRepo as unknown as TourWaypointsRepository, + placesRepo as unknown as PlacesRepository, + ); + return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service }; +} + +describe('ToursService planner contracts (mock-only; SQLite rollback semantics are covered in tours.service.test.ts)', () => { + let setup: ReturnType; + beforeEach(() => { setup = makeService(); }); + + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one transaction', async () => { + const result = await setup.service.createTour('7', request, 'socket-1'); + + expect(setup.uow.transactional).toHaveBeenCalledOnce(); + expect(setup.placesRepo.insertTourPlace).toHaveBeenCalledExactlyOnceWith({ + trip_id: 7, name: request.name, lat: 48, lng: 11, route_geometry: JSON.stringify(request.route_geometry), + }); + expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({ + place_id: 42, tour_type: 'hike', distance: expect.any(Number), elevation_gain: 50, elevation_loss: 20, + duration: 60, match_confidence: 1, max_hiking_difficulty: 2, + }); + expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0); + expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints); + expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42); + expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true }); + expect(result.waypoints).toEqual(request.waypoints); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1'); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1'); + }); + + it('TOURS-SVC-002: propagates a waypoint failure without publishing or reading an uncommitted create', async () => { + const failure = new Error('Injected waypoint failure'); + setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure); + + await expect(setup.service.createTour('7', request)).rejects.toBe(failure); + + expect(setup.uow.transactional).toHaveBeenCalledOnce(); + expect(setup.toursRepo.findInTrip).not.toHaveBeenCalled(); + expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled(); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-003: validates and rounds a fractional routed duration before the create write', async () => { + const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 }); + const result = await setup.service.createTour('7', validated); + expect(setup.toursRepo.insertTour).toHaveBeenCalledWith(expect.objectContaining({ place_id: 42, duration: 60 })); + expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false }); + }); + + it('TOURS-SVC-004: scopes detail reads to the trip and derives legacy GPX controls without writes', async () => { + expect((await setup.service.getTour('7', '42')).waypoints).toEqual(request.waypoints); + expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42); + expect(setup.waypointsRepo.listForPlace).toHaveBeenCalledWith(42); + + setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined); + setup.waypointsRepo.listForPlace.mockClear(); + await expect(setup.service.getTour('8', '42')).rejects.toThrow('Tour not found'); + expect(setup.waypointsRepo.listForPlace).not.toHaveBeenCalled(); + + setup.waypointsRepo.listForPlace.mockResolvedValueOnce([]); + setup.placesRepo.findInTrip.mockResolvedValueOnce({ route_geometry: JSON.stringify(request.route_geometry) }); + const legacyGpx = await setup.service.getTour('7', '42'); + expect(setup.placesRepo.findInTrip).toHaveBeenCalledWith(42, 7); + expect(legacyGpx.waypoints).toEqual([ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, + ]); + for (const method of [ + setup.uow.transactional, setup.placesRepo.insertTourPlace, setup.placesRepo.updateTourRoute, + setup.toursRepo.insertTour, setup.toursRepo.updateInTrip, + setup.waypointsRepo.insertForPlace, setup.waypointsRepo.deleteForPlace, setup.places.broadcast, + ]) { + expect(method).not.toHaveBeenCalled(); + } + }); + + it('TOURS-SVC-005: updates route data and replaces controls through one transaction', async () => { + const update: TourCreateRequest = { + ...request, + name: 'Updated ridge walk', + route_geometry: [[49, 12, 700], [49.02, 12.04, 760]], + waypoints: [ + { lat: 49, lng: 12, role: 'start', sequence: 0 }, + { lat: 49.01, lng: 12.02, role: 'via', sequence: 1 }, + { lat: 49.02, lng: 12.04, role: 'end', sequence: 2 }, + ], + duration_seconds: 2700.5, + }; + + await setup.service.updateTour('7', '42', update, 'socket-2'); + + expect(setup.uow.transactional).toHaveBeenCalledOnce(); + expect(setup.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, { + name: update.name, lat: 49, lng: 12, route_geometry: JSON.stringify(update.route_geometry), + }); + expect(setup.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, { + tour_type: 'hike', distance: expect.any(Number), elevation_gain: 60, elevation_loss: 0, + duration: 45, match_confidence: 1, max_hiking_difficulty: 2, + }); + expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42); + expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints); + expect(setup.waypointsRepo.deleteForPlace.mock.invocationCallOrder[0]) + .toBeLessThan(setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0]); + expect(setup.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: setup.place }, 'socket-2'); + }); + + it('TOURS-SVC-006: propagates a replacement failure without publishing or reloading', async () => { + const failure = new Error('Injected replacement failure'); + setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure); + + await expect(setup.service.updateTour('7', '42', request)).rejects.toBe(failure); + + expect(setup.uow.transactional).toHaveBeenCalledOnce(); + expect(setup.toursRepo.findInTrip).toHaveBeenCalledTimes(1); + expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled(); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); + + it.each([ + ['the tour lookup', 'findInTrip'], + ['the trip-scoped place update', 'updateTourRoute'], + ['the trip-scoped tour update', 'updateInTrip'], + ] as const)('TOURS-SVC-010: a miss in %s is a 404 that stops the write', async (_label, step) => { + if (step === 'findInTrip') setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined); + if (step === 'updateTourRoute') setup.placesRepo.updateTourRoute.mockResolvedValueOnce(false); + if (step === 'updateInTrip') setup.toursRepo.updateInTrip.mockResolvedValueOnce(false); + + await expect(setup.service.updateTour('7', '42', request)).rejects.toBeInstanceOf(NotFoundException); + + // The existence check runs inside the write's own transaction (F44). + expect(setup.uow.transactional).toHaveBeenCalledOnce(); + expect(setup.waypointsRepo.deleteForPlace).not.toHaveBeenCalled(); + expect(setup.waypointsRepo.insertForPlace).not.toHaveBeenCalled(); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts new file mode 100644 index 0000000000..f250a64078 --- /dev/null +++ b/server/tests/unit/nest/tours.service.test.ts @@ -0,0 +1,282 @@ +/** + * ToursService over the real migrated per-worker DB: the tours facet, its + * ordered control points and the owning place are written through the Tours, + * TourWaypoints and Places repositories inside one UnitOfWork transaction, and + * realtime events go out only after that transaction committed. + * + * PlacesService is a broadcast stub here; the GPX import path, which needs the + * real PlacesService, is covered by tours.gpx.atomic.test.ts. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Logger, NotFoundException } from '@nestjs/common'; + +vi.mock('../../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { db as testDb } from '../../../src/db/database'; +import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared'; +import { ToursService } from '../../../src/nest/tours/tours.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import { resetTestDb } from '../../helpers/test-db'; +import { createPlace, createTrip, createUser } from '../../helpers/factories'; +import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; +import { createTestToursRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos'; + +const request: TourCreateRequest = { + name: 'Ridge walk', + tour_type: 'hike', + route_geometry: [[48, 11, 600], [48.01, 11.02, 650], [48.02, 11.04, 630]], + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, + ], + max_hiking_difficulty: 2, + duration_seconds: 3600, +}; + +/** Two waypoints on the same sequence: the UNIQUE(place_id, sequence) index rejects the second one. */ +const duplicateSequence = { + ...request, + waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }], +} as TourCreateRequest; + +const broadcast = vi.fn(); +let service: ToursService; +let tripId: string; +let otherTripId: string; + +const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; + +beforeAll(async () => { + service = new ToursService( + await createTestUnitOfWork(testDb), + { broadcast } as unknown as PlacesService, + await createTestToursRepo(testDb), + await createTestTourWaypointsRepo(testDb), + await createTestPlacesRepo(testDb), + ); +}); + +beforeEach(async () => { + resetTestDb(testDb); + (await sharedTestOrm(testDb)).clear(); + broadcast.mockReset(); + const { user } = createUser(testDb); + tripId = String(createTrip(testDb, user.id).id); + otherTripId = String(createTrip(testDb, user.id).id); +}); + +afterEach(() => { vi.restoreAllMocks(); }); + +afterAll(() => { testDb.close(); }); + +describe('ToursService planner creation', () => { + it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', async () => { + const writeStates: boolean[] = []; + broadcast.mockImplementation(() => { writeStates.push(testDb.inTransaction); }); + + const result = await service.createTour(tripId, request, 'socket-1'); + + const placeId = result.tour.place_id; + const place = testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as Record; + const tour = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record; + const points = testDb.prepare('SELECT role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence').all(placeId); + + expect(place).toMatchObject({ trip_id: Number(tripId), name: 'Ridge walk', lat: 48, lng: 11, transport_mode: 'walking' }); + expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry); + expect(tour).toMatchObject({ tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2 }); + expect(tour.distance).toBeGreaterThan(0); + expect(tour.elevation_gain).toBe(50); + expect(tour.elevation_loss).toBe(20); + expect(tour.duration).toBe(60); + expect(points).toEqual([{ role: 'start', sequence: 0 }, { role: 'end', sequence: 1 }]); + expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true, caution: false }); + expect(result.waypoints).toEqual(request.waypoints); + expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1'); + expect(broadcast).toHaveBeenNthCalledWith(2, tripId, 'place:created', { place: expect.objectContaining({ id: placeId }) }, 'socket-1'); + expect(broadcast).toHaveBeenCalledTimes(2); + // Both events go out after the commit, never from inside the write. + expect(writeStates).toEqual([false, false]); + }); + + it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', async () => { + await expect(service.createTour(tripId, duplicateSequence)).rejects.toThrow(); + + expect(count('places')).toBe(0); + expect(count('tours')).toBe(0); + expect(count('tour_waypoints')).toBe(0); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', async () => { + const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 }); + + const result = await service.createTour(tripId, validated); + + expect(count('places')).toBe(1); + expect(count('tours')).toBe(1); + expect(count('tour_waypoints')).toBe(2); + expect(testDb.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 }); + expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false }); + }); + + it('TOURS-SVC-003b: stores no duration when the client sends none', async () => { + const { duration_seconds: _omitted, ...withoutDuration } = request; + const result = await service.createTour(tripId, withoutDuration); + expect(result.tour.duration).toBeNull(); + }); + + it('keeps a committed create successful when Tours invalidation publication fails', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + broadcast.mockImplementation((_tripId: string, event: string) => { + if (event === 'tours:changed') throw new Error('transport unavailable'); + }); + + const result = await service.createTour(tripId, request); + + expect(result.tour.name).toBe('Ridge walk'); + expect(count('places')).toBe(1); + expect(count('tours')).toBe(1); + expect(broadcast).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('keeps a committed create successful when the place notification fails', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + broadcast.mockImplementation((_tripId: string, event: string) => { + if (event === 'place:created') throw new Error('transport unavailable'); + }); + + await expect(service.createTour(tripId, request)).resolves.toMatchObject({ tour: { name: 'Ridge walk' } }); + expect(warn).toHaveBeenCalledOnce(); + }); +}); + +describe('ToursService reads', () => { + it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', async () => { + const created = await service.createTour(tripId, request); + const placeId = String(created.tour.place_id); + + expect(await service.getTour(tripId, placeId)).toEqual(created); + await expect(service.getTour(otherTripId, placeId)).rejects.toThrow('Tour not found'); + + testDb.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id); + const legacyGpx = await service.getTour(tripId, placeId); + expect(legacyGpx.tour.has_waypoints).toBe(false); + expect(legacyGpx.waypoints).toEqual([ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, + ]); + // The fallback is read-only: nothing was backfilled. + expect(count('tour_waypoints')).toBe(0); + }); + + it('TOURS-SVC-004b: a plain place, a malformed id and a geometry-less tour', async () => { + const plain = createPlace(testDb, Number(tripId), { name: 'Not a tour' }); + await expect(service.getTour(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getTour(tripId, 'abc')).rejects.toThrow('Tour not found'); + await expect(service.getTour('abc', String(plain.id))).rejects.toThrow('Tour not found'); + + const bare = createPlace(testDb, Number(tripId), { name: 'Bare import' }); + createTour(testDb, bare.id); + const detail = await service.getTour(tripId, String(bare.id)); + expect(detail.tour).toMatchObject({ place_id: bare.id, has_waypoints: false }); + expect(detail.waypoints).toEqual([]); + }); + + it('TOURS-SVC-007: lists only this trip\'s tours, newest first, with the caution flag', async () => { + const older = createPlace(testDb, Number(tripId), { name: 'Older' }); + const newer = createPlace(testDb, Number(tripId), { name: 'Newer' }); + const foreign = createPlace(testDb, Number(otherTripId), { name: 'Foreign' }); + createPlace(testDb, Number(tripId), { name: 'Plain place' }); + createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', match_confidence: 0.3 }); + createTour(testDb, newer.id, { created_at: '2026-02-01 10:00:00', match_confidence: 1 }); + createTour(testDb, foreign.id); + + const list = await service.listTours(tripId); + + expect(list.map(t => t.name)).toEqual(['Newer', 'Older']); + expect(list[0]).toMatchObject({ caution: false, planned: false, has_waypoints: false, tour_type: 'hike' }); + expect(list[1]).toMatchObject({ caution: true, match_confidence: 0.3 }); + expect(await service.listTours('not-a-number')).toEqual([]); + }); +}); + +describe('ToursService updates', () => { + const update: TourCreateRequest = { + ...request, + name: 'Updated ridge walk', + route_geometry: [[49, 12, 700], [49.02, 12.04, 760]], + waypoints: [ + { lat: 49, lng: 12, role: 'start', sequence: 0 }, + { lat: 49.01, lng: 12.02, role: 'via', sequence: 1 }, + { lat: 49.02, lng: 12.04, role: 'end', sequence: 2 }, + ], + duration_seconds: 2700.5, + }; + + it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', async () => { + const created = await service.createTour(tripId, request); + broadcast.mockReset(); + const writeStates: boolean[] = []; + broadcast.mockImplementation(() => { writeStates.push(testDb.inTransaction); }); + + const result = await service.updateTour(tripId, String(created.tour.place_id), update, 'socket-2'); + + expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' }); + expect(result.waypoints).toEqual(update.waypoints); + expect(testDb.prepare('SELECT name, lat, lng, transport_mode FROM places').get()).toEqual({ + name: 'Updated ridge walk', lat: 49, lng: 12, transport_mode: 'walking', + }); + expect(testDb.prepare('SELECT duration, elevation_gain, elevation_loss FROM tours').get()).toEqual({ + duration: 45, elevation_gain: 60, elevation_loss: 0, + }); + expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [created.tour.place_id] }, 'socket-2'); + expect(broadcast).toHaveBeenNthCalledWith(2, + tripId, 'place:updated', expect.objectContaining({ place: expect.objectContaining({ id: created.tour.place_id }) }), 'socket-2', + ); + expect(writeStates).toEqual([false, false]); + }); + + it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', async () => { + const created = await service.createTour(tripId, request); + broadcast.mockReset(); + + await expect(service.updateTour(tripId, String(created.tour.place_id), { ...duplicateSequence, name: 'Must roll back' })).rejects.toThrow(); + + expect(testDb.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' }); + expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-008: a tour of another trip is a 404 and stays untouched', async () => { + const created = await service.createTour(otherTripId, request); + broadcast.mockReset(); + + await expect(service.updateTour(tripId, String(created.tour.place_id), update, 'socket')).rejects.toThrow('Tour not found'); + + expect(testDb.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(created.tour.place_id)) + .toEqual({ trip_id: Number(otherTripId), name: 'Ridge walk' }); + expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-009: a plain place or a malformed id is a 404 without a write', async () => { + const plain = createPlace(testDb, Number(tripId), { name: 'Plain' }); + + await expect(service.updateTour(tripId, String(plain.id), update)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.updateTour(tripId, 'abc', update)).rejects.toThrow('Tour not found'); + await expect(service.updateTour('abc', String(plain.id), update)).rejects.toThrow('Tour not found'); + + expect(testDb.prepare('SELECT name FROM places WHERE id = ?').get(plain.id)).toEqual({ name: 'Plain' }); + expect(count('tours')).toBe(0); + expect(broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 0c826fdf8d..496c85a429 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -51,6 +51,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories'; +import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos'; import { MAX_TRIP_DAYS, resolveDayGridRange, tripSpanDays } from '@trek/shared'; import { DaysService } from '../../../src/nest/days/days.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; @@ -1874,6 +1875,67 @@ describe('copy — whole-trip parity (Task 8)', () => { }); }); +describe('copy: Tours (#2586)', () => { + /** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */ + function seedTour(tripId: number, name: string) { + const place = createPlace(testDb, tripId, { name }); + testDb.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?").run(place.id); + createTour(testDb, place.id, { distance: 12.5, match_confidence: 0.9, max_hiking_difficulty: 4, created_at: '2025-01-02 03:04:05' }); + testDb.prepare("UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 7 WHERE place_id = ?").run(place.id); + const insert = testDb.prepare('INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)'); + insert.run(place.id, 47, 11, 'start', 0); + insert.run(place.id, 47.1, 11.1, 'via', 1); + insert.run(place.id, 47.2, 11.2, 'end', 2); + return place; + } + + const tourColumns = (placeId: number) => { + const { place_id: _placeId, ...rest } = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record; + return rest; + }; + const waypoints = (placeId: number) => + testDb.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence').all(placeId); + + it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Alps' }); + const tour = seedTour(trip.id, 'Ridge walk'); + createPlace(testDb, trip.id, { name: 'Plain stop' }); + + const newTripId = await svc.copy(trip.id, user.id, 'Alps again'); + + const newTour = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'").get(newTripId) as { id: number }; + const newPlain = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'").get(newTripId) as { id: number }; + expect(newTour.id).not.toBe(tour.id); + expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id)); + expect(tourColumns(newTour.id)).toMatchObject({ tour_type: 'hike', created_at: '2025-01-02 03:04:05', max_hiking_difficulty: 4, tour_group_id: 7 }); + expect(waypoints(newTour.id)).toEqual(waypoints(tour.id)); + expect(waypoints(newTour.id)).toHaveLength(3); + // The plain place stays plain, and the source keeps its own rows. + expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined(); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?').get(trip.id)).toEqual({ n: 1 }); + expect(waypoints(tour.id)).toHaveLength(3); + }); + + it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Doomed alps' }); + seedTour(trip.id, 'Doomed ridge'); + const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n; + + const waypointsRepo = await createTestTourWaypointsRepo(testDb); + const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom')); + try { + await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom'); + } finally { + spy.mockRestore(); + } + + expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore); + expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined(); + }); +}); + // ── Post-fold quirk fixes (transactions, owner display name) ───────────────── describe('quirk fixes', () => { diff --git a/server/tests/unit/nest/wiring.test.ts b/server/tests/unit/nest/wiring.test.ts index 3e3dd0a61f..816ed6b0a7 100644 --- a/server/tests/unit/nest/wiring.test.ts +++ b/server/tests/unit/nest/wiring.test.ts @@ -1,15 +1,47 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AppModule } from '../../../src/nest/app.module'; import { FeaturesController } from '../../../src/nest/health/features.controller'; import { AdminGuard } from '../../../src/nest/auth/admin.guard'; +vi.mock('../../../src/config', async () => { + const { readEnv } = await import('../../../src/app-config'); + const env = readEnv(); + return { + ENCRYPTION_KEY: 'wiring-test-inert-encryption-key', + JWT_SECRET: 'wiring-test-inert-jwt-secret', + updateJwtSecret: vi.fn(), + DEFAULT_LANGUAGE: env.app.defaultLanguage, + SESSION_DURATION: env.session.duration, + SESSION_DURATION_MS: env.session.durationMs, + SESSION_DURATION_SECONDS: env.session.durationSeconds, + SESSION_DURATION_REMEMBER: env.session.durationRemember, + SESSION_DURATION_REMEMBER_MS: env.session.durationRememberMs, + SESSION_DURATION_REMEMBER_SECONDS: env.session.durationRememberSeconds, + }; +}); + function ctx(user: unknown) { return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never; } describe('AppModule wiring', () => { + it('registers the upstream feature modules and Tours exactly once', () => { + // Only plain class imports: forRoot()/forFeature() entries are dynamic + // (MikroOrmModule legitimately appears twice) and some resolve async. + const imports = Reflect.getMetadata('imports', AppModule) as unknown[]; + const names = imports + .filter((entry): entry is { name: string } => typeof entry === 'function') + .map(entry => entry.name); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual(expect.arrayContaining([ + 'GoogleQuotaModule', 'ReceiptScanModule', 'SchoolHolidaysModule', 'DocSyncModule', + 'DawarichModule', 'NotificationsModule', 'ToursModule', + ])); + expect(names.filter(name => name === 'ToursModule')).toHaveLength(1); + }); + it('compiles with the global filter + DB provider and resolves the controller', async () => { // Plan 4 Task 4: `DatabaseService` is gone — nothing left in the graph // needs overriding for this to compile. diff --git a/shared/src/assignment/assignment.schema.spec.ts b/shared/src/assignment/assignment.schema.spec.ts index 31be55e019..5159a00b95 100644 --- a/shared/src/assignment/assignment.schema.spec.ts +++ b/shared/src/assignment/assignment.schema.spec.ts @@ -3,11 +3,44 @@ import { assignmentEndDayRequestSchema, assignmentMoveRequestSchema, assignmentParticipantsRequestSchema, + assignmentSchema, assignmentTransportRequestSchema, } from './assignment.schema'; import { describe, it, expect } from 'vitest'; +describe('assignment Tours read-model merge', () => { + const ordinary = { id: 1, day_id: 2, place_id: 3, order_index: 0, place: { id: 3, name: 'Place' } }; + + it('keeps ordinary and legacy track places non-Tours without a facet identity', () => { + expect(assignmentSchema.parse(ordinary).tour_place_id).toBeUndefined(); + const legacy = assignmentSchema.parse({ ...ordinary, tour_place_id: null, tour_route_geometry: null }); + expect(legacy.tour_place_id).toBeNull(); + expect(legacy.tour_route_geometry).toBeNull(); + }); + + it('preserves nullable Tour fields, exclusion and incoming/outgoing segment modes', () => { + const fields = { + tour_place_id: 3, + tour_route_geometry: '[[48,11],[49,12]]', + route_excluded: true, + leg_transport_mode: 'walking', + incoming_leg_transport_mode: 'cycling', + }; + expect(assignmentSchema.parse({ ...ordinary, ...fields })).toMatchObject(fields); + expect(assignmentSchema.parse({ ...ordinary, tour_place_id: null, tour_route_geometry: null })).toMatchObject({ + tour_place_id: null, + tour_route_geometry: null, + }); + }); + + it('does not copy read-only joined Tours fields into create or move requests', () => { + const joined = { tour_place_id: 3, tour_route_geometry: '[[48,11],[49,12]]' }; + expect(assignmentCreateRequestSchema.parse({ place_id: 3, ...joined })).toEqual({ place_id: 3 }); + expect(assignmentMoveRequestSchema.parse({ new_day_id: 2, ...joined })).toEqual({ new_day_id: 2 }); + }); +}); + it('requires a boolean for an explicit day end', () => { expect(assignmentEndDayRequestSchema.parse({ end_day: true })).toEqual({ end_day: true }); expect(assignmentEndDayRequestSchema.parse({ end_day: false })).toEqual({ end_day: false }); diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 32287a337a..3522046528 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -55,6 +55,8 @@ export const assignmentSchema = z.object({ // this row and leaves it to road trip mode, which is the view that needs the // hotel in the driving chain. Null for every stop a traveller placed. accommodation_id: z.number().nullable().optional(), + tour_place_id: z.number().nullable().optional(), + tour_route_geometry: z.string().nullable().optional(), participants: z.array(assignmentParticipantSchema).optional(), created_at: z.string().optional(), place: assignmentPlaceSchema, diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts new file mode 100644 index 0000000000..f205b01af3 --- /dev/null +++ b/shared/src/i18n/ar/tours.ts @@ -0,0 +1,131 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'الجولات', + 'tours.mode.places': 'الأماكن', + 'tours.mode.tours': 'الجولات', + 'tours.mode.label': 'عرض الإضافة', + 'tours.title': 'الجولات', + 'tours.subtitle': 'جولات المشي الطويل وغيرها من الجولات اليومية ضمن هذه الرحلة', + 'tours.empty.title': 'لا توجد جولات بعد', + 'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX للبدء.', + 'tours.addToDay': 'إضافة إلى يوم', + 'tours.addToDay.comingSoon': 'عيّن الجولات لأحد أيام الرحلة من قائمة الجولات.', + 'tours.addToDay.pickDay': 'اختيار يوم', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'استيراد GPX', + 'tours.badge.planned': 'مخطّطة في TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'يُرجى الاستخدام بحذر', + 'tours.caution.tooltip': 'بيانات الارتفاع أو المقاييس غير مكتملة؛ تم الاستيراد مع تنبيه للحذر', + 'tours.import.caution': 'تم الاستيراد مع تنبيه للحذر؛ بيانات الارتفاع أو المقاييس غير مكتملة', + 'tours.import.success': 'تم استيراد {count} من الجولات من GPX', + 'tours.import.successOne': 'تم استيراد جولة واحدة من GPX', + 'tours.import.noTrack': 'لم يُعثر على أثر مسجّل أو مسار في ملف GPX هذا', + 'tours.import.error': 'تعذّر استيراد ملف GPX', + 'tours.delete.confirmBody': + 'هل تريد حذف هذه الجولة نهائيًا؟ ستُحذف مساراتها ونقاطها القابلة للتحرير وتعييناتها للأيام. لا يمكن التراجع عن هذا الإجراء.', + 'tours.delete.bulkConfirmBody': + 'يتضمن هذا التحديد جولة واحدة على الأقل. ستُحذف جميع الأماكن المحددة نهائيًا ولن يمكن استعادتها. وستُحذف أيضًا مسارات الجولات ونقاطها القابلة للتحرير وتعييناتها للأيام.', + 'tours.delete.confirmAction': 'حذف نهائي', + 'tours.addedToDay': 'تمت الإضافة إلى اليوم {n}', + 'tours.dayRoute.endpointUnknown': 'تعذّر تحديد نقطة نهاية الجولة. قد تكون وصلات المسار التالية غير مكتملة.', + 'tours.detail.trackColor': 'لون الأثر المسجّل', + 'tours.detail.distance': 'المسافة', + 'tours.detail.minAltitude': 'أدنى ارتفاع', + 'tours.detail.maxAltitude': 'أعلى ارتفاع', + 'tours.detail.ascent': 'إجمالي الصعود', + 'tours.detail.descent': 'إجمالي الهبوط', + 'tours.detail.difficulty': 'مستوى الصعوبة', + 'tours.detail.source': 'المصدر', + 'tours.detail.gpxReadOnly': 'جولات GPX متاحة للعرض فقط، ولا يمكن تعديلها في أداة التخطيط بعد.', + 'tours.detail.navigationHint': 'تفتح الملاحة نقطة بداية الدرب، ولا توفّر إرشادًا على طول مسار الجولة.', + 'tours.planner.title': 'تخطيط جولة', + 'tours.planner.newTour': 'تخطيط جولة جديدة', + 'tours.planner.neutralTitle': 'تخطيط جولة', + 'tours.planner.neutralBody': 'ابدأ جولة جديدة وانقر على الخريطة لتحديد نقاط المسار.', + 'tours.planner.newTitle': 'جولة جديدة', + 'tours.planner.editTitle': 'تعديل الجولة', + 'tours.planner.gpxTitle': 'جولة GPX', + 'tours.planner.backToTours': 'العودة إلى الجولات', + 'tours.planner.restored': 'تمت استعادة المسودة غير المحفوظة', + 'tours.planner.discardChanges': 'تجاهل التغييرات', + 'tours.planner.discardUnsaved': 'تجاهل التغييرات غير المحفوظة', + 'tours.planner.saveChanges': 'حفظ التغييرات', + 'tours.planner.newTourConfirmTitle': 'هل تريد بدء جولة جديدة؟', + 'tours.planner.newTourConfirmBody': 'ستُحذف تغييراتك غير المحفوظة على هذه الجولة. لن تتغيّر الجولات المحفوظة.', + 'tours.planner.newTourConfirm': 'تجاهل التغييرات وبدء جولة جديدة', + 'tours.planner.startingPoint': 'انقر على الخريطة لإضافة نقطة البداية.', + 'tours.planner.firstUseDetails': + 'أضف نقطة أخرى لحساب المسار. يتم تحديث المسار والارتفاع تلقائيًا؛ احفظ الجولة عندما تصبح جاهزة.', + 'tours.planner.collapseElevation': 'طيّ مخطط الارتفاع', + 'tours.planner.expandElevation': 'توسيع مخطط الارتفاع', + 'tours.planner.name': 'اسم الجولة', + 'tours.planner.namePlaceholder': 'جولة مشي بلا اسم', + 'tours.planner.mapHint': 'انقر على الخريطة لإضافة نقاط.', + 'tours.planner.waypoints': 'نقاط المرور', + 'tours.planner.waypointLabel': 'نقطة المرور {n}', + 'tours.durationMinutes': '{count} د', + 'tours.planner.empty': 'أضف نقطتين على الأقل لحساب مسار للمشي.', + 'tours.planner.start': 'البداية', + 'tours.planner.via': 'المرور عبر', + 'tours.planner.end': 'النهاية', + 'tours.planner.moveUp': 'نقل نقطة المرور لأعلى', + 'tours.planner.moveDown': 'نقل نقطة المرور لأسفل', + 'tours.planner.remove': 'إزالة نقطة المرور', + 'tours.planner.undo': 'التراجع عن تعديل نقطة المرور', + 'tours.planner.redo': 'إعادة تعديل نقطة المرور', + 'tours.planner.discard': 'تجاهل المسودة', + 'tours.planner.discardTitle': 'هل تريد تجاهل تغييرات الجولة غير المحفوظة؟', + 'tours.planner.discardBody': + 'ستُحذف تعديلاتك غير المحفوظة. لن تتغيّر الجولة المحفوظة أو نقاط مرورها أو ملفاتها أو تعييناتها للأيام.', + 'tours.planner.save': 'حفظ الجولة', + 'tours.planner.saved': 'تم حفظ الجولة', + 'tours.planner.planAnother': 'تخطيط جولة أخرى', + 'tours.planner.assignToDay': 'تعيين ليوم', + 'tours.planner.tripTours': 'جولات هذه الرحلة', + 'tours.planner.safetyNote': 'حساب المسارات يساعد على التخطيط، ولا يضمن السلامة.', + 'tours.planner.safetyNoteDetails': + 'قد تكون وسوم الصعوبة في OSM مفقودة أو غير صحيحة. لا يتم تقييم الطقس أو إغلاق الدروب أو الظروف الحالية.', + 'tours.planner.maxDifficulty': 'أقصى مستوى لصعوبة الدرب', + 'tours.planner.difficulty.t1': 'T1: المشي الطويل', + 'tours.planner.difficulty.t2': 'T2: المشي الجبلي', + 'tours.planner.difficulty.t3': 'T3: المشي الجبلي الشاق', + 'tours.planner.difficulty.t4': 'T4: المشي في التضاريس الألبية', + 'tours.planner.difficulty.t5': 'T5: المشي الشاق في التضاريس الألبية', + 'tours.planner.difficulty.t6': 'T6: المشي الصعب في التضاريس الألبية', + 'tours.planner.difficulty.t3Warning': + 'قد تتضمن مسارات T3 دروبًا جبلية مكشوفة لخطر السقوط أو تتطلب مهارات تقنية عالية. يلزم ثبات القدمين والقدرة على التعامل مع المرتفعات دون خوف.', + 'tours.planner.difficulty.alpineTitle': 'هل تريد تفعيل حساب المسارات الألبية؟', + 'tours.planner.difficulty.alpineBody': + 'تم تفعيل حساب المسارات الألبية. قد تعبر المسارات تضاريس مكشوفة لخطر السقوط أو بلا علامات إرشادية أو صعبة تقنيًا. قد تكون بيانات الصعوبة في OSM مفقودة أو غير صحيحة.', + 'tours.planner.difficulty.enable': 'تفعيل حساب المسارات الألبية', + 'tours.planner.openFailed': 'تعذّر فتح الجولة المحفوظة للتعديل.', + 'tours.planner.status.dirty': 'بانتظار الحساب', + 'tours.planner.status.routing': 'جارٍ حساب مسار المشي…', + 'tours.planner.status.routingFailed': 'فشل حساب المسار. لا يزال آخر مسار حُسب بنجاح معروضًا.', + 'tours.planner.status.elevation': 'جارٍ تحميل الارتفاع…', + 'tours.planner.status.elevationFailed': 'بيانات الارتفاع غير متاحة. لا يزال المسار المحسوب معروضًا.', + 'tours.planner.status.ready': 'المسار والارتفاع جاهزان', + 'tours.planner.status.saving': 'جارٍ الحفظ…', + 'tours.planner.status.saveFailed': 'تعذّر حفظ الجولة. لا تزال مسودتك متاحة هنا.', + 'tours.planner.retry': 'إعادة المحاولة', + 'tours.planner.inspector.title': 'ملخص الجولة', + 'tours.planner.inspector.empty': 'تظهر تفاصيل المسار هنا بعد توصيل نقطتي مرور.', + 'tours.planner.inspector.distance': 'المسافة', + 'tours.planner.inspector.duration': 'مدة المشي', + 'tours.planner.inspector.elevation': 'مخطط الارتفاع', + 'tours.planner.inspector.profileFocus': 'عند {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'يظهر المخطط عند توفّر بيانات ارتفاع المسار.', + 'tours.planner.gpxNoGeometry': 'بيانات الشكل الهندسي للمسار غير متاحة لملف GPX المستورد هذا.', + 'tours.planner.mobileHint': + 'تخطيط الجولات متاح على الأجهزة اللوحية والحواسيب. يمكنك هنا عرض الجولات المحفوظة وجدولتها.', + 'tourTypes.hike': 'مشي طويل', + 'tourTypes.bike': 'جولة بالدراجة', + 'tourTypes.run': 'جري', + 'tourTypes.ski': 'جولة تزلج', + 'tourTypes.kayak': 'تجديف بالكاياك', + 'tourTypes.walk': 'مشي في المدينة / مشاهدة المعالم', + 'tourTypes.food': 'جولة طعام / نبيذ', +}; +export default tours; diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index d7fad9f621..73301a1465 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -71,6 +72,7 @@ const locale = { ...collection, ...atlas, ...trip, + ...tours, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts new file mode 100644 index 0000000000..0c3521ac8f --- /dev/null +++ b/shared/src/i18n/az/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Turlar', + 'tours.mode.places': 'Məkanlar', + 'tours.mode.tours': 'Turlar', + 'tours.mode.label': 'Əlavə etmə görünüşü', + 'tours.title': 'Turlar', + 'tours.subtitle': 'Bu səyahət üçün yürüşlər və digər birgünlük turlar', + 'tours.empty.title': 'Hələ tur yoxdur', + 'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX faylı idxal edin.', + 'tours.addToDay': 'Günə əlavə et', + 'tours.addToDay.comingSoon': 'Tur siyahısından turları səyahət gününə təyin edin.', + 'tours.addToDay.pickDay': 'Gün seç', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX idxalı', + 'tours.badge.planned': 'TREK-də planlaşdırılıb', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'ehtiyatla istifadə edin', + 'tours.caution.tooltip': 'Yüksəklik və göstəricilər natamamdır — ehtiyatla idxal edilib', + 'tours.import.caution': 'Ehtiyatla idxal edilib — yüksəklik və göstəricilər natamamdır', + 'tours.import.success': 'GPX-dən {count} tur idxal edildi', + 'tours.import.successOne': 'GPX-dən 1 tur idxal edildi', + 'tours.import.noTrack': 'Bu GPX faylında iz və ya marşrut tapılmadı', + 'tours.import.error': 'GPX faylını idxal etmək mümkün olmadı', + 'tours.delete.confirmBody': + 'Bu tur həmişəlik silinsin? Onun marşrutu, redaktə edilə bilən nöqtələri və gün təyinatları silinəcək. Bu əməliyyatı geri qaytarmaq mümkün deyil.', + 'tours.delete.bulkConfirmBody': + 'Seçimə ən azı bir tur daxildir. Seçilmiş bütün məkanlar həmişəlik silinəcək və bərpa edilə bilməyəcək. Turların marşrutları, redaktə edilə bilən nöqtələri və gün təyinatları da silinəcək.', + 'tours.delete.confirmAction': 'Həmişəlik sil', + 'tours.addedToDay': '{n}-ci günə əlavə edildi', + 'tours.dayRoute.endpointUnknown': + 'Turun son nöqtəsini müəyyən etmək mümkün olmadı. Sonrakı bağlantılar natamam ola bilər.', + 'tours.detail.trackColor': 'İzin rəngi', + 'tours.detail.distance': 'Məsafə', + 'tours.detail.minAltitude': 'Minimum yüksəklik', + 'tours.detail.maxAltitude': 'Maksimum yüksəklik', + 'tours.detail.ascent': 'Yüksəlmə', + 'tours.detail.descent': 'Enmə', + 'tours.detail.difficulty': 'Çətinlik', + 'tours.detail.source': 'Mənbə', + 'tours.detail.gpxReadOnly': 'GPX turlarına yalnız baxmaq olar; onları planlaşdırıcıda hələ redaktə etmək olmur.', + 'tours.detail.navigationHint': 'Naviqasiya cığırın başlanğıcını açır; marşrut boyunca istiqamət göstərmir.', + 'tours.planner.title': 'Tur planlaşdır', + 'tours.planner.newTour': 'Yeni tur planlaşdır', + 'tours.planner.neutralTitle': 'Tur planlaşdır', + 'tours.planner.neutralBody': 'Yeni tur başladın və marşrut nöqtələrini qoymaq üçün xəritəyə klikləyin.', + 'tours.planner.newTitle': 'Yeni tur', + 'tours.planner.editTitle': 'Turu redaktə et', + 'tours.planner.gpxTitle': 'GPX turu', + 'tours.planner.backToTours': 'Turlara qayıt', + 'tours.planner.restored': 'Saxlanılmamış qaralama bərpa edildi', + 'tours.planner.discardChanges': 'Dəyişiklikləri ləğv et', + 'tours.planner.discardUnsaved': 'Saxlanılmamış dəyişiklikləri ləğv et', + 'tours.planner.saveChanges': 'Dəyişiklikləri saxla', + 'tours.planner.newTourConfirmTitle': 'Yeni tur başlansın?', + 'tours.planner.newTourConfirmBody': + 'Bu turda saxlanılmamış dəyişiklikləriniz ləğv ediləcək. Saxlanılmış turlar dəyişməyəcək.', + 'tours.planner.newTourConfirm': 'Dəyişiklikləri ləğv et və yeni tur başlat', + 'tours.planner.startingPoint': 'Başlanğıc nöqtəsini əlavə etmək üçün xəritəyə klikləyin.', + 'tours.planner.firstUseDetails': + 'Marşrutu hesablamaq üçün başqa bir nöqtə əlavə edin. Marşrut və yüksəklik avtomatik yenilənir; hazır olduqda saxlayın.', + 'tours.planner.collapseElevation': 'Yüksəklik profilini yığ', + 'tours.planner.expandElevation': 'Yüksəklik profilini aç', + 'tours.planner.name': 'Turun adı', + 'tours.planner.namePlaceholder': 'Adsız yürüş', + 'tours.planner.mapHint': 'Nöqtə əlavə etmək üçün xəritəyə klikləyin.', + 'tours.planner.waypoints': 'Yol nöqtələri', + 'tours.planner.waypointLabel': 'Yol nöqtəsi {n}', + 'tours.durationMinutes': '{count} dəq.', + 'tours.planner.empty': 'Piyada marşrutunu hesablamaq üçün ən azı iki nöqtə əlavə edin.', + 'tours.planner.start': 'Başlanğıc', + 'tours.planner.via': 'Aralıq nöqtə', + 'tours.planner.end': 'Son', + 'tours.planner.moveUp': 'Yol nöqtəsini yuxarı köçür', + 'tours.planner.moveDown': 'Yol nöqtəsini aşağı köçür', + 'tours.planner.remove': 'Yol nöqtəsini sil', + 'tours.planner.undo': 'Yol nöqtəsi dəyişikliyini geri al', + 'tours.planner.redo': 'Yol nöqtəsi dəyişikliyini təkrarla', + 'tours.planner.discard': 'Qaralamanı ləğv et', + 'tours.planner.discardTitle': 'Turun saxlanılmamış dəyişiklikləri ləğv edilsin?', + 'tours.planner.discardBody': + 'Saxlanılmamış düzəlişləriniz ləğv ediləcək. Saxlanılmış tur, onun yol nöqtələri, faylları və günlərə təyinatları dəyişməyəcək.', + 'tours.planner.save': 'Turu saxla', + 'tours.planner.saved': 'Tur saxlanıldı', + 'tours.planner.planAnother': 'Başqa tur planlaşdır', + 'tours.planner.assignToDay': 'Günə təyin et', + 'tours.planner.tripTours': 'Bu səyahətin turları', + 'tours.planner.safetyNote': 'Marşrutlaşdırma planlaşdırma köməyidir, təhlükəsizlik zəmanəti deyil.', + 'tours.planner.safetyNoteDetails': + 'OSM çətinlik etiketləri çatışmaya və ya yanlış ola bilər. Hava, yol bağlanmaları və cari şərait qiymətləndirilmir.', + 'tours.planner.maxDifficulty': 'Maksimum cığır çətinliyi', + 'tours.planner.difficulty.t1': 'T1 — Yürüş', + 'tours.planner.difficulty.t2': 'T2 — Dağ yürüşü', + 'tours.planner.difficulty.t3': 'T3 — Yüksək hazırlıq tələb edən dağ yürüşü', + 'tours.planner.difficulty.t4': 'T4 — Alp yürüşü', + 'tours.planner.difficulty.t5': 'T5 — Yüksək hazırlıq tələb edən alp yürüşü', + 'tours.planner.difficulty.t6': 'T6 — Çətin alp yürüşü', + 'tours.planner.difficulty.t3Warning': + 'T3 marşrutlarında uçurum kənarından keçən və ya texniki cəhətdən çətin dağ cığırları ola bilər; ayağı yerə möhkəm basmaq bacarığı və hündürlükdən qorxmamaq tələb olunur.', + 'tours.planner.difficulty.alpineTitle': 'Alp marşrutlaşdırması aktivləşdirilsin?', + 'tours.planner.difficulty.alpineBody': + 'Alp marşrutlaşdırması aktivdir. Marşrutlar uçurum kənarından, işarələnməmiş və ya texniki cəhətdən çətin ərazilərdən keçə bilər. OSM çətinlik məlumatları çatışmaya və ya yanlış ola bilər.', + 'tours.planner.difficulty.enable': 'Alp marşrutlaşdırmasını aktivləşdir', + 'tours.planner.openFailed': 'Saxlanılmış turu redaktə üçün açmaq mümkün olmadı.', + 'tours.planner.status.dirty': 'Hesablama gözlənilir', + 'tours.planner.status.routing': 'Piyada marşrutu hesablanır…', + 'tours.planner.status.routingFailed': 'Marşrut hesablanmadı. Son uğurlu marşrut hələ göstərilir.', + 'tours.planner.status.elevation': 'Yüksəklik yüklənir…', + 'tours.planner.status.elevationFailed': 'Yüksəklik əlçatan deyil. Hesablanmış marşrut hələ göstərilir.', + 'tours.planner.status.ready': 'Marşrut və yüksəklik hazırdır', + 'tours.planner.status.saving': 'Saxlanılır…', + 'tours.planner.status.saveFailed': 'Turu saxlamaq mümkün olmadı. Qaralamanız hələ buradadır.', + 'tours.planner.retry': 'Yenidən cəhd et', + 'tours.planner.inspector.title': 'Turun xülasəsi', + 'tours.planner.inspector.empty': 'İki yol nöqtəsi birləşdirildikdən sonra marşrut təfərrüatları burada görünəcək.', + 'tours.planner.inspector.distance': 'Məsafə', + 'tours.planner.inspector.duration': 'Piyada gediş vaxtı', + 'tours.planner.inspector.elevation': 'Yüksəklik profili', + 'tours.planner.inspector.profileFocus': '{distance} məsafədə: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Marşrutun yüksəklik məlumatları olduqda profil göstərilir.', + 'tours.planner.gpxNoGeometry': 'Bu GPX idxalı üçün marşrut həndəsəsi əlçatan deyil.', + 'tours.planner.mobileHint': + 'Tur planlaşdırmaq planşet və kompüterdə mümkündür. Burada saxlanılmış turlara baxmaq və onları günlərə təyin etmək olar.', + 'tourTypes.hike': 'Yürüş', + 'tourTypes.bike': 'Velosiped turu', + 'tourTypes.run': 'Qaçış', + 'tourTypes.ski': 'Xizək turu', + 'tourTypes.kayak': 'Kayak gəzintisi', + 'tourTypes.walk': 'Şəhər / görməli yerlər gəzintisi', + 'tourTypes.food': 'Yemək/şərab turu', +}; +export default tours; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 0afbf275bc..7417e463dc 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts new file mode 100644 index 0000000000..bc91b3046c --- /dev/null +++ b/shared/src/i18n/br/tours.ts @@ -0,0 +1,135 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Passeios', + 'tours.mode.places': 'Lugares', + 'tours.mode.tours': 'Passeios', + 'tours.mode.label': 'Adicionar visualização', + 'tours.title': 'Passeios', + 'tours.subtitle': 'Trilhas e outros passeios de um dia para esta viagem', + 'tours.empty.title': 'Nenhum passeio ainda', + 'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX para começar.', + 'tours.addToDay': 'Adicionar a um dia', + 'tours.addToDay.comingSoon': 'Atribua passeios a um dia da viagem pela lista de passeios.', + 'tours.addToDay.pickDay': 'Escolha um dia', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Importação GPX', + 'tours.badge.planned': 'planejado no TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'use com cautela', + 'tours.caution.tooltip': 'Elevação ou métricas incompletas: importado com ressalvas', + 'tours.import.caution': 'Importado com ressalvas: elevação ou métricas incompletas', + 'tours.import.success': '{count} passeios importados de GPX', + 'tours.import.successOne': '1 passeio importado de GPX', + 'tours.import.noTrack': 'Nenhum traçado ou rota encontrado nesse arquivo GPX', + 'tours.import.error': 'Não foi possível importar o arquivo GPX', + 'tours.delete.confirmBody': + 'Excluir este passeio permanentemente? A rota, os pontos editáveis e as atribuições aos dias serão removidos. Esta ação não pode ser desfeita.', + 'tours.delete.bulkConfirmBody': + 'A seleção inclui pelo menos um passeio. Todos os lugares selecionados serão excluídos permanentemente e não poderão ser restaurados. As rotas, os pontos editáveis e as atribuições aos dias dos passeios também serão removidos.', + 'tours.delete.confirmAction': 'Excluir permanentemente', + 'tours.addedToDay': 'Adicionado ao dia {n}', + 'tours.dayRoute.endpointUnknown': + 'Não foi possível determinar o ponto final do passeio. As conexões seguintes podem estar incompletas.', + 'tours.detail.trackColor': 'Cor do traçado', + 'tours.detail.distance': 'Distância', + 'tours.detail.minAltitude': 'Altitude mínima', + 'tours.detail.maxAltitude': 'Altitude máxima', + 'tours.detail.ascent': 'Ganho de elevação', + 'tours.detail.descent': 'Perda de elevação', + 'tours.detail.difficulty': 'Dificuldade', + 'tours.detail.source': 'Origem', + 'tours.detail.gpxReadOnly': + 'Os passeios GPX são somente para visualização e ainda não podem ser editados no planejador.', + 'tours.detail.navigationHint': 'A navegação abre o início da trilha; ela não orienta ao longo da rota.', + 'tours.planner.title': 'Planejar um passeio', + 'tours.planner.newTour': 'Planejar novo passeio', + 'tours.planner.neutralTitle': 'Planejar um passeio', + 'tours.planner.neutralBody': 'Comece um novo passeio e clique no mapa para marcar os pontos do percurso.', + 'tours.planner.newTitle': 'Novo passeio', + 'tours.planner.editTitle': 'Editar passeio', + 'tours.planner.gpxTitle': 'Passeio GPX', + 'tours.planner.backToTours': 'Voltar aos passeios', + 'tours.planner.restored': 'Rascunho não salvo restaurado', + 'tours.planner.discardChanges': 'Descartar alterações', + 'tours.planner.discardUnsaved': 'Descartar alterações não salvas', + 'tours.planner.saveChanges': 'Salvar alterações', + 'tours.planner.newTourConfirmTitle': 'Começar um novo passeio?', + 'tours.planner.newTourConfirmBody': + 'As alterações não salvas deste passeio serão descartadas. Os passeios salvos não serão alterados.', + 'tours.planner.newTourConfirm': 'Descartar e começar novo passeio', + 'tours.planner.startingPoint': 'Clique no mapa para adicionar o ponto de partida.', + 'tours.planner.firstUseDetails': + 'Adicione outro ponto para calcular uma rota. A rota e a elevação são atualizadas automaticamente; salve quando terminar.', + 'tours.planner.collapseElevation': 'Recolher perfil de elevação', + 'tours.planner.expandElevation': 'Expandir perfil de elevação', + 'tours.planner.name': 'Nome do passeio', + 'tours.planner.namePlaceholder': 'Trilha sem título', + 'tours.planner.mapHint': 'Clique no mapa para adicionar pontos.', + 'tours.planner.waypoints': 'Pontos de passagem', + 'tours.planner.waypointLabel': 'Ponto de passagem {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Adicione pelo menos dois pontos para calcular uma rota a pé.', + 'tours.planner.start': 'Partida', + 'tours.planner.via': 'Passagem por', + 'tours.planner.end': 'Chegada', + 'tours.planner.moveUp': 'Mover ponto de passagem para cima', + 'tours.planner.moveDown': 'Mover ponto de passagem para baixo', + 'tours.planner.remove': 'Remover ponto de passagem', + 'tours.planner.undo': 'Desfazer edição do ponto de passagem', + 'tours.planner.redo': 'Refazer edição do ponto de passagem', + 'tours.planner.discard': 'Descartar rascunho', + 'tours.planner.discardTitle': 'Descartar as alterações não salvas do passeio?', + 'tours.planner.discardBody': + 'As alterações não salvas serão descartadas. O passeio salvo, seus pontos de passagem, arquivos e atribuições não serão alterados.', + 'tours.planner.save': 'Salvar passeio', + 'tours.planner.saved': 'Passeio salvo', + 'tours.planner.planAnother': 'Planejar outro', + 'tours.planner.assignToDay': 'Atribuir a um dia', + 'tours.planner.tripTours': 'Passeios desta viagem', + 'tours.planner.safetyNote': 'O cálculo de rotas auxilia o planejamento, mas não garante a segurança.', + 'tours.planner.safetyNoteDetails': + 'As etiquetas de dificuldade do OSM podem estar ausentes ou incorretas. O clima, as interdições e as condições atuais não são avaliados.', + 'tours.planner.maxDifficulty': 'Dificuldade máxima da trilha', + 'tours.planner.difficulty.t1': 'T1 — Caminhada', + 'tours.planner.difficulty.t2': 'T2 — Caminhada em montanha', + 'tours.planner.difficulty.t3': 'T3 — Caminhada exigente em montanha', + 'tours.planner.difficulty.t4': 'T4 — Caminhada alpina', + 'tours.planner.difficulty.t5': 'T5 — Caminhada alpina exigente', + 'tours.planner.difficulty.t6': 'T6 — Caminhada alpina difícil', + 'tours.planner.difficulty.t3Warning': + 'As rotas T3 podem incluir trilhas de montanha expostas ou tecnicamente exigentes que requerem firmeza nos passos e ausência de vertigem em altura.', + 'tours.planner.difficulty.alpineTitle': 'Ativar o cálculo de rotas alpinas?', + 'tours.planner.difficulty.alpineBody': + 'O cálculo de rotas alpinas está ativado. As rotas podem atravessar terrenos expostos, sem sinalização ou tecnicamente difíceis. Os dados de dificuldade do OSM podem estar ausentes ou incorretos.', + 'tours.planner.difficulty.enable': 'Ativar rotas alpinas', + 'tours.planner.openFailed': 'Não foi possível abrir o passeio salvo para edição.', + 'tours.planner.status.dirty': 'Aguardando cálculo', + 'tours.planner.status.routing': 'Calculando rota a pé…', + 'tours.planner.status.routingFailed': 'O cálculo da rota falhou. A última rota válida continua sendo exibida.', + 'tours.planner.status.elevation': 'Carregando elevação…', + 'tours.planner.status.elevationFailed': 'A elevação não está disponível. A rota calculada continua sendo exibida.', + 'tours.planner.status.ready': 'Rota e elevação prontas', + 'tours.planner.status.saving': 'Salvando…', + 'tours.planner.status.saveFailed': 'Não foi possível salvar o passeio. Seu rascunho continua disponível.', + 'tours.planner.retry': 'Tentar novamente', + 'tours.planner.inspector.title': 'Resumo do passeio', + 'tours.planner.inspector.empty': 'Os detalhes da rota aparecem aqui após conectar dois pontos de passagem.', + 'tours.planner.inspector.distance': 'Distância', + 'tours.planner.inspector.duration': 'Tempo de caminhada', + 'tours.planner.inspector.elevation': 'Perfil de elevação', + 'tours.planner.inspector.profileFocus': 'Em {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'O perfil é exibido quando os dados de elevação da rota estão disponíveis.', + 'tours.planner.gpxNoGeometry': 'A geometria da rota não está disponível para esta importação GPX.', + 'tours.planner.mobileHint': + 'O planejamento de passeios está disponível em tablets e computadores. Aqui você pode visualizar os passeios salvos e atribuí-los a um dia.', + 'tourTypes.hike': 'Trilha', + 'tourTypes.bike': 'Passeio de bicicleta', + 'tourTypes.run': 'Corrida', + 'tourTypes.ski': 'Passeio de esqui', + 'tourTypes.kayak': 'Passeio de caiaque', + 'tourTypes.walk': 'Caminhada urbana / turística', + 'tourTypes.food': 'Passeio gastronômico / enológico', +}; +export default tours; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index 99ad8c034e..7ff67afe02 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts new file mode 100644 index 0000000000..ef73e14240 --- /dev/null +++ b/shared/src/i18n/ca/tours.ts @@ -0,0 +1,134 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Excursions', + 'tours.mode.places': 'Llocs', + 'tours.mode.tours': 'Excursions', + 'tours.mode.label': 'Afegeix una vista', + 'tours.title': 'Excursions', + 'tours.subtitle': "Rutes de senderisme i altres excursions d'un dia per a aquest viatge", + 'tours.empty.title': 'Encara no hi ha excursions', + 'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX per començar.', + 'tours.addToDay': 'Afegeix a un dia', + 'tours.addToDay.comingSoon': "Assigna excursions a un dia del viatge des de la llista d'excursions.", + 'tours.addToDay.pickDay': 'Tria un dia', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Importació GPX', + 'tours.badge.planned': 'planificada a TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'fes-la servir amb precaució', + 'tours.caution.tooltip': 'Altitud o mètriques incompletes: importada amb reserves', + 'tours.import.caution': 'Importada amb reserves: altitud o mètriques incompletes', + 'tours.import.success': '{count} excursions importades de GPX', + 'tours.import.successOne': '1 excursió importada de GPX', + 'tours.import.noTrack': "No s'ha trobat cap traça ni ruta en aquest fitxer GPX", + 'tours.import.error': "No s'ha pogut importar el fitxer GPX", + 'tours.delete.confirmBody': + 'Vols eliminar aquest itinerari permanentment? Se n’eliminaran la ruta, els punts editables i les assignacions als dies. Aquesta acció no es pot desfer.', + 'tours.delete.bulkConfirmBody': + 'La selecció inclou almenys un itinerari. Tots els llocs seleccionats s’eliminaran permanentment i no es podran restaurar. També se n’eliminaran les rutes, els punts editables i les assignacions als dies.', + 'tours.delete.confirmAction': 'Elimina permanentment', + 'tours.addedToDay': 'Afegida al dia {n}', + 'tours.dayRoute.endpointUnknown': + "No s'ha pogut determinar el punt final de l'excursió. Les connexions posteriors poden ser incompletes.", + 'tours.detail.trackColor': 'Color de la traça', + 'tours.detail.distance': 'Distància', + 'tours.detail.minAltitude': 'Altitud mínima', + 'tours.detail.maxAltitude': 'Altitud màxima', + 'tours.detail.ascent': 'Desnivell positiu', + 'tours.detail.descent': 'Desnivell negatiu', + 'tours.detail.difficulty': 'Dificultat', + 'tours.detail.source': 'Origen', + 'tours.detail.gpxReadOnly': 'Les excursions GPX són de només lectura i encara no es poden editar al planificador.', + 'tours.detail.navigationHint': "La navegació obre el punt d'inici del sender; no guia al llarg de la ruta.", + 'tours.planner.title': 'Planifica una excursió', + 'tours.planner.newTour': 'Planifica una nova excursió', + 'tours.planner.neutralTitle': 'Planifica una excursió', + 'tours.planner.neutralBody': 'Comença una ruta nova i fes clic al mapa per marcar-ne els punts.', + 'tours.planner.newTitle': 'Nova excursió', + 'tours.planner.editTitle': 'Edita excursió', + 'tours.planner.gpxTitle': 'Excursió GPX', + 'tours.planner.backToTours': 'Torna a excursions', + 'tours.planner.restored': 'Esborrany sense desar restaurat', + 'tours.planner.discardChanges': 'Descarta els canvis', + 'tours.planner.discardUnsaved': 'Descarta els canvis sense desar', + 'tours.planner.saveChanges': 'Desa els canvis', + 'tours.planner.newTourConfirmTitle': 'Vols començar una nova excursió?', + 'tours.planner.newTourConfirmBody': + "Es descartaran els canvis sense desar d'aquesta excursió. Les excursions desades no es modificaran.", + 'tours.planner.newTourConfirm': 'Descarta i comença una nova excursió', + 'tours.planner.startingPoint': "Fes clic al mapa per afegir el punt d'inici.", + 'tours.planner.firstUseDetails': + "Afegeix un altre punt per calcular una ruta. La ruta i l'altitud s'actualitzen automàticament; desa quan hagis acabat.", + 'tours.planner.collapseElevation': "Replega el perfil d'altitud", + 'tours.planner.expandElevation': "Desplega el perfil d'altitud", + 'tours.planner.name': "Nom de l'excursió", + 'tours.planner.namePlaceholder': 'Ruta de senderisme sense títol', + 'tours.planner.mapHint': 'Fes clic al mapa per afegir punts.', + 'tours.planner.waypoints': 'Punts de pas', + 'tours.planner.waypointLabel': 'Punt de pas {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Afegeix almenys dos punts per calcular una ruta a peu.', + 'tours.planner.start': 'Inici', + 'tours.planner.via': 'Pas per', + 'tours.planner.end': 'Final', + 'tours.planner.moveUp': 'Mou el punt de pas amunt', + 'tours.planner.moveDown': 'Mou el punt de pas avall', + 'tours.planner.remove': 'Elimina el punt de pas', + 'tours.planner.undo': 'Desfés el canvi al punt de pas', + 'tours.planner.redo': 'Refés el canvi al punt de pas', + 'tours.planner.discard': "Descarta l'esborrany", + 'tours.planner.discardTitle': "Vols descartar els canvis sense desar de l'excursió?", + 'tours.planner.discardBody': + "Es descartaran els canvis sense desar. L'excursió desada, els seus punts de pas, fitxers i assignacions no es modificaran.", + 'tours.planner.save': 'Desa excursió', + 'tours.planner.saved': 'Excursió desada', + 'tours.planner.planAnother': "Planifica'n una altra", + 'tours.planner.assignToDay': 'Assigna a un dia', + 'tours.planner.tripTours': "Excursions d'aquest viatge", + 'tours.planner.safetyNote': 'El càlcul de rutes ajuda a planificar, però no garanteix la seguretat.', + 'tours.planner.safetyNoteDetails': + "Les etiquetes de dificultat d'OSM poden faltar o ser incorrectes. No s'avaluen el temps, els tancaments ni les condicions actuals.", + 'tours.planner.maxDifficulty': 'Dificultat màxima del sender', + 'tours.planner.difficulty.t1': 'T1 — Senderisme', + 'tours.planner.difficulty.t2': 'T2 — Senderisme de muntanya', + 'tours.planner.difficulty.t3': 'T3 — Senderisme de muntanya exigent', + 'tours.planner.difficulty.t4': 'T4 — Senderisme alpí', + 'tours.planner.difficulty.t5': 'T5 — Senderisme alpí exigent', + 'tours.planner.difficulty.t6': 'T6 — Senderisme alpí difícil', + 'tours.planner.difficulty.t3Warning': + 'Les rutes T3 poden incloure senders de muntanya exposats o tècnicament exigents que requereixen una trepitjada segura i no tenir vertigen.', + 'tours.planner.difficulty.alpineTitle': 'Vols activar les rutes alpines?', + 'tours.planner.difficulty.alpineBody': + "El càlcul de rutes alpines està activat. Les rutes poden travessar terrenys exposats, sense senyalitzar o tècnicament difícils. Les dades de dificultat d'OSM poden faltar o ser incorrectes.", + 'tours.planner.difficulty.enable': 'Activa les rutes alpines', + 'tours.planner.openFailed': "No s'ha pogut obrir l'excursió desada per editar-la.", + 'tours.planner.status.dirty': 'Pendent de càlcul', + 'tours.planner.status.routing': 'Calculant ruta a peu…', + 'tours.planner.status.routingFailed': "El càlcul de la ruta ha fallat. Es continua mostrant l'última ruta vàlida.", + 'tours.planner.status.elevation': 'Carregant altitud…', + 'tours.planner.status.elevationFailed': "L'altitud no està disponible. Es continua mostrant la ruta calculada.", + 'tours.planner.status.ready': 'Ruta i altitud a punt', + 'tours.planner.status.saving': 'Desant…', + 'tours.planner.status.saveFailed': "No s'ha pogut desar l'excursió. L'esborrany continua disponible.", + 'tours.planner.retry': 'Torna-ho a provar', + 'tours.planner.inspector.title': "Resum de l'excursió", + 'tours.planner.inspector.empty': 'Els detalls de la ruta apareixen aquí després de connectar dos punts de pas.', + 'tours.planner.inspector.distance': 'Distància', + 'tours.planner.inspector.duration': 'Temps a peu', + 'tours.planner.inspector.elevation': "Perfil d'altitud", + 'tours.planner.inspector.profileFocus': 'A {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + "El perfil es mostra quan hi ha dades d'altitud de la ruta disponibles.", + 'tours.planner.gpxNoGeometry': 'La geometria de la ruta no està disponible per a aquesta importació GPX.', + 'tours.planner.mobileHint': + 'La planificació d’excursions està disponible en tauletes i ordinadors. Aquí pots consultar les excursions desades i assignar-les a un dia.', + 'tourTypes.hike': 'Senderisme', + 'tourTypes.bike': 'Ruta en bicicleta', + 'tourTypes.run': 'Córrer', + 'tourTypes.ski': 'Esquí de muntanya', + 'tourTypes.kayak': 'Ruta en caiac', + 'tourTypes.walk': 'Passeig urbà / turístic', + 'tourTypes.food': 'Ruta gastronòmica / enològica', +}; +export default tours; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts new file mode 100644 index 0000000000..c0ac8607f1 --- /dev/null +++ b/shared/src/i18n/cs/tours.ts @@ -0,0 +1,131 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Výlety', + 'tours.mode.places': 'Místa', + 'tours.mode.tours': 'Výlety', + 'tours.mode.label': 'Zobrazení pro přidávání', + 'tours.title': 'Výlety', + 'tours.subtitle': 'Pěší túry a další jednodenní výlety během této cesty', + 'tours.empty.title': 'Zatím žádné výlety', + 'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX.', + 'tours.addToDay': 'Přidat ke dni', + 'tours.addToDay.comingSoon': 'Přiřaďte výlety ke dni cesty ze seznamu výletů.', + 'tours.addToDay.pickDay': 'Vyberte den', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Import GPX', + 'tours.badge.planned': 'naplánováno v TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'používejte s opatrností', + 'tours.caution.tooltip': 'Neúplné výškové údaje nebo metriky – importováno s výhradou', + 'tours.import.caution': 'Importováno s výhradou – neúplné výškové údaje nebo metriky', + 'tours.import.success': 'Počet výletů importovaných z GPX: {count}', + 'tours.import.successOne': 'Z GPX byl importován 1 výlet', + 'tours.import.noTrack': 'V tomto souboru GPX nebyla nalezena stopa ani trasa', + 'tours.import.error': 'Soubor GPX se nepodařilo importovat', + 'tours.delete.confirmBody': + 'Opravdu chcete tento výlet trvale odstranit? Jeho trasu, upravitelné body a přiřazení ke dnům odstraníte. Tuto akci nelze vrátit zpět.', + 'tours.delete.bulkConfirmBody': + 'Výběr obsahuje alespoň jeden výlet. Všechna vybraná místa budou trvale odstraněna a nepůjde je obnovit. Odstraní se také trasy, upravitelné body a přiřazení výletů ke dnům.', + 'tours.delete.confirmAction': 'Trvale odstranit', + 'tours.addedToDay': 'Přidáno ke dni {n}', + 'tours.dayRoute.endpointUnknown': 'Koncový bod výletu se nepodařilo určit. Navazující úseky mohou být neúplné.', + 'tours.detail.trackColor': 'Barva stopy', + 'tours.detail.distance': 'Vzdálenost', + 'tours.detail.minAltitude': 'Minimální nadmořská výška', + 'tours.detail.maxAltitude': 'Maximální nadmořská výška', + 'tours.detail.ascent': 'Stoupání', + 'tours.detail.descent': 'Klesání', + 'tours.detail.difficulty': 'Obtížnost', + 'tours.detail.source': 'Zdroj', + 'tours.detail.gpxReadOnly': 'Výlety GPX lze pouze prohlížet; v plánovači je zatím nelze upravovat.', + 'tours.detail.navigationHint': 'Navigace otevře výchozí bod túry; nevede po její trase.', + 'tours.planner.title': 'Naplánovat výlet', + 'tours.planner.newTour': 'Naplánovat nový výlet', + 'tours.planner.neutralTitle': 'Naplánovat výlet', + 'tours.planner.neutralBody': 'Začněte novou túru a kliknutím do mapy umístěte její body.', + 'tours.planner.newTitle': 'Nový výlet', + 'tours.planner.editTitle': 'Upravit výlet', + 'tours.planner.gpxTitle': 'Výlet GPX', + 'tours.planner.backToTours': 'Zpět na výlety', + 'tours.planner.restored': 'Neuložený koncept obnoven', + 'tours.planner.discardChanges': 'Zahodit změny', + 'tours.planner.discardUnsaved': 'Zahodit neuložené změny', + 'tours.planner.saveChanges': 'Uložit změny', + 'tours.planner.newTourConfirmTitle': 'Začít nový výlet?', + 'tours.planner.newTourConfirmBody': 'Neuložené změny tohoto výletu budou zahozeny. Uložené výlety se nezmění.', + 'tours.planner.newTourConfirm': 'Zahodit změny a začít nový výlet', + 'tours.planner.startingPoint': 'Kliknutím na mapu přidejte výchozí bod.', + 'tours.planner.firstUseDetails': + 'Přidejte další bod pro výpočet trasy. Trasa a výškové údaje se aktualizují automaticky; hotový výlet uložte.', + 'tours.planner.collapseElevation': 'Sbalit výškový profil', + 'tours.planner.expandElevation': 'Rozbalit výškový profil', + 'tours.planner.name': 'Název výletu', + 'tours.planner.namePlaceholder': 'Túra bez názvu', + 'tours.planner.mapHint': 'Kliknutím do mapy přidáte body.', + 'tours.planner.waypoints': 'Průjezdní body', + 'tours.planner.waypointLabel': 'Bod {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Pro výpočet pěší trasy přidejte alespoň dva body.', + 'tours.planner.start': 'Start', + 'tours.planner.via': 'Přes', + 'tours.planner.end': 'Cíl', + 'tours.planner.moveUp': 'Posunout bod nahoru', + 'tours.planner.moveDown': 'Posunout bod dolů', + 'tours.planner.remove': 'Odstranit bod', + 'tours.planner.undo': 'Vrátit úpravu bodu', + 'tours.planner.redo': 'Zopakovat úpravu bodu', + 'tours.planner.discard': 'Zahodit koncept', + 'tours.planner.discardTitle': 'Zahodit neuložené změny výletu?', + 'tours.planner.discardBody': + 'Neuložené úpravy budou zahozeny. Uložený výlet, jeho body, soubory a přiřazení se nezmění.', + 'tours.planner.save': 'Uložit výlet', + 'tours.planner.saved': 'Výlet uložen', + 'tours.planner.planAnother': 'Naplánovat další', + 'tours.planner.assignToDay': 'Přiřadit ke dni', + 'tours.planner.tripTours': 'Výlety této cesty', + 'tours.planner.safetyNote': 'Výpočet trasy pomáhá s plánováním, nezaručuje bezpečnost.', + 'tours.planner.safetyNoteDetails': + 'Značky obtížnosti OSM mohou chybět nebo být nesprávné. Počasí, uzavírky a aktuální podmínky se nevyhodnocují.', + 'tours.planner.maxDifficulty': 'Maximální obtížnost stezky', + 'tours.planner.difficulty.t1': 'T1 – Pěší turistika', + 'tours.planner.difficulty.t2': 'T2 – Horská turistika', + 'tours.planner.difficulty.t3': 'T3 – Náročná horská turistika', + 'tours.planner.difficulty.t4': 'T4 – Vysokohorská turistika', + 'tours.planner.difficulty.t5': 'T5 – Náročná vysokohorská turistika', + 'tours.planner.difficulty.t6': 'T6 – Obtížná vysokohorská turistika', + 'tours.planner.difficulty.t3Warning': + 'Trasy T3 mohou vést po exponovaných nebo technicky náročných horských stezkách, které vyžadují jistý krok a odolnost vůči závratím z výšek.', + 'tours.planner.difficulty.alpineTitle': 'Povolit vysokohorské trasy?', + 'tours.planner.difficulty.alpineBody': + 'Vysokohorské trasy jsou povoleny. Mohou vést exponovaným, neznačeným nebo technicky obtížným terénem. Údaje o obtížnosti v OSM mohou chybět nebo být nesprávné.', + 'tours.planner.difficulty.enable': 'Povolit vysokohorské trasy', + 'tours.planner.openFailed': 'Uložený výlet se nepodařilo otevřít pro úpravy.', + 'tours.planner.status.dirty': 'Čeká na výpočet', + 'tours.planner.status.routing': 'Výpočet pěší trasy…', + 'tours.planner.status.routingFailed': 'Výpočet trasy selhal. Stále se zobrazuje poslední platná trasa.', + 'tours.planner.status.elevation': 'Načítání výškových údajů…', + 'tours.planner.status.elevationFailed': 'Výškové údaje nejsou dostupné. Vypočtená trasa se stále zobrazuje.', + 'tours.planner.status.ready': 'Trasa a výškové údaje jsou připraveny', + 'tours.planner.status.saving': 'Ukládání…', + 'tours.planner.status.saveFailed': 'Výlet se nepodařilo uložit. Koncept zůstává zachován.', + 'tours.planner.retry': 'Zkusit znovu', + 'tours.planner.inspector.title': 'Přehled výletu', + 'tours.planner.inspector.empty': 'Podrobnosti trasy se zde zobrazí po propojení dvou bodů.', + 'tours.planner.inspector.distance': 'Vzdálenost', + 'tours.planner.inspector.duration': 'Doba chůze', + 'tours.planner.inspector.elevation': 'Výškový profil', + 'tours.planner.inspector.profileFocus': 'Ve vzdálenosti {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profil se zobrazí, až budou k dispozici výškové údaje trasy.', + 'tours.planner.gpxNoGeometry': 'Pro tento import GPX není dostupná geometrie trasy.', + 'tours.planner.mobileHint': + 'Plánování výletů je dostupné na tabletu a počítači. Zde lze uložené výlety prohlížet a přiřazovat ke dnům.', + 'tourTypes.hike': 'Pěší túra', + 'tourTypes.bike': 'Cyklovýlet', + 'tourTypes.run': 'Běh', + 'tourTypes.ski': 'Skialpinistická túra', + 'tourTypes.kayak': 'Kajak', + 'tourTypes.walk': 'Procházka městem / po památkách', + 'tourTypes.food': 'Gastronomický / vinařský výlet', +}; +export default tours; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts new file mode 100644 index 0000000000..992c90906c --- /dev/null +++ b/shared/src/i18n/de/tours.ts @@ -0,0 +1,136 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Touren', + 'tours.mode.places': 'Orte', + 'tours.mode.tours': 'Touren', + 'tours.mode.label': 'Ansicht zum Hinzufügen', + 'tours.title': 'Touren', + 'tours.subtitle': 'Wanderungen und andere Tagestouren dieser Reise', + 'tours.empty.title': 'Noch keine Touren', + 'tours.empty.body': 'Plane eine Tour oder importiere eine GPX-Datei.', + 'tours.addToDay': 'Zum Tag hinzufügen', + 'tours.addToDay.comingSoon': 'Ordne Touren über die Tourenliste einem Reisetag zu.', + 'tours.addToDay.pickDay': 'Tag auswählen', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX-Import', + 'tours.badge.planned': 'in TREK geplant', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'mit Vorsicht verwenden', + 'tours.caution.tooltip': 'Höhendaten oder Streckenwerte unvollständig – mit Vorsicht importiert', + 'tours.import.caution': 'Mit Vorsicht importiert – Höhendaten oder Streckenwerte unvollständig', + 'tours.import.success': '{count} Touren aus GPX importiert', + 'tours.import.successOne': '1 Tour aus GPX importiert', + 'tours.import.noTrack': 'Keine Strecke oder Route in dieser GPX-Datei gefunden', + 'tours.import.error': 'GPX-Datei konnte nicht importiert werden', + 'tours.delete.confirmBody': + 'Diese Tour dauerhaft löschen? Ihre Route, bearbeitbaren Wegpunkte und Tageszuweisungen werden entfernt. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'tours.delete.bulkConfirmBody': + 'Diese Auswahl enthält mindestens eine Tour. Alle ausgewählten Orte werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Tourrouten, bearbeitbare Wegpunkte und Tageszuweisungen werden ebenfalls entfernt.', + 'tours.delete.confirmAction': 'Dauerhaft löschen', + 'tours.addedToDay': 'Zu Tag {n} hinzugefügt', + 'tours.dayRoute.endpointUnknown': + 'Der Endpunkt der Tour konnte nicht ermittelt werden. Folgende Verbindungen sind möglicherweise unvollständig.', + 'tours.detail.trackColor': 'Streckenfarbe', + 'tours.detail.distance': 'Strecke', + 'tours.detail.minAltitude': 'Niedrigster Punkt', + 'tours.detail.maxAltitude': 'Höchster Punkt', + 'tours.detail.ascent': 'Aufstieg', + 'tours.detail.descent': 'Abstieg', + 'tours.detail.difficulty': 'Schwierigkeit', + 'tours.detail.source': 'Quelle', + 'tours.detail.gpxReadOnly': 'GPX-Touren sind schreibgeschützt und können im Planer noch nicht bearbeitet werden.', + 'tours.detail.navigationHint': 'Die Navigation führt zum Startpunkt, nicht entlang der Tour.', + 'tours.planner.title': 'Tour planen', + 'tours.planner.newTour': 'Neue Tour planen', + 'tours.planner.neutralTitle': 'Tour planen', + 'tours.planner.neutralBody': 'Starte eine neue Tour und klicke auf die Karte, um die Wegpunkte zu setzen.', + 'tours.planner.newTitle': 'Neue Tour', + 'tours.planner.editTitle': 'Tour bearbeiten', + 'tours.planner.gpxTitle': 'GPX-Tour', + 'tours.planner.backToTours': 'Zurück zu den Touren', + 'tours.planner.restored': 'Ungespeicherter Entwurf wiederhergestellt', + 'tours.planner.discardChanges': 'Änderungen verwerfen', + 'tours.planner.discardUnsaved': 'Ungespeicherte Änderungen verwerfen', + 'tours.planner.saveChanges': 'Änderungen speichern', + 'tours.planner.newTourConfirmTitle': 'Neue Tour beginnen?', + 'tours.planner.newTourConfirmBody': + 'Ungespeicherte Änderungen an dieser Tour gehen verloren. Gespeicherte Touren bleiben erhalten.', + 'tours.planner.newTourConfirm': 'Änderungen verwerfen und neue Tour beginnen', + 'tours.planner.startingPoint': 'Klicke auf die Karte, um deinen Startpunkt zu setzen.', + 'tours.planner.firstUseDetails': + 'Setze einen weiteren Punkt, um eine Route zu berechnen. Route und Höhenprofil werden automatisch aktualisiert. Speichere die Tour, wenn sie fertig ist.', + 'tours.planner.collapseElevation': 'Höhenprofil einklappen', + 'tours.planner.expandElevation': 'Höhenprofil ausklappen', + 'tours.planner.name': 'Name der Tour', + 'tours.planner.namePlaceholder': 'Wanderung ohne Titel', + 'tours.planner.mapHint': 'Klicke auf die Karte, um Punkte zu setzen.', + 'tours.planner.waypoints': 'Wegpunkte', + 'tours.planner.waypointLabel': 'Wegpunkt {n}', + 'tours.durationMinutes': '{count} Min.', + 'tours.planner.empty': 'Setze mindestens zwei Punkte, um eine Wanderroute zu berechnen.', + 'tours.planner.start': 'Start', + 'tours.planner.via': 'Zwischenstopp', + 'tours.planner.end': 'Ziel', + 'tours.planner.moveUp': 'Wegpunkt nach oben verschieben', + 'tours.planner.moveDown': 'Wegpunkt nach unten verschieben', + 'tours.planner.remove': 'Wegpunkt entfernen', + 'tours.planner.undo': 'Wegpunkt-Änderung rückgängig machen', + 'tours.planner.redo': 'Wegpunkt-Änderung wiederholen', + 'tours.planner.discard': 'Entwurf verwerfen', + 'tours.planner.discardTitle': 'Ungespeicherte Änderungen an der Tour verwerfen?', + 'tours.planner.discardBody': + 'Ungespeicherte Änderungen gehen verloren. Die gespeicherte Tour, ihre Wegpunkte, Dateien und Tageszuordnungen bleiben erhalten.', + 'tours.planner.save': 'Tour speichern', + 'tours.planner.saved': 'Tour gespeichert', + 'tours.planner.planAnother': 'Weitere Tour planen', + 'tours.planner.assignToDay': 'Einem Tag zuordnen', + 'tours.planner.tripTours': 'Touren dieser Reise', + 'tours.planner.safetyNote': 'TREK hilft bei der Planung, bewertet aber nicht die Sicherheit einer Route.', + 'tours.planner.safetyNoteDetails': + 'OSM-Angaben zur Schwierigkeit können fehlen oder falsch sein. Wetter, Sperrungen und aktuelle Bedingungen werden nicht berücksichtigt.', + 'tours.planner.maxDifficulty': 'Maximale Wegschwierigkeit', + 'tours.planner.difficulty.t1': 'T1 – Wandern', + 'tours.planner.difficulty.t2': 'T2 – Bergwandern', + 'tours.planner.difficulty.t3': 'T3 – Anspruchsvolles Bergwandern', + 'tours.planner.difficulty.t4': 'T4 – Alpinwandern', + 'tours.planner.difficulty.t5': 'T5 – Anspruchsvolles Alpinwandern', + 'tours.planner.difficulty.t6': 'T6 – Schwieriges Alpinwandern', + 'tours.planner.difficulty.t3Warning': + 'T3-Routen können ausgesetzte oder technisch anspruchsvolle Bergwege enthalten. Trittsicherheit und Schwindelfreiheit sind erforderlich.', + 'tours.planner.difficulty.alpineTitle': 'Alpine Routenplanung aktivieren?', + 'tours.planner.difficulty.alpineBody': + 'Alpine Routen können durch ausgesetztes, unmarkiertes oder technisch schwieriges Gelände führen. OSM-Angaben zur Schwierigkeit können fehlen oder falsch sein.', + 'tours.planner.difficulty.enable': 'Alpine Routenplanung aktivieren', + 'tours.planner.openFailed': 'Die gespeicherte Tour konnte nicht zur Bearbeitung geöffnet werden.', + 'tours.planner.status.dirty': 'Berechnung ausstehend', + 'tours.planner.status.routing': 'Wanderroute wird berechnet...', + 'tours.planner.status.routingFailed': + 'Route konnte nicht berechnet werden. Die letzte gültige Route bleibt sichtbar.', + 'tours.planner.status.elevation': 'Höhendaten werden geladen...', + 'tours.planner.status.elevationFailed': 'Höhendaten sind nicht verfügbar. Die berechnete Route bleibt sichtbar.', + 'tours.planner.status.ready': 'Route und Höhendaten bereit', + 'tours.planner.status.saving': 'Tour wird gespeichert...', + 'tours.planner.status.saveFailed': 'Tour konnte nicht gespeichert werden. Dein Entwurf ist weiterhin vorhanden.', + 'tours.planner.retry': 'Erneut versuchen', + 'tours.planner.inspector.title': 'Tourenübersicht', + 'tours.planner.inspector.empty': 'Routendetails erscheinen, sobald zwei Wegpunkte verbunden sind.', + 'tours.planner.inspector.distance': 'Strecke', + 'tours.planner.inspector.duration': 'Gehzeit', + 'tours.planner.inspector.elevation': 'Höhenprofil', + 'tours.planner.inspector.profileFocus': 'Bei {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'Das Höhenprofil erscheint, sobald Höhendaten für die Route verfügbar sind.', + 'tours.planner.gpxNoGeometry': 'Für diesen GPX-Import sind keine Routendaten verfügbar.', + 'tours.planner.mobileHint': + 'Touren planen kannst du auf einem Tablet oder am Computer. Hier kannst du gespeicherte Touren ansehen und zuordnen.', + 'tourTypes.hike': 'Wanderung', + 'tourTypes.bike': 'Radtour', + 'tourTypes.run': 'Lauf', + 'tourTypes.ski': 'Skitour', + 'tourTypes.kayak': 'Kajaktour', + 'tourTypes.walk': 'Stadt- und Spaziergang', + 'tourTypes.food': 'Genusstour', +}; + +export default tours; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts new file mode 100644 index 0000000000..27db698fe6 --- /dev/null +++ b/shared/src/i18n/en/tours.ts @@ -0,0 +1,132 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Tours', + 'tours.mode.places': 'Places', + 'tours.mode.tours': 'Tours', + 'tours.mode.label': 'Add view', + 'tours.title': 'Tours', + 'tours.subtitle': 'Hikes and other single-day tours for this trip', + 'tours.empty.title': 'No tours yet', + 'tours.empty.body': 'Plan a tour or import a GPX file to get started.', + 'tours.addToDay': 'Add to day', + 'tours.addToDay.comingSoon': 'Assign tours to a trip day from the tour list.', + 'tours.addToDay.pickDay': 'Pick a day', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX import', + 'tours.badge.planned': 'planned in TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'use with caution', + 'tours.caution.tooltip': 'Elevation/metrics incomplete — imported with caution', + 'tours.import.caution': 'Imported with caution — elevation/metrics incomplete', + 'tours.import.success': '{count} tours imported from GPX', + 'tours.import.successOne': '1 tour imported from GPX', + 'tours.import.noTrack': 'No track or route found in that GPX file', + 'tours.import.error': 'Could not import GPX file', + 'tours.delete.confirmBody': + 'Delete this Tour permanently? Its route, editable waypoints and day assignments will be removed. This action cannot be undone.', + 'tours.delete.bulkConfirmBody': + 'This selection includes at least one Tour. All selected places will be deleted permanently and cannot be restored. Tour routes, editable waypoints and day assignments will also be removed.', + 'tours.delete.confirmAction': 'Delete permanently', + 'tours.addedToDay': 'Added to day {n}', + 'tours.dayRoute.endpointUnknown': 'Tour endpoint could not be determined. Following connections may be incomplete.', + 'tours.detail.trackColor': 'Track colour', + 'tours.detail.distance': 'Distance', + 'tours.detail.minAltitude': 'Minimum altitude', + 'tours.detail.maxAltitude': 'Maximum altitude', + 'tours.detail.ascent': 'Ascent', + 'tours.detail.descent': 'Descent', + 'tours.detail.difficulty': 'Difficulty', + 'tours.detail.source': 'Source', + 'tours.detail.gpxReadOnly': 'GPX tours are view-only and cannot be edited in the planner yet.', + 'tours.detail.navigationHint': 'Navigation opens the trailhead; it does not navigate along the route.', + 'tours.planner.title': 'Plan a tour', + 'tours.planner.newTour': 'Plan new tour', + 'tours.planner.neutralTitle': 'Plan a tour', + 'tours.planner.neutralBody': 'Start a new tour and click the map to place its waypoints.', + 'tours.planner.newTitle': 'New tour', + 'tours.planner.editTitle': 'Edit tour', + 'tours.planner.gpxTitle': 'GPX tour', + 'tours.planner.backToTours': 'Back to Tours', + 'tours.planner.restored': 'Unsaved draft restored', + 'tours.planner.discardChanges': 'Discard changes', + 'tours.planner.discardUnsaved': 'Discard unsaved changes', + 'tours.planner.saveChanges': 'Save changes', + 'tours.planner.newTourConfirmTitle': 'Start a new tour?', + 'tours.planner.newTourConfirmBody': + 'Your unsaved changes to this tour will be discarded. Saved tours will not be changed.', + 'tours.planner.newTourConfirm': 'Discard changes and start new tour', + 'tours.planner.startingPoint': 'Click the map to add your starting point.', + 'tours.planner.firstUseDetails': + 'Add another point to calculate a route. The route and elevation update automatically; save when ready.', + 'tours.planner.collapseElevation': 'Collapse elevation profile', + 'tours.planner.expandElevation': 'Expand elevation profile', + 'tours.planner.name': 'Tour name', + 'tours.planner.namePlaceholder': 'Untitled hike', + 'tours.planner.mapHint': 'Click the map to add points.', + 'tours.planner.waypoints': 'Waypoints', + 'tours.planner.waypointLabel': 'Waypoint {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Add at least two points to calculate a walking route.', + 'tours.planner.start': 'Start', + 'tours.planner.via': 'Via', + 'tours.planner.end': 'End', + 'tours.planner.moveUp': 'Move waypoint up', + 'tours.planner.moveDown': 'Move waypoint down', + 'tours.planner.remove': 'Remove waypoint', + 'tours.planner.undo': 'Undo waypoint edit', + 'tours.planner.redo': 'Redo waypoint edit', + 'tours.planner.discard': 'Discard draft', + 'tours.planner.discardTitle': 'Discard unsaved tour changes?', + 'tours.planner.discardBody': + 'Your unsaved edits will be discarded. The saved Tour, its waypoints, files and assignments will not be changed.', + 'tours.planner.save': 'Save tour', + 'tours.planner.saved': 'Tour saved', + 'tours.planner.planAnother': 'Plan another', + 'tours.planner.assignToDay': 'Assign to a day', + 'tours.planner.tripTours': 'Tours of this trip', + 'tours.planner.safetyNote': 'Routing is planning assistance, not a safety guarantee.', + 'tours.planner.safetyNoteDetails': + 'OSM difficulty tags may be missing or incorrect. Weather, closures and current conditions are not evaluated.', + 'tours.planner.maxDifficulty': 'Maximum trail difficulty', + 'tours.planner.difficulty.t1': 'T1 — Hiking', + 'tours.planner.difficulty.t2': 'T2 — Mountain hiking', + 'tours.planner.difficulty.t3': 'T3 — Demanding mountain hiking', + 'tours.planner.difficulty.t4': 'T4 — Alpine hiking', + 'tours.planner.difficulty.t5': 'T5 — Demanding alpine hiking', + 'tours.planner.difficulty.t6': 'T6 — Difficult alpine hiking', + 'tours.planner.difficulty.t3Warning': + 'T3 routes may include exposed or technically demanding mountain trails requiring sure-footedness and a head for heights.', + 'tours.planner.difficulty.alpineTitle': 'Enable alpine routing?', + 'tours.planner.difficulty.alpineBody': + 'Alpine routing enabled. Routes may cross exposed, unmarked or technically difficult terrain. OSM difficulty data can be missing or incorrect.', + 'tours.planner.difficulty.enable': 'Enable alpine routing', + 'tours.planner.openFailed': 'The saved tour could not be opened for editing.', + 'tours.planner.status.dirty': 'Waiting to calculate', + 'tours.planner.status.routing': 'Calculating walking route…', + 'tours.planner.status.routingFailed': 'Route calculation failed. The last good route is still shown.', + 'tours.planner.status.elevation': 'Loading elevation…', + 'tours.planner.status.elevationFailed': 'Elevation is unavailable. The calculated route is still shown.', + 'tours.planner.status.ready': 'Route and elevation ready', + 'tours.planner.status.saving': 'Saving…', + 'tours.planner.status.saveFailed': 'Tour could not be saved. Your draft is still here.', + 'tours.planner.retry': 'Retry', + 'tours.planner.inspector.title': 'Tour summary', + 'tours.planner.inspector.empty': 'Route details appear here after two waypoints are connected.', + 'tours.planner.inspector.distance': 'Distance', + 'tours.planner.inspector.duration': 'Walking time', + 'tours.planner.inspector.elevation': 'Elevation profile', + 'tours.planner.inspector.profileFocus': 'At {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'The profile is shown when route elevation data is available.', + 'tours.planner.gpxNoGeometry': 'Route geometry is unavailable for this GPX import.', + 'tours.planner.mobileHint': + 'Tour planning is available on tablet and desktop. Saved tours can be viewed and scheduled here.', + 'tourTypes.hike': 'Hike', + 'tourTypes.bike': 'Bike tour', + 'tourTypes.run': 'Run', + 'tourTypes.ski': 'Ski tour', + 'tourTypes.kayak': 'Kayak', + 'tourTypes.walk': 'City / sightseeing walk', + 'tourTypes.food': 'Food/wine tour', +}; +export default tours; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index e4f631e73e..87546f168c 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts new file mode 100644 index 0000000000..795186d234 --- /dev/null +++ b/shared/src/i18n/es/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Excursiones', + 'tours.mode.places': 'Lugares', + 'tours.mode.tours': 'Excursiones', + 'tours.mode.label': 'Añadir vista', + 'tours.title': 'Excursiones', + 'tours.subtitle': 'Rutas de senderismo y otras excursiones de un día para este viaje', + 'tours.empty.title': 'Aún no hay excursiones', + 'tours.empty.body': 'Planifica una excursión o importa un archivo GPX para empezar.', + 'tours.addToDay': 'Añadir a un día', + 'tours.addToDay.comingSoon': 'Asigna excursiones a un día del viaje desde la lista de excursiones.', + 'tours.addToDay.pickDay': 'Elige un día', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Importación GPX', + 'tours.badge.planned': 'planificada en TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'usar con precaución', + 'tours.caution.tooltip': 'Altitud o métricas incompletas: importada con reservas', + 'tours.import.caution': 'Importada con reservas: altitud o métricas incompletas', + 'tours.import.success': '{count} excursiones importadas desde GPX', + 'tours.import.successOne': '1 excursión importada desde GPX', + 'tours.import.noTrack': 'No se ha encontrado ningún track ni ruta en ese archivo GPX', + 'tours.import.error': 'No se ha podido importar el archivo GPX', + 'tours.delete.confirmBody': + '¿Eliminar esta ruta permanentemente? Se eliminarán el recorrido, los puntos editables y las asignaciones a días. Esta acción no se puede deshacer.', + 'tours.delete.bulkConfirmBody': + 'La selección incluye al menos una ruta. Todos los lugares seleccionados se eliminarán permanentemente y no se podrán restaurar. También se eliminarán las rutas, los puntos editables y las asignaciones a días.', + 'tours.delete.confirmAction': 'Eliminar permanentemente', + 'tours.addedToDay': 'Añadida al día {n}', + 'tours.dayRoute.endpointUnknown': + 'No se ha podido determinar el punto final de la excursión. Las conexiones posteriores pueden estar incompletas.', + 'tours.detail.trackColor': 'Color del track', + 'tours.detail.distance': 'Distancia', + 'tours.detail.minAltitude': 'Altitud mínima', + 'tours.detail.maxAltitude': 'Altitud máxima', + 'tours.detail.ascent': 'Desnivel positivo', + 'tours.detail.descent': 'Desnivel negativo', + 'tours.detail.difficulty': 'Dificultad', + 'tours.detail.source': 'Origen', + 'tours.detail.gpxReadOnly': 'Las excursiones GPX son de solo lectura y aún no se pueden editar en el planificador.', + 'tours.detail.navigationHint': 'La navegación abre el punto de inicio; no guía a lo largo de la ruta.', + 'tours.planner.title': 'Planificar una excursión', + 'tours.planner.newTour': 'Planificar nueva excursión', + 'tours.planner.neutralTitle': 'Planificar una excursión', + 'tours.planner.neutralBody': 'Empieza una ruta nueva y haz clic en el mapa para colocar sus puntos.', + 'tours.planner.newTitle': 'Nueva excursión', + 'tours.planner.editTitle': 'Editar excursión', + 'tours.planner.gpxTitle': 'Excursión GPX', + 'tours.planner.backToTours': 'Volver a excursiones', + 'tours.planner.restored': 'Borrador sin guardar restaurado', + 'tours.planner.discardChanges': 'Descartar cambios', + 'tours.planner.discardUnsaved': 'Descartar cambios sin guardar', + 'tours.planner.saveChanges': 'Guardar cambios', + 'tours.planner.newTourConfirmTitle': '¿Empezar una nueva excursión?', + 'tours.planner.newTourConfirmBody': + 'Se descartarán los cambios sin guardar de esta excursión. Las excursiones guardadas no se modificarán.', + 'tours.planner.newTourConfirm': 'Descartar y empezar nueva excursión', + 'tours.planner.startingPoint': 'Haz clic en el mapa para añadir el punto de inicio.', + 'tours.planner.firstUseDetails': + 'Añade otro punto para calcular una ruta. La ruta y la altitud se actualizan automáticamente; guarda cuando termines.', + 'tours.planner.collapseElevation': 'Contraer perfil de altitud', + 'tours.planner.expandElevation': 'Expandir perfil de altitud', + 'tours.planner.name': 'Nombre de la excursión', + 'tours.planner.namePlaceholder': 'Ruta de senderismo sin título', + 'tours.planner.mapHint': 'Haz clic en el mapa para añadir puntos.', + 'tours.planner.waypoints': 'Puntos de paso', + 'tours.planner.waypointLabel': 'Punto de paso {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Añade al menos dos puntos para calcular una ruta a pie.', + 'tours.planner.start': 'Inicio', + 'tours.planner.via': 'Paso por', + 'tours.planner.end': 'Final', + 'tours.planner.moveUp': 'Subir punto de paso', + 'tours.planner.moveDown': 'Bajar punto de paso', + 'tours.planner.remove': 'Eliminar punto de paso', + 'tours.planner.undo': 'Deshacer cambio de punto de paso', + 'tours.planner.redo': 'Rehacer cambio de punto de paso', + 'tours.planner.discard': 'Descartar borrador', + 'tours.planner.discardTitle': '¿Descartar los cambios sin guardar de la excursión?', + 'tours.planner.discardBody': + 'Se descartarán los cambios sin guardar. La excursión guardada, sus puntos de paso, archivos y asignaciones no se modificarán.', + 'tours.planner.save': 'Guardar excursión', + 'tours.planner.saved': 'Excursión guardada', + 'tours.planner.planAnother': 'Planificar otra', + 'tours.planner.assignToDay': 'Asignar a un día', + 'tours.planner.tripTours': 'Excursiones de este viaje', + 'tours.planner.safetyNote': 'El cálculo de rutas ayuda a planificar, pero no garantiza la seguridad.', + 'tours.planner.safetyNoteDetails': + 'Las etiquetas de dificultad de OSM pueden faltar o ser incorrectas. No se evalúan el tiempo, los cierres ni las condiciones actuales.', + 'tours.planner.maxDifficulty': 'Dificultad máxima del sendero', + 'tours.planner.difficulty.t1': 'T1 — Senderismo', + 'tours.planner.difficulty.t2': 'T2 — Senderismo de montaña', + 'tours.planner.difficulty.t3': 'T3 — Senderismo de montaña exigente', + 'tours.planner.difficulty.t4': 'T4 — Senderismo alpino', + 'tours.planner.difficulty.t5': 'T5 — Senderismo alpino exigente', + 'tours.planner.difficulty.t6': 'T6 — Senderismo alpino difícil', + 'tours.planner.difficulty.t3Warning': + 'Las rutas T3 pueden incluir senderos de montaña expuestos o técnicamente exigentes que requieren pisada segura y no tener vértigo.', + 'tours.planner.difficulty.alpineTitle': '¿Activar rutas alpinas?', + 'tours.planner.difficulty.alpineBody': + 'El cálculo de rutas alpinas está activado. Las rutas pueden atravesar terrenos expuestos, sin señalizar o técnicamente difíciles. Los datos de dificultad de OSM pueden faltar o ser incorrectos.', + 'tours.planner.difficulty.enable': 'Activar rutas alpinas', + 'tours.planner.openFailed': 'No se ha podido abrir la excursión guardada para editarla.', + 'tours.planner.status.dirty': 'Pendiente de cálculo', + 'tours.planner.status.routing': 'Calculando ruta a pie…', + 'tours.planner.status.routingFailed': 'No se ha podido calcular la ruta. Se sigue mostrando la última ruta válida.', + 'tours.planner.status.elevation': 'Cargando altitud…', + 'tours.planner.status.elevationFailed': 'La altitud no está disponible. Se sigue mostrando la ruta calculada.', + 'tours.planner.status.ready': 'Ruta y altitud listas', + 'tours.planner.status.saving': 'Guardando…', + 'tours.planner.status.saveFailed': 'No se ha podido guardar la excursión. El borrador sigue disponible.', + 'tours.planner.retry': 'Reintentar', + 'tours.planner.inspector.title': 'Resumen de la excursión', + 'tours.planner.inspector.empty': 'Los detalles de la ruta aparecen aquí tras conectar dos puntos de paso.', + 'tours.planner.inspector.distance': 'Distancia', + 'tours.planner.inspector.duration': 'Tiempo a pie', + 'tours.planner.inspector.elevation': 'Perfil de altitud', + 'tours.planner.inspector.profileFocus': 'A {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'El perfil se muestra cuando hay datos de altitud de la ruta.', + 'tours.planner.gpxNoGeometry': 'La geometría de la ruta no está disponible para esta importación GPX.', + 'tours.planner.mobileHint': + 'La planificación de excursiones está disponible en tabletas y ordenadores. Aquí puedes consultar las excursiones guardadas y asignarlas a un día.', + 'tourTypes.hike': 'Senderismo', + 'tourTypes.bike': 'Ruta en bicicleta', + 'tourTypes.run': 'Carrera a pie', + 'tourTypes.ski': 'Esquí de travesía', + 'tourTypes.kayak': 'Ruta en kayak', + 'tourTypes.walk': 'Paseo urbano / turístico', + 'tourTypes.food': 'Ruta gastronómica / enológica', +}; +export default tours; diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index d7fad9f621..73301a1465 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -71,6 +72,7 @@ const locale = { ...collection, ...atlas, ...trip, + ...tours, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts new file mode 100644 index 0000000000..c2adcfcbf9 --- /dev/null +++ b/shared/src/i18n/et/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Retked', + 'tours.mode.places': 'Kohad', + 'tours.mode.tours': 'Retked', + 'tours.mode.label': 'Lisamise vaade', + 'tours.title': 'Retked', + 'tours.subtitle': 'Selle reisi matkad ja muud ühepäevased retked', + 'tours.empty.title': 'Retki veel pole', + 'tours.empty.body': 'Alustamiseks planeeri retk või impordi GPX-fail.', + 'tours.addToDay': 'Lisa päevale', + 'tours.addToDay.comingSoon': 'Lisa retkede loendist retki reisipäevale.', + 'tours.addToDay.pickDay': 'Vali päev', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX-import', + 'tours.badge.planned': 'Planeeritud rakenduses TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'Kasuta ettevaatlikult', + 'tours.caution.tooltip': 'Kõrgusandmed või mõõdikud on puudulikud — imporditud hoiatusega', + 'tours.import.caution': 'Imporditud hoiatusega — kõrgusandmed või mõõdikud on puudulikud', + 'tours.import.success': 'GPX-failist imporditi {count} retke', + 'tours.import.successOne': 'GPX-failist imporditi 1 retk', + 'tours.import.noTrack': 'Sellest GPX-failist ei leitud rada ega marsruuti', + 'tours.import.error': 'GPX-faili importimine ebaõnnestus', + 'tours.delete.confirmBody': + 'Kas kustutada see matk jäädavalt? Selle marsruut, muudetavad vahepunktid ja päevadele määramised eemaldatakse. Seda toimingut ei saa tagasi võtta.', + 'tours.delete.bulkConfirmBody': + 'Valik sisaldab vähemalt üht matka. Kõik valitud kohad kustutatakse jäädavalt ja neid ei saa taastada. Eemaldatakse ka matkade marsruudid, muudetavad vahepunktid ja päevadele määramised.', + 'tours.delete.confirmAction': 'Kustuta jäädavalt', + 'tours.addedToDay': 'Lisatud päevale {n}', + 'tours.dayRoute.endpointUnknown': 'Retke lõpp-punkti ei saanud määrata. Järgnevad ühendused võivad olla puudulikud.', + 'tours.detail.trackColor': 'Raja värv', + 'tours.detail.distance': 'Vahemaa', + 'tours.detail.minAltitude': 'Madalaim kõrgus', + 'tours.detail.maxAltitude': 'Kõrgeim kõrgus', + 'tours.detail.ascent': 'Tõus', + 'tours.detail.descent': 'Laskumine', + 'tours.detail.difficulty': 'Raskusaste', + 'tours.detail.source': 'Allikas', + 'tours.detail.gpxReadOnly': 'GPX-retki saab ainult vaadata; planeerijas neid veel muuta ei saa.', + 'tours.detail.navigationHint': 'Navigeerimine avab raja alguspunkti, mitte juhiseid mööda marsruuti.', + 'tours.planner.title': 'Planeeri retk', + 'tours.planner.newTour': 'Planeeri uus retk', + 'tours.planner.neutralTitle': 'Planeeri retk', + 'tours.planner.neutralBody': 'Alusta uut matka ja klõpsa kaardil, et selle punktid paigutada.', + 'tours.planner.newTitle': 'Uus retk', + 'tours.planner.editTitle': 'Muuda retke', + 'tours.planner.gpxTitle': 'GPX-retk', + 'tours.planner.backToTours': 'Tagasi retkede juurde', + 'tours.planner.restored': 'Salvestamata mustand taastatud', + 'tours.planner.discardChanges': 'Loobu muudatustest', + 'tours.planner.discardUnsaved': 'Loobu salvestamata muudatustest', + 'tours.planner.saveChanges': 'Salvesta muudatused', + 'tours.planner.newTourConfirmTitle': 'Kas alustada uut retke?', + 'tours.planner.newTourConfirmBody': + 'Selle retke salvestamata muudatused lähevad kaotsi. Salvestatud retki ei muudeta.', + 'tours.planner.newTourConfirm': 'Loobu muudatustest ja alusta uut retke', + 'tours.planner.startingPoint': 'Klõpsa kaardil alguspunkti lisamiseks.', + 'tours.planner.firstUseDetails': + 'Lisa marsruudi arvutamiseks veel üks punkt. Marsruut ja kõrgusandmed uuenevad automaatselt; salvesta, kui oled valmis.', + 'tours.planner.collapseElevation': 'Ahenda kõrgusprofiil', + 'tours.planner.expandElevation': 'Laienda kõrgusprofiil', + 'tours.planner.name': 'Retke nimi', + 'tours.planner.namePlaceholder': 'Nimetu matk', + 'tours.planner.mapHint': 'Klõpsa kaardil punktide lisamiseks.', + 'tours.planner.waypoints': 'Teekonnapunktid', + 'tours.planner.waypointLabel': 'Teekonnapunkt {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Lisa jalgsimarsruudi arvutamiseks vähemalt kaks punkti.', + 'tours.planner.start': 'Algus', + 'tours.planner.via': 'Vahepunkt', + 'tours.planner.end': 'Lõpp', + 'tours.planner.moveUp': 'Liiguta teekonnapunkt üles', + 'tours.planner.moveDown': 'Liiguta teekonnapunkt alla', + 'tours.planner.remove': 'Eemalda teekonnapunkt', + 'tours.planner.undo': 'Võta teekonnapunkti muudatus tagasi', + 'tours.planner.redo': 'Tee teekonnapunkti muudatus uuesti', + 'tours.planner.discard': 'Loobu mustandist', + 'tours.planner.discardTitle': 'Kas loobuda retke salvestamata muudatustest?', + 'tours.planner.discardBody': + 'Salvestamata muudatused lähevad kaotsi. Salvestatud retke, selle teekonnapunkte, faile ega päevadele lisamisi ei muudeta.', + 'tours.planner.save': 'Salvesta retk', + 'tours.planner.saved': 'Retk salvestatud', + 'tours.planner.planAnother': 'Planeeri järgmine', + 'tours.planner.assignToDay': 'Lisa päevale', + 'tours.planner.tripTours': 'Selle reisi retked', + 'tours.planner.safetyNote': 'Marsruudi arvutamine aitab planeerida, kuid ei taga ohutust.', + 'tours.planner.safetyNoteDetails': + 'OSM-i raskusastme märgendid võivad puududa või olla valed. Ilma, teesulge ega hetkeolusid ei hinnata.', + 'tours.planner.maxDifficulty': 'Raja suurim raskusaste', + 'tours.planner.difficulty.t1': 'T1 — Matkamine', + 'tours.planner.difficulty.t2': 'T2 — Mägimatkamine', + 'tours.planner.difficulty.t3': 'T3 — Nõudlik mägimatkamine', + 'tours.planner.difficulty.t4': 'T4 — Alpimatkamine', + 'tours.planner.difficulty.t5': 'T5 — Nõudlik alpimatkamine', + 'tours.planner.difficulty.t6': 'T6 — Raske alpimatkamine', + 'tours.planner.difficulty.t3Warning': + 'T3-marsruudid võivad hõlmata kukkumisohuga või tehniliselt nõudlikke mägiradu, mis eeldavad kindlat sammu ja kõrguse talumist.', + 'tours.planner.difficulty.alpineTitle': 'Kas lubada alpimarsruutide arvutamine?', + 'tours.planner.difficulty.alpineBody': + 'Alpimarsruutide arvutamine on lubatud. Marsruudid võivad läbida kukkumisohuga, märgistamata või tehniliselt rasket maastikku. OSM-i raskusastme andmed võivad puududa või olla valed.', + 'tours.planner.difficulty.enable': 'Luba alpimarsruutide arvutamine', + 'tours.planner.openFailed': 'Salvestatud retke avamine muutmiseks ebaõnnestus.', + 'tours.planner.status.dirty': 'Arvutamise ootel', + 'tours.planner.status.routing': 'Jalgsimarsruudi arvutamine…', + 'tours.planner.status.routingFailed': + 'Marsruudi arvutamine ebaõnnestus. Viimane õnnestunud marsruut on endiselt nähtav.', + 'tours.planner.status.elevation': 'Kõrgusandmete laadimine…', + 'tours.planner.status.elevationFailed': 'Kõrgusandmed pole saadaval. Arvutatud marsruut on endiselt nähtav.', + 'tours.planner.status.ready': 'Marsruut ja kõrgusandmed on valmis', + 'tours.planner.status.saving': 'Salvestamine…', + 'tours.planner.status.saveFailed': 'Retke salvestamine ebaõnnestus. Mustand on alles.', + 'tours.planner.retry': 'Proovi uuesti', + 'tours.planner.inspector.title': 'Retke kokkuvõte', + 'tours.planner.inspector.empty': 'Marsruudi üksikasjad ilmuvad siia pärast kahe teekonnapunkti ühendamist.', + 'tours.planner.inspector.distance': 'Vahemaa', + 'tours.planner.inspector.duration': 'Kõndimisaeg', + 'tours.planner.inspector.elevation': 'Kõrgusprofiil', + 'tours.planner.inspector.profileFocus': 'Kaugusel {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profiil kuvatakse, kui marsruudi kõrgusandmed on saadaval.', + 'tours.planner.gpxNoGeometry': 'Selle GPX-impordi marsruudi geomeetria pole saadaval.', + 'tours.planner.mobileHint': + 'Retki saab planeerida tahvel- ja lauaarvutis. Siin saab salvestatud retki vaadata ja päevadele lisada.', + 'tourTypes.hike': 'Matk', + 'tourTypes.bike': 'Rattaretk', + 'tourTypes.run': 'Jooks', + 'tourTypes.ski': 'Suusaretk', + 'tourTypes.kayak': 'Süstaretk', + 'tourTypes.walk': 'Linna- / vaatamisväärsuste jalutuskäik', + 'tourTypes.food': 'Toidu- / veiniretk', +}; +export default tours; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts new file mode 100644 index 0000000000..cbc3f78330 --- /dev/null +++ b/shared/src/i18n/fr/tours.ts @@ -0,0 +1,138 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Sorties', + 'tours.mode.places': 'Lieux', + 'tours.mode.tours': 'Sorties', + 'tours.mode.label': 'Ajouter une vue', + 'tours.title': 'Sorties', + 'tours.subtitle': 'Randonnées et autres sorties à la journée pour ce voyage', + 'tours.empty.title': 'Aucune sortie pour le moment', + 'tours.empty.body': 'Planifiez une sortie ou importez un fichier GPX pour commencer.', + 'tours.addToDay': 'Ajouter à une journée', + 'tours.addToDay.comingSoon': 'Affectez les sorties à une journée du voyage depuis la liste des sorties.', + 'tours.addToDay.pickDay': 'Choisir une journée', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Import GPX', + 'tours.badge.planned': 'planifiée dans TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'à utiliser avec prudence', + 'tours.caution.tooltip': 'Altitude ou mesures incomplètes : importée avec réserves', + 'tours.import.caution': 'Importée avec réserves : altitude ou mesures incomplètes', + 'tours.import.success': '{count} sorties importées depuis GPX', + 'tours.import.successOne': '1 sortie importée depuis GPX', + 'tours.import.noTrack': 'Aucune trace ni aucun itinéraire trouvé dans ce fichier GPX', + 'tours.import.error': "Impossible d'importer le fichier GPX", + 'tours.delete.confirmBody': + 'Supprimer définitivement cette randonnée ? Son itinéraire, ses points modifiables et ses affectations aux jours seront supprimés. Cette action est irréversible.', + 'tours.delete.bulkConfirmBody': + 'Cette sélection comprend au moins une randonnée. Tous les lieux sélectionnés seront supprimés définitivement et ne pourront pas être restaurés. Les itinéraires, points modifiables et affectations aux jours des randonnées seront également supprimés.', + 'tours.delete.confirmAction': 'Supprimer définitivement', + 'tours.addedToDay': 'Ajoutée au jour {n}', + 'tours.dayRoute.endpointUnknown': + "Le point d'arrivée de la sortie n'a pas pu être déterminé. Les liaisons suivantes peuvent être incomplètes.", + 'tours.detail.trackColor': 'Couleur de la trace', + 'tours.detail.distance': 'Distance', + 'tours.detail.minAltitude': 'Altitude minimale', + 'tours.detail.maxAltitude': 'Altitude maximale', + 'tours.detail.ascent': 'Dénivelé positif', + 'tours.detail.descent': 'Dénivelé négatif', + 'tours.detail.difficulty': 'Difficulté', + 'tours.detail.source': 'Source', + 'tours.detail.gpxReadOnly': + 'Les sorties GPX sont en lecture seule et ne peuvent pas encore être modifiées dans le planificateur.', + 'tours.detail.navigationHint': + "La navigation ouvre le point de départ du sentier ; elle ne vous guide pas le long de l'itinéraire.", + 'tours.planner.title': 'Planifier une sortie', + 'tours.planner.newTour': 'Planifier une nouvelle sortie', + 'tours.planner.neutralTitle': 'Planifier une sortie', + 'tours.planner.neutralBody': 'Commencez une nouvelle randonnée et cliquez sur la carte pour placer ses points.', + 'tours.planner.newTitle': 'Nouvelle sortie', + 'tours.planner.editTitle': 'Modifier la sortie', + 'tours.planner.gpxTitle': 'Sortie GPX', + 'tours.planner.backToTours': 'Retour aux sorties', + 'tours.planner.restored': 'Brouillon non enregistré restauré', + 'tours.planner.discardChanges': 'Abandonner les modifications', + 'tours.planner.discardUnsaved': 'Abandonner les modifications non enregistrées', + 'tours.planner.saveChanges': 'Enregistrer les modifications', + 'tours.planner.newTourConfirmTitle': 'Commencer une nouvelle sortie ?', + 'tours.planner.newTourConfirmBody': + 'Les modifications non enregistrées de cette sortie seront perdues. Les sorties enregistrées resteront inchangées.', + 'tours.planner.newTourConfirm': 'Abandonner et commencer une nouvelle sortie', + 'tours.planner.startingPoint': 'Cliquez sur la carte pour ajouter votre point de départ.', + 'tours.planner.firstUseDetails': + "Ajoutez un autre point pour calculer un itinéraire. L'itinéraire et l'altitude se mettent à jour automatiquement ; enregistrez quand vous avez terminé.", + 'tours.planner.collapseElevation': 'Réduire le profil altimétrique', + 'tours.planner.expandElevation': 'Déployer le profil altimétrique', + 'tours.planner.name': 'Nom de la sortie', + 'tours.planner.namePlaceholder': 'Randonnée sans titre', + 'tours.planner.mapHint': 'Cliquez sur la carte pour ajouter des points.', + 'tours.planner.waypoints': 'Points de passage', + 'tours.planner.waypointLabel': 'Point de passage {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Ajoutez au moins deux points pour calculer un itinéraire à pied.', + 'tours.planner.start': 'Départ', + 'tours.planner.via': 'Passage par', + 'tours.planner.end': 'Arrivée', + 'tours.planner.moveUp': 'Monter le point de passage', + 'tours.planner.moveDown': 'Descendre le point de passage', + 'tours.planner.remove': 'Supprimer le point de passage', + 'tours.planner.undo': 'Annuler la modification du point de passage', + 'tours.planner.redo': 'Rétablir la modification du point de passage', + 'tours.planner.discard': 'Abandonner le brouillon', + 'tours.planner.discardTitle': 'Abandonner les modifications non enregistrées de la sortie ?', + 'tours.planner.discardBody': + 'Les modifications non enregistrées seront perdues. La sortie enregistrée, ses points de passage, ses fichiers et ses affectations resteront inchangés.', + 'tours.planner.save': 'Enregistrer la sortie', + 'tours.planner.saved': 'Sortie enregistrée', + 'tours.planner.planAnother': 'En planifier une autre', + 'tours.planner.assignToDay': 'Affecter à une journée', + 'tours.planner.tripTours': 'Sorties de ce voyage', + 'tours.planner.safetyNote': "Le calcul d'itinéraire aide à planifier, mais ne garantit pas la sécurité.", + 'tours.planner.safetyNoteDetails': + 'Les indications de difficulté dans OSM peuvent être absentes ou erronées. La météo, les fermetures et les conditions actuelles ne sont pas évaluées.', + 'tours.planner.maxDifficulty': 'Difficulté maximale du sentier', + 'tours.planner.difficulty.t1': 'T1 — Randonnée', + 'tours.planner.difficulty.t2': 'T2 — Randonnée en montagne', + 'tours.planner.difficulty.t3': 'T3 — Randonnée en montagne exigeante', + 'tours.planner.difficulty.t4': 'T4 — Randonnée alpine', + 'tours.planner.difficulty.t5': 'T5 — Randonnée alpine exigeante', + 'tours.planner.difficulty.t6': 'T6 — Randonnée alpine difficile', + 'tours.planner.difficulty.t3Warning': + 'Les itinéraires T3 peuvent comporter des sentiers de montagne exposés ou techniquement exigeants, nécessitant un pied sûr et une bonne tolérance au vide.', + 'tours.planner.difficulty.alpineTitle': "Activer le calcul d'itinéraires alpins ?", + 'tours.planner.difficulty.alpineBody': + "Le calcul d'itinéraires alpins est activé. Les itinéraires peuvent traverser des terrains exposés, non balisés ou techniquement difficiles. Les données de difficulté dans OSM peuvent être absentes ou erronées.", + 'tours.planner.difficulty.enable': 'Activer les itinéraires alpins', + 'tours.planner.openFailed': "Impossible d'ouvrir la sortie enregistrée pour la modifier.", + 'tours.planner.status.dirty': 'En attente du calcul', + 'tours.planner.status.routing': "Calcul de l'itinéraire à pied…", + 'tours.planner.status.routingFailed': + "Le calcul de l'itinéraire a échoué. Le dernier itinéraire valide reste affiché.", + 'tours.planner.status.elevation': "Chargement de l'altitude…", + 'tours.planner.status.elevationFailed': "L'altitude est indisponible. L'itinéraire calculé reste affiché.", + 'tours.planner.status.ready': 'Itinéraire et altitude prêts', + 'tours.planner.status.saving': 'Enregistrement…', + 'tours.planner.status.saveFailed': "La sortie n'a pas pu être enregistrée. Votre brouillon est toujours disponible.", + 'tours.planner.retry': 'Réessayer', + 'tours.planner.inspector.title': 'Résumé de la sortie', + 'tours.planner.inspector.empty': + "Les détails de l'itinéraire apparaissent ici une fois deux points de passage reliés.", + 'tours.planner.inspector.distance': 'Distance', + 'tours.planner.inspector.duration': 'Temps de marche', + 'tours.planner.inspector.elevation': 'Profil altimétrique', + 'tours.planner.inspector.profileFocus': 'À {distance} : {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + "Le profil s'affiche lorsque les données d'altitude de l'itinéraire sont disponibles.", + 'tours.planner.gpxNoGeometry': "La géométrie de l'itinéraire est indisponible pour cet import GPX.", + 'tours.planner.mobileHint': + 'La planification des sorties est disponible sur tablette et ordinateur. Vous pouvez consulter les sorties enregistrées et les affecter à une journée ici.', + 'tourTypes.hike': 'Randonnée', + 'tourTypes.bike': 'Sortie à vélo', + 'tourTypes.run': 'Course à pied', + 'tourTypes.ski': 'Ski de randonnée', + 'tourTypes.kayak': 'Sortie en kayak', + 'tourTypes.walk': 'Balade urbaine / touristique', + 'tourTypes.food': 'Circuit gastronomique / œnologique', +}; +export default tours; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts new file mode 100644 index 0000000000..c9ca4e7afa --- /dev/null +++ b/shared/src/i18n/gr/tours.ts @@ -0,0 +1,139 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Εκδρομές', + 'tours.mode.places': 'Μέρη', + 'tours.mode.tours': 'Εκδρομές', + 'tours.mode.label': 'Προβολή προσθήκης', + 'tours.title': 'Εκδρομές', + 'tours.subtitle': 'Πεζοπορίες και άλλες ημερήσιες εκδρομές σε αυτό το ταξίδι', + 'tours.empty.title': 'Δεν υπάρχουν εκδρομές ακόμη', + 'tours.empty.body': 'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX για να ξεκινήσετε.', + 'tours.addToDay': 'Προσθήκη σε ημέρα', + 'tours.addToDay.comingSoon': 'Αντιστοιχίστε εκδρομές σε ημέρες του ταξιδιού από τη λίστα εκδρομών.', + 'tours.addToDay.pickDay': 'Επιλέξτε ημέρα', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Εισαγωγή GPX', + 'tours.badge.planned': 'σχεδιάστηκε στο TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'χρήση με προσοχή', + 'tours.caution.tooltip': 'Ελλιπή υψομετρικά δεδομένα ή μετρήσεις – εισαγωγή με επιφύλαξη', + 'tours.import.caution': 'Εισαγωγή με επιφύλαξη – ελλιπή υψομετρικά δεδομένα ή μετρήσεις', + 'tours.import.success': 'Αριθμός εκδρομών που εισήχθησαν από GPX: {count}', + 'tours.import.successOne': 'Εισήχθη 1 εκδρομή από GPX', + 'tours.import.noTrack': 'Δεν βρέθηκε ίχνος ή διαδρομή σε αυτό το αρχείο GPX', + 'tours.import.error': 'Δεν ήταν δυνατή η εισαγωγή του αρχείου GPX', + 'tours.delete.confirmBody': + 'Να διαγραφεί οριστικά αυτή η διαδρομή; Η πορεία, τα επεξεργάσιμα σημεία και οι αναθέσεις σε ημέρες θα αφαιρεθούν. Αυτή η ενέργεια δεν αναιρείται.', + 'tours.delete.bulkConfirmBody': + 'Η επιλογή περιλαμβάνει τουλάχιστον μία διαδρομή. Όλα τα επιλεγμένα μέρη θα διαγραφούν οριστικά και δεν θα μπορούν να αποκατασταθούν. Θα αφαιρεθούν επίσης οι πορείες, τα επεξεργάσιμα σημεία και οι αναθέσεις των διαδρομών σε ημέρες.', + 'tours.delete.confirmAction': 'Οριστική διαγραφή', + 'tours.addedToDay': 'Προστέθηκε στην ημέρα {n}', + 'tours.dayRoute.endpointUnknown': + 'Δεν ήταν δυνατός ο προσδιορισμός του τελικού σημείου της εκδρομής. Τα επόμενα συνδετικά τμήματα ενδέχεται να είναι ελλιπή.', + 'tours.detail.trackColor': 'Χρώμα ίχνους', + 'tours.detail.distance': 'Απόσταση', + 'tours.detail.minAltitude': 'Ελάχιστο υψόμετρο', + 'tours.detail.maxAltitude': 'Μέγιστο υψόμετρο', + 'tours.detail.ascent': 'Υψομετρική άνοδος', + 'tours.detail.descent': 'Υψομετρική κάθοδος', + 'tours.detail.difficulty': 'Δυσκολία', + 'tours.detail.source': 'Πηγή', + 'tours.detail.gpxReadOnly': + 'Οι εκδρομές GPX είναι μόνο για προβολή και δεν μπορούν ακόμη να τροποποιηθούν στον σχεδιαστή.', + 'tours.detail.navigationHint': + 'Η πλοήγηση ανοίγει την αφετηρία του μονοπατιού· δεν σας καθοδηγεί κατά μήκος της διαδρομής.', + 'tours.planner.title': 'Σχεδιασμός εκδρομής', + 'tours.planner.newTour': 'Σχεδιασμός νέας εκδρομής', + 'tours.planner.neutralTitle': 'Σχεδιασμός εκδρομής', + 'tours.planner.neutralBody': 'Ξεκινήστε μια νέα διαδρομή και κάντε κλικ στον χάρτη για να ορίσετε τα σημεία της.', + 'tours.planner.newTitle': 'Νέα εκδρομή', + 'tours.planner.editTitle': 'Επεξεργασία εκδρομής', + 'tours.planner.gpxTitle': 'Εκδρομή GPX', + 'tours.planner.backToTours': 'Πίσω στις εκδρομές', + 'tours.planner.restored': 'Το μη αποθηκευμένο πρόχειρο επαναφέρθηκε', + 'tours.planner.discardChanges': 'Απόρριψη αλλαγών', + 'tours.planner.discardUnsaved': 'Απόρριψη μη αποθηκευμένων αλλαγών', + 'tours.planner.saveChanges': 'Αποθήκευση αλλαγών', + 'tours.planner.newTourConfirmTitle': 'Έναρξη νέας εκδρομής;', + 'tours.planner.newTourConfirmBody': + 'Οι μη αποθηκευμένες αλλαγές αυτής της εκδρομής θα απορριφθούν. Οι αποθηκευμένες εκδρομές δεν θα αλλάξουν.', + 'tours.planner.newTourConfirm': 'Απόρριψη αλλαγών και έναρξη νέας εκδρομής', + 'tours.planner.startingPoint': 'Κάντε κλικ στον χάρτη για να προσθέσετε την αφετηρία.', + 'tours.planner.firstUseDetails': + 'Προσθέστε άλλο ένα σημείο για να υπολογιστεί η διαδρομή. Η διαδρομή και τα υψομετρικά δεδομένα ενημερώνονται αυτόματα· αποθηκεύστε όταν είναι έτοιμα.', + 'tours.planner.collapseElevation': 'Σύμπτυξη υψομετρικού προφίλ', + 'tours.planner.expandElevation': 'Ανάπτυξη υψομετρικού προφίλ', + 'tours.planner.name': 'Όνομα εκδρομής', + 'tours.planner.namePlaceholder': 'Πεζοπορία χωρίς όνομα', + 'tours.planner.mapHint': 'Κάντε κλικ στον χάρτη για να προσθέσετε σημεία.', + 'tours.planner.waypoints': 'Σημεία διέλευσης', + 'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}', + 'tours.durationMinutes': '{count} λεπτά', + 'tours.planner.empty': 'Προσθέστε τουλάχιστον δύο σημεία για να υπολογιστεί μια διαδρομή πεζοπορίας.', + 'tours.planner.start': 'Αφετηρία', + 'tours.planner.via': 'Μέσω', + 'tours.planner.end': 'Τερματισμός', + 'tours.planner.moveUp': 'Μετακίνηση σημείου πάνω', + 'tours.planner.moveDown': 'Μετακίνηση σημείου κάτω', + 'tours.planner.remove': 'Αφαίρεση σημείου', + 'tours.planner.undo': 'Αναίρεση αλλαγής σημείου', + 'tours.planner.redo': 'Επανάληψη αλλαγής σημείου', + 'tours.planner.discard': 'Απόρριψη πρόχειρου', + 'tours.planner.discardTitle': 'Απόρριψη μη αποθηκευμένων αλλαγών εκδρομής;', + 'tours.planner.discardBody': + 'Οι μη αποθηκευμένες αλλαγές θα απορριφθούν. Η αποθηκευμένη εκδρομή, τα σημεία, τα αρχεία και οι αντιστοιχίσεις της δεν θα αλλάξουν.', + 'tours.planner.save': 'Αποθήκευση εκδρομής', + 'tours.planner.saved': 'Η εκδρομή αποθηκεύτηκε', + 'tours.planner.planAnother': 'Σχεδιασμός άλλης', + 'tours.planner.assignToDay': 'Αντιστοίχιση σε ημέρα', + 'tours.planner.tripTours': 'Εκδρομές αυτού του ταξιδιού', + 'tours.planner.safetyNote': 'Ο υπολογισμός διαδρομών βοηθά στον σχεδιασμό, αλλά δεν εγγυάται την ασφάλεια.', + 'tours.planner.safetyNoteDetails': + 'Οι ετικέτες δυσκολίας του OSM ενδέχεται να λείπουν ή να είναι λανθασμένες. Ο καιρός, οι κλειστές διαδρομές και οι τρέχουσες συνθήκες δεν αξιολογούνται.', + 'tours.planner.maxDifficulty': 'Μέγιστη δυσκολία μονοπατιού', + 'tours.planner.difficulty.t1': 'T1 – Πεζοπορία', + 'tours.planner.difficulty.t2': 'T2 – Ορεινή πεζοπορία', + 'tours.planner.difficulty.t3': 'T3 – Απαιτητική ορεινή πεζοπορία', + 'tours.planner.difficulty.t4': 'T4 – Αλπική πεζοπορία', + 'tours.planner.difficulty.t5': 'T5 – Απαιτητική αλπική πεζοπορία', + 'tours.planner.difficulty.t6': 'T6 – Δύσκολη αλπική πεζοπορία', + 'tours.planner.difficulty.t3Warning': + 'Οι διαδρομές T3 ενδέχεται να περιλαμβάνουν εκτεθειμένα ή τεχνικά απαιτητικά ορεινά μονοπάτια που χρειάζονται σταθερό πάτημα και εξοικείωση με τα ύψη χωρίς φόβο.', + 'tours.planner.difficulty.alpineTitle': 'Ενεργοποίηση αλπικών διαδρομών;', + 'tours.planner.difficulty.alpineBody': + 'Οι αλπικές διαδρομές ενεργοποιήθηκαν. Μπορεί να διασχίζουν εκτεθειμένο, μη σηματοδοτημένο ή τεχνικά δύσκολο έδαφος. Τα δεδομένα δυσκολίας του OSM ενδέχεται να λείπουν ή να είναι λανθασμένα.', + 'tours.planner.difficulty.enable': 'Ενεργοποίηση αλπικών διαδρομών', + 'tours.planner.openFailed': 'Δεν ήταν δυνατό το άνοιγμα της αποθηκευμένης εκδρομής για επεξεργασία.', + 'tours.planner.status.dirty': 'Αναμονή υπολογισμού', + 'tours.planner.status.routing': 'Υπολογισμός διαδρομής πεζοπορίας…', + 'tours.planner.status.routingFailed': + 'Ο υπολογισμός διαδρομής απέτυχε. Εμφανίζεται ακόμη η τελευταία έγκυρη διαδρομή.', + 'tours.planner.status.elevation': 'Φόρτωση υψομετρικών δεδομένων…', + 'tours.planner.status.elevationFailed': + 'Τα υψομετρικά δεδομένα δεν είναι διαθέσιμα. Η υπολογισμένη διαδρομή εξακολουθεί να εμφανίζεται.', + 'tours.planner.status.ready': 'Η διαδρομή και τα υψομετρικά δεδομένα είναι έτοιμα', + 'tours.planner.status.saving': 'Αποθήκευση…', + 'tours.planner.status.saveFailed': 'Δεν ήταν δυνατή η αποθήκευση της εκδρομής. Το πρόχειρό σας παραμένει διαθέσιμο.', + 'tours.planner.retry': 'Επανάληψη', + 'tours.planner.inspector.title': 'Σύνοψη εκδρομής', + 'tours.planner.inspector.empty': + 'Οι λεπτομέρειες της διαδρομής εμφανίζονται εδώ μόλις συνδεθούν δύο σημεία διέλευσης.', + 'tours.planner.inspector.distance': 'Απόσταση', + 'tours.planner.inspector.duration': 'Χρόνος πεζοπορίας', + 'tours.planner.inspector.elevation': 'Υψομετρικό προφίλ', + 'tours.planner.inspector.profileFocus': 'Στην απόσταση {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'Το προφίλ εμφανίζεται όταν είναι διαθέσιμα τα υψομετρικά δεδομένα της διαδρομής.', + 'tours.planner.gpxNoGeometry': 'Η γεωμετρία της διαδρομής δεν είναι διαθέσιμη για αυτήν την εισαγωγή GPX.', + 'tours.planner.mobileHint': + 'Ο σχεδιασμός εκδρομών είναι διαθέσιμος σε τάμπλετ και υπολογιστή. Εδώ μπορείτε να βλέπετε τις αποθηκευμένες εκδρομές και να τις αντιστοιχίζετε σε ημέρες.', + 'tourTypes.hike': 'Πεζοπορία', + 'tourTypes.bike': 'Ποδηλατική εκδρομή', + 'tourTypes.run': 'Τρέξιμο', + 'tourTypes.ski': 'Ορειβατικό σκι', + 'tourTypes.kayak': 'Καγιάκ', + 'tourTypes.walk': 'Περίπατος στην πόλη / στα αξιοθέατα', + 'tourTypes.food': 'Γαστρονομική / οινική εκδρομή', +}; +export default tours; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 0afbf275bc..7417e463dc 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts new file mode 100644 index 0000000000..3d9f544949 --- /dev/null +++ b/shared/src/i18n/hu/tours.ts @@ -0,0 +1,135 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Túrák', + 'tours.mode.places': 'Helyek', + 'tours.mode.tours': 'Túrák', + 'tours.mode.label': 'Hozzáadási nézet', + 'tours.title': 'Túrák', + 'tours.subtitle': 'Gyalogtúrák és más egynapos túrák ezen az utazáson', + 'tours.empty.title': 'Még nincsenek túrák', + 'tours.empty.body': 'A kezdéshez tervezz egy túrát, vagy importálj egy GPX-fájlt.', + 'tours.addToDay': 'Hozzáadás egy naphoz', + 'tours.addToDay.comingSoon': 'A túralistából rendelj túrákat az utazás valamelyik napjához.', + 'tours.addToDay.pickDay': 'Válassz napot', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX-import', + 'tours.badge.planned': 'TREK-ben tervezve', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'óvatosan használd', + 'tours.caution.tooltip': 'Hiányos magassági adatok vagy mutatók – fenntartással importálva', + 'tours.import.caution': 'Fenntartással importálva – hiányos magassági adatok vagy mutatók', + 'tours.import.success': 'GPX-ből importált túrák száma: {count}', + 'tours.import.successOne': '1 túra importálva GPX-ből', + 'tours.import.noTrack': 'Ebben a GPX-fájlban nem található nyomvonal vagy útvonal', + 'tours.import.error': 'Nem sikerült importálni a GPX-fájlt', + 'tours.delete.confirmBody': + 'Végleg törli ezt a túrát? Az útvonala, szerkeszthető útvonalpontjai és a napokhoz rendelései is törlődnek. A művelet nem vonható vissza.', + 'tours.delete.bulkConfirmBody': + 'A kijelölés legalább egy túrát tartalmaz. Minden kijelölt hely végleg törlődik, és nem állítható vissza. A túrák útvonalai, szerkeszthető pontjai és napokhoz rendelései is törlődnek.', + 'tours.delete.confirmAction': 'Végleges törlés', + 'tours.addedToDay': 'Hozzáadva a(z) {n}. naphoz', + 'tours.dayRoute.endpointUnknown': + 'A túra végpontja nem határozható meg. Az ezt követő összekötő szakaszok hiányosak lehetnek.', + 'tours.detail.trackColor': 'Nyomvonal színe', + 'tours.detail.distance': 'Távolság', + 'tours.detail.minAltitude': 'Legkisebb tengerszint feletti magasság', + 'tours.detail.maxAltitude': 'Legnagyobb tengerszint feletti magasság', + 'tours.detail.ascent': 'Szintemelkedés', + 'tours.detail.descent': 'Szintcsökkenés', + 'tours.detail.difficulty': 'Nehézség', + 'tours.detail.source': 'Forrás', + 'tours.detail.gpxReadOnly': 'A GPX-túrák csak megtekinthetők, a tervezőben még nem szerkeszthetők.', + 'tours.detail.navigationHint': 'A navigáció a túra kiindulópontját nyitja meg; nem vezet végig az útvonalon.', + 'tours.planner.title': 'Túra tervezése', + 'tours.planner.newTour': 'Új túra tervezése', + 'tours.planner.neutralTitle': 'Túra tervezése', + 'tours.planner.neutralBody': 'Indíts új túrát, és kattints a térképre a pontjai elhelyezéséhez.', + 'tours.planner.newTitle': 'Új túra', + 'tours.planner.editTitle': 'Túra szerkesztése', + 'tours.planner.gpxTitle': 'GPX-túra', + 'tours.planner.backToTours': 'Vissza a túrákhoz', + 'tours.planner.restored': 'Nem mentett vázlat visszaállítva', + 'tours.planner.discardChanges': 'Módosítások elvetése', + 'tours.planner.discardUnsaved': 'Nem mentett módosítások elvetése', + 'tours.planner.saveChanges': 'Módosítások mentése', + 'tours.planner.newTourConfirmTitle': 'Új túrát kezdesz?', + 'tours.planner.newTourConfirmBody': 'A túra nem mentett módosításai elvesznek. A mentett túrák nem változnak.', + 'tours.planner.newTourConfirm': 'Módosítások elvetése és új túra kezdése', + 'tours.planner.startingPoint': 'Kattints a térképre a kiindulópont hozzáadásához.', + 'tours.planner.firstUseDetails': + 'Adj hozzá még egy pontot az útvonal kiszámításához. Az útvonal és a magassági adatok automatikusan frissülnek; mentsd a túrát, ha elkészült.', + 'tours.planner.collapseElevation': 'Magassági profil összecsukása', + 'tours.planner.expandElevation': 'Magassági profil kibontása', + 'tours.planner.name': 'Túra neve', + 'tours.planner.namePlaceholder': 'Névtelen gyalogtúra', + 'tours.planner.mapHint': 'Kattints a térképre pontok hozzáadásához.', + 'tours.planner.waypoints': 'Útvonalpontok', + 'tours.planner.waypointLabel': '{n}. útvonalpont', + 'tours.durationMinutes': '{count} perc', + 'tours.planner.empty': 'Adj hozzá legalább két pontot egy gyalogos útvonal kiszámításához.', + 'tours.planner.start': 'Kezdőpont', + 'tours.planner.via': 'Köztes pont', + 'tours.planner.end': 'Végpont', + 'tours.planner.moveUp': 'Útvonalpont feljebb', + 'tours.planner.moveDown': 'Útvonalpont lejjebb', + 'tours.planner.remove': 'Útvonalpont törlése', + 'tours.planner.undo': 'Pontmódosítás visszavonása', + 'tours.planner.redo': 'Pontmódosítás ismétlése', + 'tours.planner.discard': 'Vázlat elvetése', + 'tours.planner.discardTitle': 'Elveted a túra nem mentett módosításait?', + 'tours.planner.discardBody': + 'A nem mentett módosítások elvesznek. A mentett túra, útvonalpontjai, fájljai és napokhoz rendelései nem változnak.', + 'tours.planner.save': 'Túra mentése', + 'tours.planner.saved': 'Túra mentve', + 'tours.planner.planAnother': 'Másik túra tervezése', + 'tours.planner.assignToDay': 'Naphoz rendelés', + 'tours.planner.tripTours': 'Az utazás túrái', + 'tours.planner.safetyNote': 'Az útvonaltervezés tervezési segítség, nem biztonsági garancia.', + 'tours.planner.safetyNoteDetails': + 'Az OSM nehézségi címkéi hiányozhatnak vagy hibásak lehetnek. Az időjárást, lezárásokat és aktuális körülményeket a rendszer nem értékeli.', + 'tours.planner.maxDifficulty': 'Ösvény legnagyobb nehézsége', + 'tours.planner.difficulty.t1': 'T1 – Gyalogtúra', + 'tours.planner.difficulty.t2': 'T2 – Hegyi túra', + 'tours.planner.difficulty.t3': 'T3 – Megerőltető hegyi túra', + 'tours.planner.difficulty.t4': 'T4 – Magashegyi túra', + 'tours.planner.difficulty.t5': 'T5 – Megerőltető magashegyi túra', + 'tours.planner.difficulty.t6': 'T6 – Nehéz magashegyi túra', + 'tours.planner.difficulty.t3Warning': + 'A T3 útvonalak kitett vagy technikailag nehéz hegyi ösvényeket is tartalmazhatnak, amelyek magasban is biztos lépést és szédülésmentességet igényelnek.', + 'tours.planner.difficulty.alpineTitle': 'Engedélyezed a magashegyi útvonaltervezést?', + 'tours.planner.difficulty.alpineBody': + 'A magashegyi útvonaltervezés engedélyezve. Az útvonalak kitett, jelöletlen vagy technikailag nehéz terepen vezethetnek. Az OSM nehézségi adatai hiányozhatnak vagy hibásak lehetnek.', + 'tours.planner.difficulty.enable': 'Magashegyi tervezés engedélyezése', + 'tours.planner.openFailed': 'A mentett túrát nem sikerült megnyitni szerkesztésre.', + 'tours.planner.status.dirty': 'Számításra vár', + 'tours.planner.status.routing': 'Gyalogos útvonal kiszámítása…', + 'tours.planner.status.routingFailed': + 'Az útvonalszámítás sikertelen. Továbbra is az utolsó sikeresen kiszámított útvonal látható.', + 'tours.planner.status.elevation': 'Magassági adatok betöltése…', + 'tours.planner.status.elevationFailed': + 'A magassági adatok nem érhetők el. A kiszámított útvonal továbbra is látható.', + 'tours.planner.status.ready': 'Az útvonal és a magassági adatok elkészültek', + 'tours.planner.status.saving': 'Mentés…', + 'tours.planner.status.saveFailed': 'A túrát nem sikerült menteni. A vázlat megmaradt.', + 'tours.planner.retry': 'Újra', + 'tours.planner.inspector.title': 'Túra összefoglalója', + 'tours.planner.inspector.empty': 'Az útvonal részletei két útvonalpont összekötése után jelennek meg itt.', + 'tours.planner.inspector.distance': 'Távolság', + 'tours.planner.inspector.duration': 'Gyaloglási idő', + 'tours.planner.inspector.elevation': 'Magassági profil', + 'tours.planner.inspector.profileFocus': '{distance} távolságnál: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'A profil akkor jelenik meg, ha rendelkezésre állnak az útvonal magassági adatai.', + 'tours.planner.gpxNoGeometry': 'Ehhez a GPX-importhoz nem érhető el útvonalgeometria.', + 'tours.planner.mobileHint': + 'A túratervezés táblagépen és számítógépen érhető el. Itt megtekintheted és napokhoz rendelheted a mentett túrákat.', + 'tourTypes.hike': 'Gyalogtúra', + 'tourTypes.bike': 'Kerékpártúra', + 'tourTypes.run': 'Futás', + 'tourTypes.ski': 'Sítúra', + 'tourTypes.kayak': 'Kajakozás', + 'tourTypes.walk': 'Városi / városnéző séta', + 'tourTypes.food': 'Gasztro- / bortúra', +}; +export default tours; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts new file mode 100644 index 0000000000..e9f1add6bd --- /dev/null +++ b/shared/src/i18n/id/tours.ts @@ -0,0 +1,134 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Tur', + 'tours.mode.places': 'Tempat', + 'tours.mode.tours': 'Tur', + 'tours.mode.label': 'Tampilan penambahan', + 'tours.title': 'Tur', + 'tours.subtitle': 'Pendakian dan tur sehari lainnya untuk perjalanan ini', + 'tours.empty.title': 'Belum ada tur', + 'tours.empty.body': 'Rencanakan tur atau impor berkas GPX untuk memulai.', + 'tours.addToDay': 'Tambahkan ke hari', + 'tours.addToDay.comingSoon': 'Tetapkan tur ke hari perjalanan dari daftar tur.', + 'tours.addToDay.pickDay': 'Pilih hari', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Impor GPX', + 'tours.badge.planned': 'direncanakan di TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'gunakan dengan hati-hati', + 'tours.caution.tooltip': 'Data elevasi atau metrik belum lengkap; diimpor dengan peringatan', + 'tours.import.caution': 'Diimpor dengan peringatan; data elevasi atau metrik belum lengkap', + 'tours.import.success': '{count} tur diimpor dari GPX', + 'tours.import.successOne': '1 tur diimpor dari GPX', + 'tours.import.noTrack': 'Tidak ditemukan jejak atau rute dalam berkas GPX tersebut', + 'tours.import.error': 'Berkas GPX tidak dapat diimpor', + 'tours.delete.confirmBody': + 'Hapus tur ini secara permanen? Rute, titik arah yang dapat diedit, dan penetapan harinya akan dihapus. Tindakan ini tidak dapat dibatalkan.', + 'tours.delete.bulkConfirmBody': + 'Pilihan ini mencakup setidaknya satu tur. Semua tempat yang dipilih akan dihapus secara permanen dan tidak dapat dipulihkan. Rute, titik arah yang dapat diedit, dan penetapan hari untuk tur juga akan dihapus.', + 'tours.delete.confirmAction': 'Hapus permanen', + 'tours.addedToDay': 'Ditambahkan ke hari ke-{n}', + 'tours.dayRoute.endpointUnknown': + 'Titik akhir tur tidak dapat ditentukan. Sambungan rute berikutnya mungkin tidak lengkap.', + 'tours.detail.trackColor': 'Warna jejak', + 'tours.detail.distance': 'Jarak', + 'tours.detail.minAltitude': 'Ketinggian minimum', + 'tours.detail.maxAltitude': 'Ketinggian maksimum', + 'tours.detail.ascent': 'Total kenaikan elevasi', + 'tours.detail.descent': 'Total penurunan elevasi', + 'tours.detail.difficulty': 'Tingkat kesulitan', + 'tours.detail.source': 'Sumber', + 'tours.detail.gpxReadOnly': 'Tur GPX hanya dapat dilihat dan belum dapat diedit di perencana.', + 'tours.detail.navigationHint': 'Navigasi membuka titik awal jalur, bukan memberikan panduan sepanjang rute.', + 'tours.planner.title': 'Rencanakan tur', + 'tours.planner.newTour': 'Rencanakan tur baru', + 'tours.planner.neutralTitle': 'Rencanakan tur', + 'tours.planner.neutralBody': 'Mulai tur baru dan klik peta untuk menempatkan titik-titiknya.', + 'tours.planner.newTitle': 'Tur baru', + 'tours.planner.editTitle': 'Edit tur', + 'tours.planner.gpxTitle': 'Tur GPX', + 'tours.planner.backToTours': 'Kembali ke daftar tur', + 'tours.planner.restored': 'Draf yang belum disimpan telah dipulihkan', + 'tours.planner.discardChanges': 'Buang perubahan', + 'tours.planner.discardUnsaved': 'Buang perubahan yang belum disimpan', + 'tours.planner.saveChanges': 'Simpan perubahan', + 'tours.planner.newTourConfirmTitle': 'Mulai tur baru?', + 'tours.planner.newTourConfirmBody': + 'Perubahan yang belum disimpan pada tur ini akan dibuang. Tur yang sudah disimpan tidak akan diubah.', + 'tours.planner.newTourConfirm': 'Buang perubahan dan mulai tur baru', + 'tours.planner.startingPoint': 'Klik peta untuk menambahkan titik awal.', + 'tours.planner.firstUseDetails': + 'Tambahkan satu titik lagi untuk menghitung rute. Rute dan elevasi diperbarui otomatis; simpan setelah selesai.', + 'tours.planner.collapseElevation': 'Ciutkan profil elevasi', + 'tours.planner.expandElevation': 'Perluas profil elevasi', + 'tours.planner.name': 'Nama tur', + 'tours.planner.namePlaceholder': 'Pendakian tanpa judul', + 'tours.planner.mapHint': 'Klik peta untuk menambahkan titik.', + 'tours.planner.waypoints': 'Titik lintasan', + 'tours.planner.waypointLabel': 'Titik lintasan {n}', + 'tours.durationMinutes': '{count} menit', + 'tours.planner.empty': 'Tambahkan setidaknya dua titik untuk menghitung rute berjalan kaki.', + 'tours.planner.start': 'Awal', + 'tours.planner.via': 'Melalui', + 'tours.planner.end': 'Akhir', + 'tours.planner.moveUp': 'Pindahkan titik lintasan ke atas', + 'tours.planner.moveDown': 'Pindahkan titik lintasan ke bawah', + 'tours.planner.remove': 'Hapus titik lintasan', + 'tours.planner.undo': 'Urungkan perubahan titik lintasan', + 'tours.planner.redo': 'Ulangi perubahan titik lintasan', + 'tours.planner.discard': 'Buang draf', + 'tours.planner.discardTitle': 'Buang perubahan tur yang belum disimpan?', + 'tours.planner.discardBody': + 'Edit yang belum disimpan akan dibuang. Tur tersimpan beserta titik lintasan, berkas, dan penetapan harinya tidak akan diubah.', + 'tours.planner.save': 'Simpan tur', + 'tours.planner.saved': 'Tur disimpan', + 'tours.planner.planAnother': 'Rencanakan tur lain', + 'tours.planner.assignToDay': 'Tetapkan ke hari', + 'tours.planner.tripTours': 'Tur dalam perjalanan ini', + 'tours.planner.safetyNote': 'Perhitungan rute hanya membantu perencanaan, bukan jaminan keselamatan.', + 'tours.planner.safetyNoteDetails': + 'Tag tingkat kesulitan OSM mungkin tidak tersedia atau keliru. Cuaca, penutupan jalur, dan kondisi terkini tidak dievaluasi.', + 'tours.planner.maxDifficulty': 'Tingkat kesulitan jalur maksimum', + 'tours.planner.difficulty.t1': 'T1 - Pendakian', + 'tours.planner.difficulty.t2': 'T2 - Pendakian gunung', + 'tours.planner.difficulty.t3': 'T3 - Pendakian gunung menantang', + 'tours.planner.difficulty.t4': 'T4 - Pendakian alpin', + 'tours.planner.difficulty.t5': 'T5 - Pendakian alpin menantang', + 'tours.planner.difficulty.t6': 'T6 - Pendakian alpin sulit', + 'tours.planner.difficulty.t3Warning': + 'Rute T3 dapat mencakup jalur gunung terbuka dengan risiko jatuh atau jalur yang menuntut keterampilan teknis. Diperlukan pijakan yang mantap dan kemampuan menghadapi ketinggian tanpa rasa takut.', + 'tours.planner.difficulty.alpineTitle': 'Aktifkan perhitungan rute alpin?', + 'tours.planner.difficulty.alpineBody': + 'Perhitungan rute alpin diaktifkan. Rute dapat melintasi medan terbuka dengan risiko jatuh, tanpa penanda, atau sulit secara teknis. Data tingkat kesulitan OSM mungkin tidak tersedia atau keliru.', + 'tours.planner.difficulty.enable': 'Aktifkan perhitungan rute alpin', + 'tours.planner.openFailed': 'Tur tersimpan tidak dapat dibuka untuk diedit.', + 'tours.planner.status.dirty': 'Menunggu perhitungan', + 'tours.planner.status.routing': 'Menghitung rute berjalan kaki...', + 'tours.planner.status.routingFailed': + 'Perhitungan rute gagal. Rute terakhir yang berhasil dihitung tetap ditampilkan.', + 'tours.planner.status.elevation': 'Memuat elevasi...', + 'tours.planner.status.elevationFailed': 'Elevasi tidak tersedia. Rute yang telah dihitung tetap ditampilkan.', + 'tours.planner.status.ready': 'Rute dan elevasi siap', + 'tours.planner.status.saving': 'Menyimpan...', + 'tours.planner.status.saveFailed': 'Tur tidak dapat disimpan. Draf Anda tetap tersedia.', + 'tours.planner.retry': 'Coba lagi', + 'tours.planner.inspector.title': 'Ringkasan tur', + 'tours.planner.inspector.empty': 'Detail rute muncul di sini setelah dua titik lintasan terhubung.', + 'tours.planner.inspector.distance': 'Jarak', + 'tours.planner.inspector.duration': 'Waktu berjalan kaki', + 'tours.planner.inspector.elevation': 'Profil elevasi', + 'tours.planner.inspector.profileFocus': 'Pada jarak {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profil ditampilkan saat data elevasi rute tersedia.', + 'tours.planner.gpxNoGeometry': 'Geometri rute tidak tersedia untuk impor GPX ini.', + 'tours.planner.mobileHint': + 'Perencanaan tur tersedia di tablet dan komputer. Tur tersimpan dapat dilihat dan dijadwalkan di sini.', + 'tourTypes.hike': 'Pendakian', + 'tourTypes.bike': 'Tur sepeda', + 'tourTypes.run': 'Lari', + 'tourTypes.ski': 'Tur ski', + 'tourTypes.kayak': 'Berkayak', + 'tourTypes.walk': 'Jalan-jalan kota / wisata', + 'tourTypes.food': 'Tur kuliner / anggur', +}; +export default tours; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts new file mode 100644 index 0000000000..0a3ee576d0 --- /dev/null +++ b/shared/src/i18n/it/tours.ts @@ -0,0 +1,135 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Escursioni', + 'tours.mode.places': 'Luoghi', + 'tours.mode.tours': 'Escursioni', + 'tours.mode.label': 'Aggiungi vista', + 'tours.title': 'Escursioni', + 'tours.subtitle': 'Escursioni a piedi e altre uscite in giornata per questo viaggio', + 'tours.empty.title': 'Nessuna escursione ancora', + 'tours.empty.body': "Pianifica un'escursione o importa un file GPX per iniziare.", + 'tours.addToDay': 'Aggiungi a un giorno', + 'tours.addToDay.comingSoon': 'Assegna le escursioni a un giorno del viaggio dalla lista delle escursioni.', + 'tours.addToDay.pickDay': 'Scegli un giorno', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Importazione GPX', + 'tours.badge.planned': 'pianificata in TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'usare con cautela', + 'tours.caution.tooltip': 'Quote o metriche incomplete: importata con riserva', + 'tours.import.caution': 'Importata con riserva: quote o metriche incomplete', + 'tours.import.success': '{count} escursioni importate da GPX', + 'tours.import.successOne': '1 escursione importata da GPX', + 'tours.import.noTrack': 'Nessuna traccia o percorso trovato nel file GPX', + 'tours.import.error': 'Impossibile importare il file GPX', + 'tours.delete.confirmBody': + 'Eliminare definitivamente questo tour? Verranno rimossi il percorso, i waypoint modificabili e le assegnazioni ai giorni. L’azione non può essere annullata.', + 'tours.delete.bulkConfirmBody': + 'La selezione include almeno un tour. Tutti i luoghi selezionati verranno eliminati definitivamente e non potranno essere ripristinati. Verranno rimossi anche i percorsi, i waypoint modificabili e le assegnazioni ai giorni dei tour.', + 'tours.delete.confirmAction': 'Elimina definitivamente', + 'tours.addedToDay': 'Aggiunta al giorno {n}', + 'tours.dayRoute.endpointUnknown': + "Impossibile determinare il punto di arrivo dell'escursione. I collegamenti successivi potrebbero essere incompleti.", + 'tours.detail.trackColor': 'Colore della traccia', + 'tours.detail.distance': 'Distanza', + 'tours.detail.minAltitude': 'Quota minima', + 'tours.detail.maxAltitude': 'Quota massima', + 'tours.detail.ascent': 'Dislivello positivo', + 'tours.detail.descent': 'Dislivello negativo', + 'tours.detail.difficulty': 'Difficoltà', + 'tours.detail.source': 'Origine', + 'tours.detail.gpxReadOnly': + 'Le escursioni GPX sono di sola lettura e non possono ancora essere modificate nel pianificatore.', + 'tours.detail.navigationHint': 'La navigazione apre il punto di partenza del sentiero; non guida lungo il percorso.', + 'tours.planner.title': "Pianifica un'escursione", + 'tours.planner.newTour': 'Pianifica nuova escursione', + 'tours.planner.neutralTitle': "Pianifica un'escursione", + 'tours.planner.neutralBody': 'Inizia un nuovo percorso e fai clic sulla mappa per posizionarne i punti.', + 'tours.planner.newTitle': 'Nuova escursione', + 'tours.planner.editTitle': 'Modifica escursione', + 'tours.planner.gpxTitle': 'Escursione GPX', + 'tours.planner.backToTours': 'Torna alle escursioni', + 'tours.planner.restored': 'Bozza non salvata ripristinata', + 'tours.planner.discardChanges': 'Scarta modifiche', + 'tours.planner.discardUnsaved': 'Scarta modifiche non salvate', + 'tours.planner.saveChanges': 'Salva modifiche', + 'tours.planner.newTourConfirmTitle': 'Iniziare una nuova escursione?', + 'tours.planner.newTourConfirmBody': + 'Le modifiche non salvate a questa escursione verranno scartate. Le escursioni salvate non verranno modificate.', + 'tours.planner.newTourConfirm': 'Scarta e inizia nuova escursione', + 'tours.planner.startingPoint': 'Fai clic sulla mappa per aggiungere il punto di partenza.', + 'tours.planner.firstUseDetails': + 'Aggiungi un altro punto per calcolare un percorso. Il percorso e le quote si aggiornano automaticamente; salva quando hai finito.', + 'tours.planner.collapseElevation': 'Comprimi profilo altimetrico', + 'tours.planner.expandElevation': 'Espandi profilo altimetrico', + 'tours.planner.name': "Nome dell'escursione", + 'tours.planner.namePlaceholder': 'Escursione a piedi senza titolo', + 'tours.planner.mapHint': 'Fai clic sulla mappa per aggiungere punti.', + 'tours.planner.waypoints': 'Punti di passaggio', + 'tours.planner.waypointLabel': 'Punto di passaggio {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Aggiungi almeno due punti per calcolare un percorso a piedi.', + 'tours.planner.start': 'Partenza', + 'tours.planner.via': 'Passaggio per', + 'tours.planner.end': 'Arrivo', + 'tours.planner.moveUp': 'Sposta punto di passaggio in alto', + 'tours.planner.moveDown': 'Sposta punto di passaggio in basso', + 'tours.planner.remove': 'Rimuovi punto di passaggio', + 'tours.planner.undo': 'Annulla modifica al punto di passaggio', + 'tours.planner.redo': 'Ripeti modifica al punto di passaggio', + 'tours.planner.discard': 'Scarta bozza', + 'tours.planner.discardTitle': "Scartare le modifiche non salvate all'escursione?", + 'tours.planner.discardBody': + "Le modifiche non salvate verranno scartate. L'escursione salvata, i suoi punti di passaggio, file e assegnazioni non verranno modificati.", + 'tours.planner.save': 'Salva escursione', + 'tours.planner.saved': 'Escursione salvata', + 'tours.planner.planAnother': "Pianificane un'altra", + 'tours.planner.assignToDay': 'Assegna a un giorno', + 'tours.planner.tripTours': 'Escursioni di questo viaggio', + 'tours.planner.safetyNote': 'Il calcolo del percorso aiuta a pianificare, ma non garantisce la sicurezza.', + 'tours.planner.safetyNoteDetails': + 'Le indicazioni di difficoltà di OSM possono essere assenti o errate. Meteo, chiusure e condizioni attuali non vengono valutati.', + 'tours.planner.maxDifficulty': 'Difficoltà massima del sentiero', + 'tours.planner.difficulty.t1': 'T1 — Escursionismo', + 'tours.planner.difficulty.t2': 'T2 — Escursionismo di montagna', + 'tours.planner.difficulty.t3': 'T3 — Escursionismo di montagna impegnativo', + 'tours.planner.difficulty.t4': 'T4 — Escursionismo alpino', + 'tours.planner.difficulty.t5': 'T5 — Escursionismo alpino impegnativo', + 'tours.planner.difficulty.t6': 'T6 — Escursionismo alpino difficile', + 'tours.planner.difficulty.t3Warning': + 'I percorsi T3 possono includere sentieri di montagna esposti o tecnicamente impegnativi che richiedono passo sicuro e assenza di vertigini.', + 'tours.planner.difficulty.alpineTitle': 'Attivare il calcolo dei percorsi alpini?', + 'tours.planner.difficulty.alpineBody': + 'Il calcolo dei percorsi alpini è attivo. I percorsi possono attraversare terreni esposti, non segnalati o tecnicamente difficili. I dati di difficoltà di OSM possono essere assenti o errati.', + 'tours.planner.difficulty.enable': 'Attiva percorsi alpini', + 'tours.planner.openFailed': "Impossibile aprire l'escursione salvata per modificarla.", + 'tours.planner.status.dirty': 'In attesa del calcolo', + 'tours.planner.status.routing': 'Calcolo del percorso a piedi…', + 'tours.planner.status.routingFailed': "Calcolo del percorso non riuscito. L'ultimo percorso valido resta visibile.", + 'tours.planner.status.elevation': 'Caricamento delle quote…', + 'tours.planner.status.elevationFailed': 'Le quote non sono disponibili. Il percorso calcolato resta visibile.', + 'tours.planner.status.ready': 'Percorso e quote pronti', + 'tours.planner.status.saving': 'Salvataggio…', + 'tours.planner.status.saveFailed': "Impossibile salvare l'escursione. La bozza è ancora disponibile.", + 'tours.planner.retry': 'Riprova', + 'tours.planner.inspector.title': "Riepilogo dell'escursione", + 'tours.planner.inspector.empty': 'I dettagli del percorso appaiono qui dopo aver collegato due punti di passaggio.', + 'tours.planner.inspector.distance': 'Distanza', + 'tours.planner.inspector.duration': 'Tempo di cammino', + 'tours.planner.inspector.elevation': 'Profilo altimetrico', + 'tours.planner.inspector.profileFocus': 'A {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'Il profilo viene mostrato quando sono disponibili i dati altimetrici del percorso.', + 'tours.planner.gpxNoGeometry': 'La geometria del percorso non è disponibile per questa importazione GPX.', + 'tours.planner.mobileHint': + 'La pianificazione delle escursioni è disponibile su tablet e computer. Qui puoi visualizzare le escursioni salvate e assegnarle a un giorno.', + 'tourTypes.hike': 'Escursione a piedi', + 'tourTypes.bike': 'Escursione in bici', + 'tourTypes.run': 'Corsa', + 'tourTypes.ski': 'Escursione con gli sci', + 'tourTypes.kayak': 'Escursione in kayak', + 'tourTypes.walk': 'Passeggiata urbana / turistica', + 'tourTypes.food': 'Tour enogastronomico', +}; +export default tours; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts new file mode 100644 index 0000000000..026e3916ab --- /dev/null +++ b/shared/src/i18n/ja/tours.ts @@ -0,0 +1,131 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'ツアー', + 'tours.mode.places': 'スポット', + 'tours.mode.tours': 'ツアー', + 'tours.mode.label': '追加する項目', + 'tours.title': 'ツアー', + 'tours.subtitle': 'この旅行のハイキングと日帰りツアー', + 'tours.empty.title': 'ツアーはまだありません', + 'tours.empty.body': 'ツアーを計画するか、GPXファイルをインポートしてください。', + 'tours.addToDay': '日程に追加', + 'tours.addToDay.comingSoon': 'ツアー一覧から旅行の日程に割り当てます。', + 'tours.addToDay.pickDay': '日程を選択', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPXインポート', + 'tours.badge.planned': 'TREKで計画', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': '利用時は注意', + 'tours.caution.tooltip': '標高・各種データが不完全です。注意が必要な状態でインポートされました。', + 'tours.import.caution': '標高・各種データが不完全なため、注意が必要な状態でインポートしました。', + 'tours.import.success': 'GPXから{count}件のツアーをインポートしました', + 'tours.import.successOne': 'GPXから1件のツアーをインポートしました', + 'tours.import.noTrack': 'このGPXファイルにトラックやルートが見つかりません', + 'tours.import.error': 'GPXファイルをインポートできませんでした', + 'tours.delete.confirmBody': + 'このツアーを完全に削除しますか?ルート、編集可能な経由地点、日程への割り当ても削除されます。この操作は取り消せません。', + 'tours.delete.bulkConfirmBody': + '選択内容にはツアーが1件以上含まれています。選択した場所はすべて完全に削除され、復元できません。ツアーのルート、編集可能な経由地点、日程への割り当ても削除されます。', + 'tours.delete.confirmAction': '完全に削除', + 'tours.addedToDay': '{n}日目に追加しました', + 'tours.dayRoute.endpointUnknown': 'ツアーの終点を特定できませんでした。以降の接続ルートが不完全な場合があります。', + 'tours.detail.trackColor': 'トラックの色', + 'tours.detail.distance': '距離', + 'tours.detail.minAltitude': '最低標高', + 'tours.detail.maxAltitude': '最高標高', + 'tours.detail.ascent': '累積上昇量', + 'tours.detail.descent': '累積下降量', + 'tours.detail.difficulty': '難易度', + 'tours.detail.source': '出典', + 'tours.detail.gpxReadOnly': 'GPXツアーは閲覧専用です。現時点ではプランナーで編集できません。', + 'tours.detail.navigationHint': 'ナビには登山口が表示されます。ルートに沿った案内は行いません。', + 'tours.planner.title': 'ツアーを計画', + 'tours.planner.newTour': '新しいツアーを計画', + 'tours.planner.neutralTitle': 'ツアーを計画', + 'tours.planner.neutralBody': '新しいツアーを始めて、地図をクリックして経由地点を置きます。', + 'tours.planner.newTitle': '新しいツアー', + 'tours.planner.editTitle': 'ツアーを編集', + 'tours.planner.gpxTitle': 'GPXツアー', + 'tours.planner.backToTours': 'ツアー一覧に戻る', + 'tours.planner.restored': '未保存の下書きを復元しました', + 'tours.planner.discardChanges': '変更を破棄', + 'tours.planner.discardUnsaved': '未保存の変更を破棄', + 'tours.planner.saveChanges': '変更を保存', + 'tours.planner.newTourConfirmTitle': '新しいツアーを開始しますか?', + 'tours.planner.newTourConfirmBody': 'このツアーの未保存の変更は破棄されます。保存済みのツアーは変更されません。', + 'tours.planner.newTourConfirm': '変更を破棄して新しいツアーを開始', + 'tours.planner.startingPoint': '地図をクリックして出発地点を追加してください。', + 'tours.planner.firstUseDetails': + 'もう1地点追加するとルートを計算します。ルートと標高は自動更新されます。準備ができたら保存してください。', + 'tours.planner.collapseElevation': '標高グラフを折りたたむ', + 'tours.planner.expandElevation': '標高グラフを展開', + 'tours.planner.name': 'ツアー名', + 'tours.planner.namePlaceholder': '名前のないハイキング', + 'tours.planner.mapHint': '地図をクリックして地点を追加します。', + 'tours.planner.waypoints': 'ルート上の地点', + 'tours.planner.waypointLabel': '地点{n}', + 'tours.durationMinutes': '{count}分', + 'tours.planner.empty': '徒歩ルートを計算するには、2地点以上追加してください。', + 'tours.planner.start': '出発地点', + 'tours.planner.via': '経由地点', + 'tours.planner.end': '到着地点', + 'tours.planner.moveUp': '地点を上へ移動', + 'tours.planner.moveDown': '地点を下へ移動', + 'tours.planner.remove': '地点を削除', + 'tours.planner.undo': '地点の編集を元に戻す', + 'tours.planner.redo': '地点の編集をやり直す', + 'tours.planner.discard': '下書きを破棄', + 'tours.planner.discardTitle': 'ツアーの未保存の変更を破棄しますか?', + 'tours.planner.discardBody': + '未保存の編集内容は破棄されます。保存済みのツアー、地点、ファイル、日程への割り当ては変更されません。', + 'tours.planner.save': 'ツアーを保存', + 'tours.planner.saved': 'ツアーを保存しました', + 'tours.planner.planAnother': '別のツアーを計画', + 'tours.planner.assignToDay': '日程に割り当て', + 'tours.planner.tripTours': 'この旅行のツアー', + 'tours.planner.safetyNote': 'ルート検索は計画の補助であり、安全を保証するものではありません。', + 'tours.planner.safetyNoteDetails': + 'OSMの難易度タグは欠落していたり、誤っていたりする場合があります。天候、通行止め、現地の最新状況は評価されません。', + 'tours.planner.maxDifficulty': '登山道の難易度上限', + 'tours.planner.difficulty.t1': 'T1:ハイキング', + 'tours.planner.difficulty.t2': 'T2:山岳ハイキング', + 'tours.planner.difficulty.t3': 'T3:上級山岳ハイキング', + 'tours.planner.difficulty.t4': 'T4:アルパインハイキング', + 'tours.planner.difficulty.t5': 'T5:上級アルパインハイキング', + 'tours.planner.difficulty.t6': 'T6:難度の高いアルパインハイキング', + 'tours.planner.difficulty.t3Warning': + 'T3のルートには、転落の危険がある箇所や技術を要する登山道が含まれる場合があります。確かな足運びと、高所でも動じないことが求められます。', + 'tours.planner.difficulty.alpineTitle': 'アルパインルート検索を有効にしますか?', + 'tours.planner.difficulty.alpineBody': + 'アルパインルート検索が有効になります。転落の危険がある場所、道標や目印のない場所、技術的に難しい地形を通る場合があります。OSMの難易度データは欠落や誤りがある場合があります。', + 'tours.planner.difficulty.enable': 'アルパインルート検索を有効にする', + 'tours.planner.openFailed': '保存済みのツアーを編集用に開けませんでした。', + 'tours.planner.status.dirty': '計算待ち', + 'tours.planner.status.routing': '徒歩ルートを計算中…', + 'tours.planner.status.routingFailed': 'ルートの計算に失敗しました。直前に計算できたルートを引き続き表示しています。', + 'tours.planner.status.elevation': '標高を読み込み中…', + 'tours.planner.status.elevationFailed': '標高を取得できません。計算済みのルートを引き続き表示しています。', + 'tours.planner.status.ready': 'ルートと標高の準備完了', + 'tours.planner.status.saving': '保存中…', + 'tours.planner.status.saveFailed': 'ツアーを保存できませんでした。下書きは保持されています。', + 'tours.planner.retry': '再試行', + 'tours.planner.inspector.title': 'ツアーの概要', + 'tours.planner.inspector.empty': '2地点を接続すると、ここにルートの詳細が表示されます。', + 'tours.planner.inspector.distance': '距離', + 'tours.planner.inspector.duration': '歩行時間', + 'tours.planner.inspector.elevation': '標高グラフ', + 'tours.planner.inspector.profileFocus': '距離{distance}:標高{elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'ルートの標高データが取得できると、グラフが表示されます。', + 'tours.planner.gpxNoGeometry': 'このGPXインポートにはルートの形状データがありません。', + 'tours.planner.mobileHint': + 'ツアーの計画はタブレットとパソコンで利用できます。ここでは保存済みのツアーを閲覧し、日程に割り当てられます。', + 'tourTypes.hike': 'ハイキング', + 'tourTypes.bike': 'サイクリング', + 'tourTypes.run': 'ランニング', + 'tourTypes.ski': 'スキーツアー', + 'tourTypes.kayak': 'カヤック', + 'tourTypes.walk': '街歩き・観光', + 'tourTypes.food': 'グルメ・ワインツアー', +}; +export default tours; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts new file mode 100644 index 0000000000..2b7df68817 --- /dev/null +++ b/shared/src/i18n/ko/tours.ts @@ -0,0 +1,132 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': '투어', + 'tours.mode.places': '장소', + 'tours.mode.tours': '투어', + 'tours.mode.label': '추가 항목 보기', + 'tours.title': '투어', + 'tours.subtitle': '이 여행의 하이킹과 당일 투어', + 'tours.empty.title': '아직 투어가 없습니다', + 'tours.empty.body': '투어를 계획하거나 GPX 파일을 가져와 시작하세요.', + 'tours.addToDay': '일정에 추가', + 'tours.addToDay.comingSoon': '투어 목록에서 여행의 특정 날짜에 투어를 배정하세요.', + 'tours.addToDay.pickDay': '날짜 선택', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX 가져오기', + 'tours.badge.planned': 'TREK에서 계획됨', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': '이용 시 주의', + 'tours.caution.tooltip': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다', + 'tours.import.caution': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다', + 'tours.import.success': 'GPX에서 투어 {count}개를 가져왔습니다', + 'tours.import.successOne': 'GPX에서 투어 1개를 가져왔습니다', + 'tours.import.noTrack': '이 GPX 파일에서 트랙이나 경로를 찾을 수 없습니다', + 'tours.import.error': 'GPX 파일을 가져오지 못했습니다', + 'tours.delete.confirmBody': + '이 투어를 영구 삭제할까요? 경로, 편집 가능한 경유 지점 및 날짜 배정이 삭제됩니다. 이 작업은 되돌릴 수 없습니다.', + 'tours.delete.bulkConfirmBody': + '선택 항목에 투어가 하나 이상 포함되어 있습니다. 선택한 장소가 모두 영구 삭제되며 복원할 수 없습니다. 투어의 경로, 편집 가능한 경유 지점 및 날짜 배정도 삭제됩니다.', + 'tours.delete.confirmAction': '영구 삭제', + 'tours.addedToDay': '{n}일차에 추가했습니다', + 'tours.dayRoute.endpointUnknown': '투어의 종점을 확인할 수 없습니다. 이후 연결 경로가 불완전할 수 있습니다.', + 'tours.detail.trackColor': '트랙 색상', + 'tours.detail.distance': '거리', + 'tours.detail.minAltitude': '최저 고도', + 'tours.detail.maxAltitude': '최고 고도', + 'tours.detail.ascent': '누적 상승 고도', + 'tours.detail.descent': '누적 하강 고도', + 'tours.detail.difficulty': '난이도', + 'tours.detail.source': '출처', + 'tours.detail.gpxReadOnly': 'GPX 투어는 조회만 가능하며, 아직 플래너에서 편집할 수 없습니다.', + 'tours.detail.navigationHint': '길찾기는 탐방로 입구를 목적지로 엽니다. 투어 경로를 따라 안내하지는 않습니다.', + 'tours.planner.title': '투어 계획', + 'tours.planner.newTour': '새 투어 계획', + 'tours.planner.neutralTitle': '투어 계획', + 'tours.planner.neutralBody': '새 투어를 시작하고 지도를 클릭해 경유 지점을 놓으세요.', + 'tours.planner.newTitle': '새 투어', + 'tours.planner.editTitle': '투어 편집', + 'tours.planner.gpxTitle': 'GPX 투어', + 'tours.planner.backToTours': '투어 목록으로', + 'tours.planner.restored': '저장하지 않은 초안을 복원했습니다', + 'tours.planner.discardChanges': '변경 사항 버리기', + 'tours.planner.discardUnsaved': '저장하지 않은 변경 사항 버리기', + 'tours.planner.saveChanges': '변경 사항 저장', + 'tours.planner.newTourConfirmTitle': '새 투어를 시작할까요?', + 'tours.planner.newTourConfirmBody': + '이 투어의 저장하지 않은 변경 사항은 삭제됩니다. 저장된 투어는 변경되지 않습니다.', + 'tours.planner.newTourConfirm': '변경 사항을 버리고 새 투어 시작', + 'tours.planner.startingPoint': '지도를 클릭해 출발 지점을 추가하세요.', + 'tours.planner.firstUseDetails': + '지점을 하나 더 추가하면 경로를 계산합니다. 경로와 고도는 자동으로 업데이트됩니다. 준비가 되면 저장하세요.', + 'tours.planner.collapseElevation': '고도 그래프 접기', + 'tours.planner.expandElevation': '고도 그래프 펼치기', + 'tours.planner.name': '투어 이름', + 'tours.planner.namePlaceholder': '이름 없는 하이킹', + 'tours.planner.mapHint': '지도를 클릭해 지점을 추가하세요.', + 'tours.planner.waypoints': '경로 지점', + 'tours.planner.waypointLabel': '지점 {n}', + 'tours.durationMinutes': '{count}분', + 'tours.planner.empty': '도보 경로를 계산하려면 지점을 두 개 이상 추가하세요.', + 'tours.planner.start': '출발', + 'tours.planner.via': '경유', + 'tours.planner.end': '도착', + 'tours.planner.moveUp': '지점 위로 이동', + 'tours.planner.moveDown': '지점 아래로 이동', + 'tours.planner.remove': '지점 삭제', + 'tours.planner.undo': '지점 편집 실행 취소', + 'tours.planner.redo': '지점 편집 다시 실행', + 'tours.planner.discard': '초안 버리기', + 'tours.planner.discardTitle': '저장하지 않은 투어 변경 사항을 버릴까요?', + 'tours.planner.discardBody': + '저장하지 않은 편집 내용은 삭제됩니다. 저장된 투어와 지점, 파일, 일정 배정은 변경되지 않습니다.', + 'tours.planner.save': '투어 저장', + 'tours.planner.saved': '투어를 저장했습니다', + 'tours.planner.planAnother': '다른 투어 계획', + 'tours.planner.assignToDay': '날짜에 배정', + 'tours.planner.tripTours': '이 여행의 투어', + 'tours.planner.safetyNote': '경로 계산은 계획을 돕는 기능이며 안전을 보장하지 않습니다.', + 'tours.planner.safetyNoteDetails': + 'OSM 난이도 태그는 누락되거나 잘못될 수 있습니다. 날씨, 통행 제한, 현재 현장 상태는 평가하지 않습니다.', + 'tours.planner.maxDifficulty': '탐방로 최대 난이도', + 'tours.planner.difficulty.t1': 'T1 - 하이킹', + 'tours.planner.difficulty.t2': 'T2 - 산악 하이킹', + 'tours.planner.difficulty.t3': 'T3 - 고난도 산악 하이킹', + 'tours.planner.difficulty.t4': 'T4 - 고산 하이킹', + 'tours.planner.difficulty.t5': 'T5 - 고난도 고산 하이킹', + 'tours.planner.difficulty.t6': 'T6 - 매우 어려운 고산 하이킹', + 'tours.planner.difficulty.t3Warning': + 'T3 경로에는 추락 위험에 노출된 산길이나 높은 기술이 필요한 산길이 포함될 수 있습니다. 안정적인 발디딤과 높은 곳에서도 두려움 없이 움직이는 능력이 필요합니다.', + 'tours.planner.difficulty.alpineTitle': '고산 경로 계산을 활성화할까요?', + 'tours.planner.difficulty.alpineBody': + '고산 경로 계산이 활성화됩니다. 경로가 추락 위험에 노출된 지형, 표식이 없는 지형 또는 기술적으로 어려운 지형을 지날 수 있습니다. OSM 난이도 데이터는 누락되거나 잘못될 수 있습니다.', + 'tours.planner.difficulty.enable': '고산 경로 계산 활성화', + 'tours.planner.openFailed': '저장된 투어를 편집용으로 열지 못했습니다.', + 'tours.planner.status.dirty': '계산 대기 중', + 'tours.planner.status.routing': '도보 경로 계산 중…', + 'tours.planner.status.routingFailed': '경로 계산에 실패했습니다. 마지막으로 계산에 성공한 경로를 계속 표시합니다.', + 'tours.planner.status.elevation': '고도 불러오는 중…', + 'tours.planner.status.elevationFailed': '고도를 불러올 수 없습니다. 계산된 경로를 계속 표시합니다.', + 'tours.planner.status.ready': '경로와 고도 준비 완료', + 'tours.planner.status.saving': '저장 중…', + 'tours.planner.status.saveFailed': '투어를 저장하지 못했습니다. 초안은 그대로 남아 있습니다.', + 'tours.planner.retry': '다시 시도', + 'tours.planner.inspector.title': '투어 요약', + 'tours.planner.inspector.empty': '두 지점을 연결하면 여기에 경로 정보가 표시됩니다.', + 'tours.planner.inspector.distance': '거리', + 'tours.planner.inspector.duration': '도보 소요 시간', + 'tours.planner.inspector.elevation': '고도 그래프', + 'tours.planner.inspector.profileFocus': '{distance} 지점: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': '경로의 고도 데이터가 있으면 그래프가 표시됩니다.', + 'tours.planner.gpxNoGeometry': '이 GPX 가져오기에는 경로 형상 데이터가 없습니다.', + 'tours.planner.mobileHint': + '투어 계획은 태블릿과 컴퓨터에서 이용할 수 있습니다. 여기에서는 저장된 투어를 보고 일정에 배정할 수 있습니다.', + 'tourTypes.hike': '하이킹', + 'tourTypes.bike': '자전거 투어', + 'tourTypes.run': '달리기', + 'tourTypes.ski': '스키 투어', + 'tourTypes.kayak': '카약', + 'tourTypes.walk': '도심 산책 / 관광', + 'tourTypes.food': '미식 / 와인 투어', +}; +export default tours; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts new file mode 100644 index 0000000000..5b7bf0ecfc --- /dev/null +++ b/shared/src/i18n/nl/tours.ts @@ -0,0 +1,134 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Tochten', + 'tours.mode.places': 'Plaatsen', + 'tours.mode.tours': 'Tochten', + 'tours.mode.label': 'Weergave toevoegen', + 'tours.title': 'Tochten', + 'tours.subtitle': 'Wandelingen en andere dagtochten voor deze reis', + 'tours.empty.title': 'Nog geen tochten', + 'tours.empty.body': 'Plan een tocht of importeer een GPX-bestand om te beginnen.', + 'tours.addToDay': 'Aan dag toevoegen', + 'tours.addToDay.comingSoon': 'Wijs tochten vanuit de tochtenlijst toe aan een reisdag.', + 'tours.addToDay.pickDay': 'Kies een dag', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX-import', + 'tours.badge.planned': 'gepland in TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'voorzichtig gebruiken', + 'tours.caution.tooltip': 'Hoogtegegevens of meetwaarden onvolledig: met voorbehoud geïmporteerd', + 'tours.import.caution': 'Met voorbehoud geïmporteerd: hoogtegegevens of meetwaarden onvolledig', + 'tours.import.success': '{count} tochten geïmporteerd uit GPX', + 'tours.import.successOne': '1 tocht geïmporteerd uit GPX', + 'tours.import.noTrack': 'Geen track of route gevonden in dit GPX-bestand', + 'tours.import.error': 'GPX-bestand kon niet worden geïmporteerd', + 'tours.delete.confirmBody': + 'Deze tour definitief verwijderen? De route, bewerkbare routepunten en dagtoewijzingen worden verwijderd. Dit kan niet ongedaan worden gemaakt.', + 'tours.delete.bulkConfirmBody': + 'Deze selectie bevat minstens één tour. Alle geselecteerde plaatsen worden definitief verwijderd en kunnen niet worden hersteld. Ook de routes, bewerkbare routepunten en dagtoewijzingen van de tours worden verwijderd.', + 'tours.delete.confirmAction': 'Definitief verwijderen', + 'tours.addedToDay': 'Toegevoegd aan dag {n}', + 'tours.dayRoute.endpointUnknown': + 'Het eindpunt van de tocht kon niet worden bepaald. Aansluitende verbindingen kunnen onvolledig zijn.', + 'tours.detail.trackColor': 'Trackkleur', + 'tours.detail.distance': 'Afstand', + 'tours.detail.minAltitude': 'Minimale hoogte', + 'tours.detail.maxAltitude': 'Maximale hoogte', + 'tours.detail.ascent': 'Hoogtemeters omhoog', + 'tours.detail.descent': 'Hoogtemeters omlaag', + 'tours.detail.difficulty': 'Moeilijkheid', + 'tours.detail.source': 'Bron', + 'tours.detail.gpxReadOnly': 'GPX-tochten kunnen alleen worden bekeken en nog niet in de planner worden bewerkt.', + 'tours.detail.navigationHint': 'De navigatie opent het startpunt van het pad; ze begeleidt je niet langs de route.', + 'tours.planner.title': 'Een tocht plannen', + 'tours.planner.newTour': 'Nieuwe tocht plannen', + 'tours.planner.neutralTitle': 'Een tocht plannen', + 'tours.planner.neutralBody': 'Begin een nieuwe tocht en klik op de kaart om de punten te plaatsen.', + 'tours.planner.newTitle': 'Nieuwe tocht', + 'tours.planner.editTitle': 'Tocht bewerken', + 'tours.planner.gpxTitle': 'GPX-tocht', + 'tours.planner.backToTours': 'Terug naar tochten', + 'tours.planner.restored': 'Niet-opgeslagen concept hersteld', + 'tours.planner.discardChanges': 'Wijzigingen verwerpen', + 'tours.planner.discardUnsaved': 'Niet-opgeslagen wijzigingen verwerpen', + 'tours.planner.saveChanges': 'Wijzigingen opslaan', + 'tours.planner.newTourConfirmTitle': 'Een nieuwe tocht starten?', + 'tours.planner.newTourConfirmBody': + 'Je niet-opgeslagen wijzigingen aan deze tocht worden verworpen. Opgeslagen tochten blijven ongewijzigd.', + 'tours.planner.newTourConfirm': 'Verwerpen en nieuwe tocht starten', + 'tours.planner.startingPoint': 'Klik op de kaart om je startpunt toe te voegen.', + 'tours.planner.firstUseDetails': + 'Voeg nog een punt toe om een route te berekenen. De route en hoogtegegevens worden automatisch bijgewerkt; sla op wanneer je klaar bent.', + 'tours.planner.collapseElevation': 'Hoogteprofiel inklappen', + 'tours.planner.expandElevation': 'Hoogteprofiel uitklappen', + 'tours.planner.name': 'Naam van de tocht', + 'tours.planner.namePlaceholder': 'Naamloze wandeling', + 'tours.planner.mapHint': 'Klik op de kaart om punten toe te voegen.', + 'tours.planner.waypoints': 'Routepunten', + 'tours.planner.waypointLabel': 'Routepunt {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Voeg minstens twee punten toe om een wandelroute te berekenen.', + 'tours.planner.start': 'Vertrek', + 'tours.planner.via': 'Via', + 'tours.planner.end': 'Einde', + 'tours.planner.moveUp': 'Routepunt omhoog verplaatsen', + 'tours.planner.moveDown': 'Routepunt omlaag verplaatsen', + 'tours.planner.remove': 'Routepunt verwijderen', + 'tours.planner.undo': 'Routepuntbewerking ongedaan maken', + 'tours.planner.redo': 'Routepuntbewerking opnieuw uitvoeren', + 'tours.planner.discard': 'Concept verwerpen', + 'tours.planner.discardTitle': 'Niet-opgeslagen wijzigingen aan de tocht verwerpen?', + 'tours.planner.discardBody': + 'Je niet-opgeslagen wijzigingen worden verworpen. De opgeslagen tocht, de routepunten, bestanden en toewijzingen blijven ongewijzigd.', + 'tours.planner.save': 'Tocht opslaan', + 'tours.planner.saved': 'Tocht opgeslagen', + 'tours.planner.planAnother': 'Nog een tocht plannen', + 'tours.planner.assignToDay': 'Aan een dag toewijzen', + 'tours.planner.tripTours': 'Tochten van deze reis', + 'tours.planner.safetyNote': 'Routeberekening helpt bij het plannen, maar biedt geen veiligheidsgarantie.', + 'tours.planner.safetyNoteDetails': + 'Moeilijkheidslabels in OSM kunnen ontbreken of onjuist zijn. Weer, afsluitingen en actuele omstandigheden worden niet beoordeeld.', + 'tours.planner.maxDifficulty': 'Maximale moeilijkheid van het pad', + 'tours.planner.difficulty.t1': 'T1 — Wandelen', + 'tours.planner.difficulty.t2': 'T2 — Bergwandelen', + 'tours.planner.difficulty.t3': 'T3 — Veeleisend bergwandelen', + 'tours.planner.difficulty.t4': 'T4 — Alpien wandelen', + 'tours.planner.difficulty.t5': 'T5 — Veeleisend alpien wandelen', + 'tours.planner.difficulty.t6': 'T6 — Moeilijk alpien wandelen', + 'tours.planner.difficulty.t3Warning': + 'T3-routes kunnen blootgestelde of technisch veeleisende bergpaden bevatten die tredzekerheid en geen hoogtevrees vereisen.', + 'tours.planner.difficulty.alpineTitle': 'Alpiene routeberekening inschakelen?', + 'tours.planner.difficulty.alpineBody': + 'Alpiene routeberekening is ingeschakeld. Routes kunnen over blootgesteld, ongemarkeerd of technisch moeilijk terrein lopen. Moeilijkheidsgegevens in OSM kunnen ontbreken of onjuist zijn.', + 'tours.planner.difficulty.enable': 'Alpiene routes inschakelen', + 'tours.planner.openFailed': 'De opgeslagen tocht kon niet worden geopend om te bewerken.', + 'tours.planner.status.dirty': 'Wachten op berekening', + 'tours.planner.status.routing': 'Wandelroute berekenen…', + 'tours.planner.status.routingFailed': 'Routeberekening mislukt. De laatste geldige route blijft zichtbaar.', + 'tours.planner.status.elevation': 'Hoogtegegevens laden…', + 'tours.planner.status.elevationFailed': 'Hoogtegegevens zijn niet beschikbaar. De berekende route blijft zichtbaar.', + 'tours.planner.status.ready': 'Route en hoogtegegevens gereed', + 'tours.planner.status.saving': 'Opslaan…', + 'tours.planner.status.saveFailed': 'De tocht kon niet worden opgeslagen. Je concept is nog beschikbaar.', + 'tours.planner.retry': 'Opnieuw proberen', + 'tours.planner.inspector.title': 'Tochtoverzicht', + 'tours.planner.inspector.empty': 'Routedetails verschijnen hier zodra twee routepunten verbonden zijn.', + 'tours.planner.inspector.distance': 'Afstand', + 'tours.planner.inspector.duration': 'Wandeltijd', + 'tours.planner.inspector.elevation': 'Hoogteprofiel', + 'tours.planner.inspector.profileFocus': 'Bij {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'Het profiel wordt getoond zodra hoogtegegevens voor de route beschikbaar zijn.', + 'tours.planner.gpxNoGeometry': 'De routegeometrie is niet beschikbaar voor deze GPX-import.', + 'tours.planner.mobileHint': + 'Tochten plannen kan op een tablet of computer. Hier kun je opgeslagen tochten bekijken en aan een dag toewijzen.', + 'tourTypes.hike': 'Wandeling', + 'tourTypes.bike': 'Fietstocht', + 'tourTypes.run': 'Hardlooptocht', + 'tourTypes.ski': 'Skitocht', + 'tourTypes.kayak': 'Kajaktocht', + 'tourTypes.walk': 'Stadswandeling / bezienswaardigheden', + 'tourTypes.food': 'Culinaire tocht / wijntocht', +}; +export default tours; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts new file mode 100644 index 0000000000..6c7b07200c --- /dev/null +++ b/shared/src/i18n/pl/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Wycieczki', + 'tours.mode.places': 'Miejsca', + 'tours.mode.tours': 'Wycieczki', + 'tours.mode.label': 'Widok dodawania', + 'tours.title': 'Wycieczki', + 'tours.subtitle': 'Wędrówki i inne jednodniowe wycieczki podczas tej podróży', + 'tours.empty.title': 'Nie ma jeszcze wycieczek', + 'tours.empty.body': 'Zaplanuj wycieczkę lub zaimportuj plik GPX, aby rozpocząć.', + 'tours.addToDay': 'Dodaj do dnia', + 'tours.addToDay.comingSoon': 'Przypisz wycieczki do dnia podróży z listy wycieczek.', + 'tours.addToDay.pickDay': 'Wybierz dzień', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Import GPX', + 'tours.badge.planned': 'zaplanowano w TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'korzystaj ostrożnie', + 'tours.caution.tooltip': 'Niepełne dane wysokościowe lub parametry – import z zastrzeżeniami', + 'tours.import.caution': 'Import z zastrzeżeniami – niepełne dane wysokościowe lub parametry', + 'tours.import.success': 'Liczba wycieczek zaimportowanych z GPX: {count}', + 'tours.import.successOne': 'Zaimportowano 1 wycieczkę z GPX', + 'tours.import.noTrack': 'W tym pliku GPX nie znaleziono śladu ani trasy', + 'tours.import.error': 'Nie udało się zaimportować pliku GPX', + 'tours.delete.confirmBody': + 'Usunąć tę trasę na stałe? Jej przebieg, edytowalne punkty trasy i przypisania do dni zostaną usunięte. Tej czynności nie można cofnąć.', + 'tours.delete.bulkConfirmBody': + 'Ten wybór obejmuje co najmniej jedną trasę. Wszystkie wybrane miejsca zostaną trwale usunięte i nie będzie można ich przywrócić. Przebiegi tras, edytowalne punkty i przypisania do dni również zostaną usunięte.', + 'tours.delete.confirmAction': 'Usuń na stałe', + 'tours.addedToDay': 'Dodano do dnia {n}', + 'tours.dayRoute.endpointUnknown': + 'Nie udało się ustalić punktu końcowego wycieczki. Kolejne odcinki łączące mogą być niepełne.', + 'tours.detail.trackColor': 'Kolor śladu', + 'tours.detail.distance': 'Dystans', + 'tours.detail.minAltitude': 'Minimalna wysokość n.p.m.', + 'tours.detail.maxAltitude': 'Maksymalna wysokość n.p.m.', + 'tours.detail.ascent': 'Suma podejść', + 'tours.detail.descent': 'Suma zejść', + 'tours.detail.difficulty': 'Trudność', + 'tours.detail.source': 'Źródło', + 'tours.detail.gpxReadOnly': 'Wycieczki GPX można tylko przeglądać; nie można ich jeszcze edytować w planerze.', + 'tours.detail.navigationHint': 'Nawigacja otwiera punkt początkowy szlaku; nie prowadzi wzdłuż trasy.', + 'tours.planner.title': 'Zaplanuj wycieczkę', + 'tours.planner.newTour': 'Zaplanuj nową wycieczkę', + 'tours.planner.neutralTitle': 'Zaplanuj wycieczkę', + 'tours.planner.neutralBody': 'Rozpocznij nową trasę i kliknij mapę, aby wyznaczyć jej punkty.', + 'tours.planner.newTitle': 'Nowa wycieczka', + 'tours.planner.editTitle': 'Edytuj wycieczkę', + 'tours.planner.gpxTitle': 'Wycieczka GPX', + 'tours.planner.backToTours': 'Wróć do wycieczek', + 'tours.planner.restored': 'Przywrócono niezapisany szkic', + 'tours.planner.discardChanges': 'Odrzuć zmiany', + 'tours.planner.discardUnsaved': 'Odrzuć niezapisane zmiany', + 'tours.planner.saveChanges': 'Zapisz zmiany', + 'tours.planner.newTourConfirmTitle': 'Rozpocząć nową wycieczkę?', + 'tours.planner.newTourConfirmBody': + 'Niezapisane zmiany tej wycieczki zostaną odrzucone. Zapisane wycieczki nie zostaną zmienione.', + 'tours.planner.newTourConfirm': 'Odrzuć zmiany i rozpocznij nową wycieczkę', + 'tours.planner.startingPoint': 'Kliknij mapę, aby dodać punkt początkowy.', + 'tours.planner.firstUseDetails': + 'Dodaj kolejny punkt, aby obliczyć trasę. Trasa i wysokości aktualizują się automatycznie; zapisz wycieczkę, gdy będzie gotowa.', + 'tours.planner.collapseElevation': 'Zwiń profil wysokościowy', + 'tours.planner.expandElevation': 'Rozwiń profil wysokościowy', + 'tours.planner.name': 'Nazwa wycieczki', + 'tours.planner.namePlaceholder': 'Wędrówka bez nazwy', + 'tours.planner.mapHint': 'Kliknij mapę, aby dodać punkty.', + 'tours.planner.waypoints': 'Punkty trasy', + 'tours.planner.waypointLabel': 'Punkt trasy {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Dodaj co najmniej dwa punkty, aby obliczyć trasę pieszą.', + 'tours.planner.start': 'Początek', + 'tours.planner.via': 'Przez', + 'tours.planner.end': 'Koniec', + 'tours.planner.moveUp': 'Przesuń punkt w górę', + 'tours.planner.moveDown': 'Przesuń punkt w dół', + 'tours.planner.remove': 'Usuń punkt', + 'tours.planner.undo': 'Cofnij zmianę punktu', + 'tours.planner.redo': 'Ponów zmianę punktu', + 'tours.planner.discard': 'Odrzuć szkic', + 'tours.planner.discardTitle': 'Odrzucić niezapisane zmiany wycieczki?', + 'tours.planner.discardBody': + 'Niezapisane zmiany zostaną odrzucone. Zapisana wycieczka, jej punkty, pliki i przypisania pozostaną bez zmian.', + 'tours.planner.save': 'Zapisz wycieczkę', + 'tours.planner.saved': 'Wycieczka zapisana', + 'tours.planner.planAnother': 'Zaplanuj kolejną', + 'tours.planner.assignToDay': 'Przypisz do dnia', + 'tours.planner.tripTours': 'Wycieczki w tej podróży', + 'tours.planner.safetyNote': 'Wyznaczanie trasy pomaga w planowaniu, ale nie gwarantuje bezpieczeństwa.', + 'tours.planner.safetyNoteDetails': + 'Oznaczeń trudności w OSM może brakować lub mogą być błędne. Pogoda, zamknięcia szlaków i aktualne warunki nie są oceniane.', + 'tours.planner.maxDifficulty': 'Maksymalna trudność szlaku', + 'tours.planner.difficulty.t1': 'T1 – Turystyka piesza', + 'tours.planner.difficulty.t2': 'T2 – Turystyka górska', + 'tours.planner.difficulty.t3': 'T3 – Wymagająca turystyka górska', + 'tours.planner.difficulty.t4': 'T4 – Turystyka wysokogórska', + 'tours.planner.difficulty.t5': 'T5 – Wymagająca turystyka wysokogórska', + 'tours.planner.difficulty.t6': 'T6 – Trudna turystyka wysokogórska', + 'tours.planner.difficulty.t3Warning': + 'Trasy T3 mogą obejmować eksponowane lub wymagające technicznie szlaki górskie, na których konieczne są pewny krok i odporność na lęk wysokości.', + 'tours.planner.difficulty.alpineTitle': 'Włączyć trasy wysokogórskie?', + 'tours.planner.difficulty.alpineBody': + 'Trasy wysokogórskie są włączone. Mogą prowadzić przez eksponowany, nieoznakowany lub trudny technicznie teren. Danych o trudności w OSM może brakować lub mogą być błędne.', + 'tours.planner.difficulty.enable': 'Włącz trasy wysokogórskie', + 'tours.planner.openFailed': 'Nie udało się otworzyć zapisanej wycieczki do edycji.', + 'tours.planner.status.dirty': 'Oczekiwanie na obliczenie', + 'tours.planner.status.routing': 'Obliczanie trasy pieszej…', + 'tours.planner.status.routingFailed': 'Nie udało się obliczyć trasy. Nadal widoczna jest ostatnia poprawna trasa.', + 'tours.planner.status.elevation': 'Wczytywanie wysokości…', + 'tours.planner.status.elevationFailed': 'Dane wysokościowe są niedostępne. Obliczona trasa nadal jest widoczna.', + 'tours.planner.status.ready': 'Trasa i wysokości są gotowe', + 'tours.planner.status.saving': 'Zapisywanie…', + 'tours.planner.status.saveFailed': 'Nie udało się zapisać wycieczki. Szkic został zachowany.', + 'tours.planner.retry': 'Spróbuj ponownie', + 'tours.planner.inspector.title': 'Podsumowanie wycieczki', + 'tours.planner.inspector.empty': 'Szczegóły trasy pojawią się tutaj po połączeniu dwóch punktów.', + 'tours.planner.inspector.distance': 'Dystans', + 'tours.planner.inspector.duration': 'Czas marszu', + 'tours.planner.inspector.elevation': 'Profil wysokościowy', + 'tours.planner.inspector.profileFocus': 'Na dystansie {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profil pojawi się, gdy będą dostępne dane wysokościowe trasy.', + 'tours.planner.gpxNoGeometry': 'Geometria trasy jest niedostępna dla tego importu GPX.', + 'tours.planner.mobileHint': + 'Planowanie wycieczek jest dostępne na tablecie i komputerze. Tutaj możesz przeglądać zapisane wycieczki i przypisywać je do dni.', + 'tourTypes.hike': 'Wędrówka piesza', + 'tourTypes.bike': 'Wycieczka rowerowa', + 'tourTypes.run': 'Bieg', + 'tourTypes.ski': 'Wycieczka skiturowa', + 'tourTypes.kayak': 'Kajak', + 'tourTypes.walk': 'Spacer po mieście / zwiedzanie', + 'tourTypes.food': 'Wycieczka kulinarna / winiarska', +}; +export default tours; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts new file mode 100644 index 0000000000..b69c767ab6 --- /dev/null +++ b/shared/src/i18n/ru/tours.ts @@ -0,0 +1,134 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Туры', + 'tours.mode.places': 'Места', + 'tours.mode.tours': 'Туры', + 'tours.mode.label': 'Режим добавления', + 'tours.title': 'Туры', + 'tours.subtitle': 'Походы и другие однодневные туры в этой поездке', + 'tours.empty.title': 'Туров пока нет', + 'tours.empty.body': 'Для начала спланируйте тур или импортируйте файл GPX.', + 'tours.addToDay': 'Добавить к дню', + 'tours.addToDay.comingSoon': 'Назначайте туры на дни поездки из списка туров.', + 'tours.addToDay.pickDay': 'Выберите день', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Импорт GPX', + 'tours.badge.planned': 'спланировано в TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'используйте с осторожностью', + 'tours.caution.tooltip': 'Данные о высоте или показателях неполные — импорт с оговорками', + 'tours.import.caution': 'Импорт с оговорками — данные о высоте или показателях неполные', + 'tours.import.success': 'Импортировано туров из GPX: {count}', + 'tours.import.successOne': 'Импортирован 1 тур из GPX', + 'tours.import.noTrack': 'В этом файле GPX не найден трек или маршрут', + 'tours.import.error': 'Не удалось импортировать файл GPX', + 'tours.delete.confirmBody': + 'Удалить этот маршрут навсегда? Его путь, редактируемые точки и назначения на дни будут удалены. Это действие нельзя отменить.', + 'tours.delete.bulkConfirmBody': + 'В выборке есть как минимум один маршрут. Все выбранные места будут удалены навсегда, восстановить их будет нельзя. Пути маршрутов, редактируемые точки и назначения на дни также будут удалены.', + 'tours.delete.confirmAction': 'Удалить навсегда', + 'tours.addedToDay': 'Добавлено к дню {n}', + 'tours.dayRoute.endpointUnknown': + 'Не удалось определить конечную точку тура. Следующие соединительные участки могут быть неполными.', + 'tours.detail.trackColor': 'Цвет трека', + 'tours.detail.distance': 'Расстояние', + 'tours.detail.minAltitude': 'Минимальная высота', + 'tours.detail.maxAltitude': 'Максимальная высота', + 'tours.detail.ascent': 'Набор высоты', + 'tours.detail.descent': 'Сброс высоты', + 'tours.detail.difficulty': 'Сложность', + 'tours.detail.source': 'Источник', + 'tours.detail.gpxReadOnly': 'Туры GPX доступны только для просмотра; редактировать их в планировщике пока нельзя.', + 'tours.detail.navigationHint': 'Навигация открывает начальную точку тропы, но не ведёт по маршруту.', + 'tours.planner.title': 'Спланировать тур', + 'tours.planner.newTour': 'Спланировать новый тур', + 'tours.planner.neutralTitle': 'Спланировать тур', + 'tours.planner.neutralBody': 'Начните новый маршрут и нажимайте на карту, чтобы расставить точки.', + 'tours.planner.newTitle': 'Новый тур', + 'tours.planner.editTitle': 'Редактировать тур', + 'tours.planner.gpxTitle': 'Тур GPX', + 'tours.planner.backToTours': 'К турам', + 'tours.planner.restored': 'Несохранённый черновик восстановлен', + 'tours.planner.discardChanges': 'Отменить изменения', + 'tours.planner.discardUnsaved': 'Отменить несохранённые изменения', + 'tours.planner.saveChanges': 'Сохранить изменения', + 'tours.planner.newTourConfirmTitle': 'Начать новый тур?', + 'tours.planner.newTourConfirmBody': + 'Несохранённые изменения этого тура будут отменены. Сохранённые туры не изменятся.', + 'tours.planner.newTourConfirm': 'Отменить изменения и начать новый тур', + 'tours.planner.startingPoint': 'Нажмите на карту, чтобы добавить начальную точку.', + 'tours.planner.firstUseDetails': + 'Добавьте ещё одну точку для расчёта маршрута. Маршрут и высоты обновляются автоматически; сохраните тур, когда он будет готов.', + 'tours.planner.collapseElevation': 'Свернуть профиль высот', + 'tours.planner.expandElevation': 'Развернуть профиль высот', + 'tours.planner.name': 'Название тура', + 'tours.planner.namePlaceholder': 'Поход без названия', + 'tours.planner.mapHint': 'Нажмите на карту, чтобы добавить точки.', + 'tours.planner.waypoints': 'Путевые точки', + 'tours.planner.waypointLabel': 'Путевая точка {n}', + 'tours.durationMinutes': '{count} мин', + 'tours.planner.empty': 'Добавьте хотя бы две точки для расчёта пешего маршрута.', + 'tours.planner.start': 'Начало', + 'tours.planner.via': 'Через', + 'tours.planner.end': 'Конец', + 'tours.planner.moveUp': 'Переместить точку выше', + 'tours.planner.moveDown': 'Переместить точку ниже', + 'tours.planner.remove': 'Удалить точку', + 'tours.planner.undo': 'Отменить изменение точки', + 'tours.planner.redo': 'Повторить изменение точки', + 'tours.planner.discard': 'Удалить черновик', + 'tours.planner.discardTitle': 'Отменить несохранённые изменения тура?', + 'tours.planner.discardBody': + 'Несохранённые изменения будут отменены. Сохранённый тур, его точки, файлы и назначения на дни не изменятся.', + 'tours.planner.save': 'Сохранить тур', + 'tours.planner.saved': 'Тур сохранён', + 'tours.planner.planAnother': 'Спланировать ещё', + 'tours.planner.assignToDay': 'Назначить на день', + 'tours.planner.tripTours': 'Туры этой поездки', + 'tours.planner.safetyNote': 'Расчёт маршрута помогает в планировании, но не гарантирует безопасность.', + 'tours.planner.safetyNoteDetails': + 'Теги сложности OSM могут отсутствовать или быть неверными. Погода, закрытые участки и текущие условия не оцениваются.', + 'tours.planner.maxDifficulty': 'Максимальная сложность тропы', + 'tours.planner.difficulty.t1': 'T1 — Пеший поход', + 'tours.planner.difficulty.t2': 'T2 — Горный поход', + 'tours.planner.difficulty.t3': 'T3 — Сложный горный поход', + 'tours.planner.difficulty.t4': 'T4 — Высокогорный поход', + 'tours.planner.difficulty.t5': 'T5 — Сложный высокогорный поход', + 'tours.planner.difficulty.t6': 'T6 — Очень сложный высокогорный поход', + 'tours.planner.difficulty.t3Warning': + 'Маршруты T3 могут включать открытые участки над обрывами или технически сложные горные тропы, требующие уверенного шага и отсутствия боязни высоты.', + 'tours.planner.difficulty.alpineTitle': 'Разрешить высокогорные маршруты?', + 'tours.planner.difficulty.alpineBody': + 'Высокогорные маршруты разрешены. Они могут проходить по открытым участкам над обрывами, немаркированной или технически сложной местности. Данные о сложности в OSM могут отсутствовать или быть неверными.', + 'tours.planner.difficulty.enable': 'Разрешить высокогорные маршруты', + 'tours.planner.openFailed': 'Не удалось открыть сохранённый тур для редактирования.', + 'tours.planner.status.dirty': 'Ожидание расчёта', + 'tours.planner.status.routing': 'Расчёт пешего маршрута…', + 'tours.planner.status.routingFailed': + 'Не удалось рассчитать маршрут. По-прежнему отображается последний успешно рассчитанный маршрут.', + 'tours.planner.status.elevation': 'Загрузка высот…', + 'tours.planner.status.elevationFailed': 'Данные о высоте недоступны. Рассчитанный маршрут по-прежнему отображается.', + 'tours.planner.status.ready': 'Маршрут и высоты готовы', + 'tours.planner.status.saving': 'Сохранение…', + 'tours.planner.status.saveFailed': 'Не удалось сохранить тур. Черновик по-прежнему доступен здесь.', + 'tours.planner.retry': 'Повторить', + 'tours.planner.inspector.title': 'Сводка тура', + 'tours.planner.inspector.empty': 'Сведения о маршруте появятся здесь после соединения двух путевых точек.', + 'tours.planner.inspector.distance': 'Расстояние', + 'tours.planner.inspector.duration': 'Время пешком', + 'tours.planner.inspector.elevation': 'Профиль высот', + 'tours.planner.inspector.profileFocus': 'На расстоянии {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Профиль появится, когда будут доступны данные о высоте маршрута.', + 'tours.planner.gpxNoGeometry': 'Геометрия маршрута недоступна для этого импорта GPX.', + 'tours.planner.mobileHint': + 'Планирование туров доступно на планшете и компьютере. Здесь можно просматривать сохранённые туры и назначать их на дни.', + 'tourTypes.hike': 'Пеший поход', + 'tourTypes.bike': 'Велотур', + 'tourTypes.run': 'Пробежка', + 'tourTypes.ski': 'Лыжный тур', + 'tourTypes.kayak': 'Каякинг', + 'tourTypes.walk': 'Прогулка по городу / достопримечательностям', + 'tourTypes.food': 'Гастрономический / винный тур', +}; +export default tours; diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index d7fad9f621..73301a1465 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -71,6 +72,7 @@ const locale = { ...collection, ...atlas, ...trip, + ...tours, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts new file mode 100644 index 0000000000..57b0a75dee --- /dev/null +++ b/shared/src/i18n/sk/tours.ts @@ -0,0 +1,131 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Túry', + 'tours.mode.places': 'Miesta', + 'tours.mode.tours': 'Túry', + 'tours.mode.label': 'Zobrazenie pridávania', + 'tours.title': 'Túry', + 'tours.subtitle': 'Pešie a ďalšie jednodňové túry počas tejto cesty', + 'tours.empty.title': 'Zatiaľ žiadne túry', + 'tours.empty.body': 'Začnite plánovaním túry alebo importom súboru GPX.', + 'tours.addToDay': 'Pridať k dňu', + 'tours.addToDay.comingSoon': 'Priraďte túry k dňu cesty zo zoznamu túr.', + 'tours.addToDay.pickDay': 'Vyberte deň', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Import GPX', + 'tours.badge.planned': 'Naplánované v TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'Používajte opatrne', + 'tours.caution.tooltip': 'Neúplné výškové údaje alebo metriky – importované s upozornením', + 'tours.import.caution': 'Importované s upozornením – neúplné výškové údaje alebo metriky', + 'tours.import.success': 'Počet túr importovaných z GPX: {count}', + 'tours.import.successOne': '1 túra importovaná z GPX', + 'tours.import.noTrack': 'V tomto súbore GPX sa nenašla žiadna stopa ani trasa', + 'tours.import.error': 'Súbor GPX sa nepodarilo importovať', + 'tours.delete.confirmBody': + 'Naozaj chcete tento výlet natrvalo odstrániť? Odstráni sa jeho trasa, upraviteľné body a priradenia k dňom. Túto akciu nemožno vrátiť späť.', + 'tours.delete.bulkConfirmBody': + 'Výber obsahuje aspoň jeden výlet. Všetky vybrané miesta sa natrvalo odstránia a nebude možné ich obnoviť. Odstránia sa aj trasy, upraviteľné body a priradenia výletov k dňom.', + 'tours.delete.confirmAction': 'Odstrániť natrvalo', + 'tours.addedToDay': 'Pridané k dňu {n}', + 'tours.dayRoute.endpointUnknown': 'Koncový bod túry sa nepodarilo určiť. Nadväzujúce spojenia môžu byť neúplné.', + 'tours.detail.trackColor': 'Farba stopy', + 'tours.detail.distance': 'Vzdialenosť', + 'tours.detail.minAltitude': 'Minimálna nadmorská výška', + 'tours.detail.maxAltitude': 'Maximálna nadmorská výška', + 'tours.detail.ascent': 'Stúpanie', + 'tours.detail.descent': 'Klesanie', + 'tours.detail.difficulty': 'Náročnosť', + 'tours.detail.source': 'Zdroj', + 'tours.detail.gpxReadOnly': 'Túry z GPX možno len prezerať, zatiaľ ich nemožno upravovať v plánovači.', + 'tours.detail.navigationHint': 'Navigácia otvorí začiatok túry; nevedie po trase.', + 'tours.planner.title': 'Naplánovať túru', + 'tours.planner.newTour': 'Naplánovať novú túru', + 'tours.planner.neutralTitle': 'Naplánovať túru', + 'tours.planner.neutralBody': 'Začnite novú túru a kliknutím do mapy umiestnite jej body.', + 'tours.planner.newTitle': 'Nová túra', + 'tours.planner.editTitle': 'Upraviť túru', + 'tours.planner.gpxTitle': 'Túra z GPX', + 'tours.planner.backToTours': 'Späť na túry', + 'tours.planner.restored': 'Neuložený návrh obnovený', + 'tours.planner.discardChanges': 'Zahodiť zmeny', + 'tours.planner.discardUnsaved': 'Zahodiť neuložené zmeny', + 'tours.planner.saveChanges': 'Uložiť zmeny', + 'tours.planner.newTourConfirmTitle': 'Začať novú túru?', + 'tours.planner.newTourConfirmBody': 'Neuložené zmeny tejto túry sa zahodia. Uložené túry zostanú nezmenené.', + 'tours.planner.newTourConfirm': 'Zahodiť zmeny a začať novú túru', + 'tours.planner.startingPoint': 'Kliknite na mapu a pridajte začiatočný bod.', + 'tours.planner.firstUseDetails': + 'Pridajte ďalší bod na výpočet trasy. Trasa a výškové údaje sa aktualizujú automaticky; hotový návrh uložte.', + 'tours.planner.collapseElevation': 'Zbaliť výškový profil', + 'tours.planner.expandElevation': 'Rozbaliť výškový profil', + 'tours.planner.name': 'Názov túry', + 'tours.planner.namePlaceholder': 'Pešia túra bez názvu', + 'tours.planner.mapHint': 'Kliknutím do mapy pridáte body.', + 'tours.planner.waypoints': 'Body trasy', + 'tours.planner.waypointLabel': 'Bod trasy {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Pridajte aspoň dva body na výpočet pešej trasy.', + 'tours.planner.start': 'Začiatok', + 'tours.planner.via': 'Cez', + 'tours.planner.end': 'Koniec', + 'tours.planner.moveUp': 'Posunúť bod trasy nahor', + 'tours.planner.moveDown': 'Posunúť bod trasy nadol', + 'tours.planner.remove': 'Odstrániť bod trasy', + 'tours.planner.undo': 'Vrátiť úpravu bodu trasy', + 'tours.planner.redo': 'Zopakovať úpravu bodu trasy', + 'tours.planner.discard': 'Zahodiť návrh', + 'tours.planner.discardTitle': 'Zahodiť neuložené zmeny túry?', + 'tours.planner.discardBody': + 'Neuložené úpravy sa zahodia. Uložená túra, jej body trasy, súbory a priradenia zostanú nezmenené.', + 'tours.planner.save': 'Uložiť túru', + 'tours.planner.saved': 'Túra uložená', + 'tours.planner.planAnother': 'Naplánovať ďalšiu túru', + 'tours.planner.assignToDay': 'Priradiť k dňu', + 'tours.planner.tripTours': 'Túry tejto cesty', + 'tours.planner.safetyNote': 'Výpočet trasy pomáha pri plánovaní, nezaručuje bezpečnosť.', + 'tours.planner.safetyNoteDetails': + 'Značky náročnosti v OSM môžu chýbať alebo byť nesprávne. Počasie, uzávery a aktuálne podmienky sa nevyhodnocujú.', + 'tours.planner.maxDifficulty': 'Maximálna náročnosť chodníka', + 'tours.planner.difficulty.t1': 'T1 – Pešia turistika', + 'tours.planner.difficulty.t2': 'T2 – Horská turistika', + 'tours.planner.difficulty.t3': 'T3 – Náročná horská turistika', + 'tours.planner.difficulty.t4': 'T4 – Vysokohorská turistika', + 'tours.planner.difficulty.t5': 'T5 – Náročná vysokohorská turistika', + 'tours.planner.difficulty.t6': 'T6 – Veľmi náročná vysokohorská turistika', + 'tours.planner.difficulty.t3Warning': + 'Trasy T3 môžu viesť po exponovaných alebo technicky náročných horských chodníkoch, ktoré vyžadujú istý krok a odolnosť voči závratom vo výškach.', + 'tours.planner.difficulty.alpineTitle': 'Povoliť vysokohorské trasy?', + 'tours.planner.difficulty.alpineBody': + 'Vysokohorské trasy sú povolené. Môžu viesť cez exponovaný, neznačený alebo technicky náročný terén. Údaje o náročnosti v OSM môžu chýbať alebo byť nesprávne.', + 'tours.planner.difficulty.enable': 'Povoliť vysokohorské trasy', + 'tours.planner.openFailed': 'Uloženú túru sa nepodarilo otvoriť na úpravu.', + 'tours.planner.status.dirty': 'Čaká na výpočet', + 'tours.planner.status.routing': 'Vypočítava sa pešia trasa…', + 'tours.planner.status.routingFailed': 'Výpočet trasy zlyhal. Naďalej sa zobrazuje posledná úspešne vypočítaná trasa.', + 'tours.planner.status.elevation': 'Načítavajú sa výškové údaje…', + 'tours.planner.status.elevationFailed': 'Výškové údaje nie sú dostupné. Vypočítaná trasa sa naďalej zobrazuje.', + 'tours.planner.status.ready': 'Trasa a výškové údaje sú pripravené', + 'tours.planner.status.saving': 'Ukladá sa…', + 'tours.planner.status.saveFailed': 'Túru sa nepodarilo uložiť. Návrh zostáva zachovaný.', + 'tours.planner.retry': 'Skúsiť znova', + 'tours.planner.inspector.title': 'Prehľad túry', + 'tours.planner.inspector.empty': 'Podrobnosti trasy sa zobrazia po spojení dvoch bodov trasy.', + 'tours.planner.inspector.distance': 'Vzdialenosť', + 'tours.planner.inspector.duration': 'Čas chôdze', + 'tours.planner.inspector.elevation': 'Výškový profil', + 'tours.planner.inspector.profileFocus': 'Vo vzdialenosti {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profil sa zobrazí, keď budú dostupné výškové údaje trasy.', + 'tours.planner.gpxNoGeometry': 'Geometria trasy nie je pre tento import GPX dostupná.', + 'tours.planner.mobileHint': + 'Plánovanie túr je dostupné na tablete a počítači. Tu si môžete uložené túry prezerať a priraďovať k dňom.', + 'tourTypes.hike': 'Pešia túra', + 'tourTypes.bike': 'Cyklotúra', + 'tourTypes.run': 'Beh', + 'tourTypes.ski': 'Lyžiarska túra', + 'tourTypes.kayak': 'Kajak', + 'tourTypes.walk': 'Prechádzka mestom / po pamiatkach', + 'tourTypes.food': 'Gastronomická / vínna túra', +}; +export default tours; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts new file mode 100644 index 0000000000..eae02216e9 --- /dev/null +++ b/shared/src/i18n/sv/tours.ts @@ -0,0 +1,132 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Turer', + 'tours.mode.places': 'Platser', + 'tours.mode.tours': 'Turer', + 'tours.mode.label': 'Lägg till vy', + 'tours.title': 'Turer', + 'tours.subtitle': 'Vandringar och andra dagsturer för den här resan', + 'tours.empty.title': 'Inga turer ännu', + 'tours.empty.body': 'Planera en tur eller importera en GPX-fil för att komma igång.', + 'tours.addToDay': 'Lägg till på en dag', + 'tours.addToDay.comingSoon': 'Tilldela turer till en resdag från turlistan.', + 'tours.addToDay.pickDay': 'Välj en dag', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX-import', + 'tours.badge.planned': 'planerad i TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'använd med försiktighet', + 'tours.caution.tooltip': 'Ofullständiga höjddata eller mätvärden: importerad med förbehåll', + 'tours.import.caution': 'Importerad med förbehåll: ofullständiga höjddata eller mätvärden', + 'tours.import.success': '{count} turer importerade från GPX', + 'tours.import.successOne': '1 tur importerad från GPX', + 'tours.import.noTrack': 'Varken något spår eller någon rutt hittades i GPX-filen', + 'tours.import.error': 'Det gick inte att importera GPX-filen', + 'tours.delete.confirmBody': + 'Ta bort den här turen permanent? Rutten, de redigerbara rutpunkterna och dagstilldelningarna tas bort. Åtgärden kan inte ångras.', + 'tours.delete.bulkConfirmBody': + 'Urvalet innehåller minst en tur. Alla valda platser tas bort permanent och kan inte återställas. Även turernas rutter, redigerbara rutpunkter och dagstilldelningar tas bort.', + 'tours.delete.confirmAction': 'Ta bort permanent', + 'tours.addedToDay': 'Tillagd på dag {n}', + 'tours.dayRoute.endpointUnknown': + 'Turens slutpunkt kunde inte fastställas. Efterföljande anslutningar kan vara ofullständiga.', + 'tours.detail.trackColor': 'Spårfärg', + 'tours.detail.distance': 'Avstånd', + 'tours.detail.minAltitude': 'Lägsta höjd', + 'tours.detail.maxAltitude': 'Högsta höjd', + 'tours.detail.ascent': 'Höjdmeter uppför', + 'tours.detail.descent': 'Höjdmeter nedför', + 'tours.detail.difficulty': 'Svårighetsgrad', + 'tours.detail.source': 'Källa', + 'tours.detail.gpxReadOnly': 'GPX-turer kan bara visas och kan ännu inte redigeras i planeraren.', + 'tours.detail.navigationHint': 'Navigeringen öppnar ledens startpunkt; den vägleder inte längs rutten.', + 'tours.planner.title': 'Planera en tur', + 'tours.planner.newTour': 'Planera ny tur', + 'tours.planner.neutralTitle': 'Planera en tur', + 'tours.planner.neutralBody': 'Starta en ny tur och klicka på kartan för att placera dess punkter.', + 'tours.planner.newTitle': 'Ny tur', + 'tours.planner.editTitle': 'Redigera tur', + 'tours.planner.gpxTitle': 'GPX-tur', + 'tours.planner.backToTours': 'Tillbaka till turer', + 'tours.planner.restored': 'Osparat utkast återställt', + 'tours.planner.discardChanges': 'Kasta ändringar', + 'tours.planner.discardUnsaved': 'Kasta osparade ändringar', + 'tours.planner.saveChanges': 'Spara ändringar', + 'tours.planner.newTourConfirmTitle': 'Börja på en ny tur?', + 'tours.planner.newTourConfirmBody': 'Dina osparade ändringar i den här turen kastas. Sparade turer ändras inte.', + 'tours.planner.newTourConfirm': 'Kasta ändringar och börja på ny tur', + 'tours.planner.startingPoint': 'Klicka på kartan för att lägga till din startpunkt.', + 'tours.planner.firstUseDetails': + 'Lägg till en punkt till för att beräkna en rutt. Rutten och höjddata uppdateras automatiskt; spara när du är klar.', + 'tours.planner.collapseElevation': 'Fäll ihop höjdprofil', + 'tours.planner.expandElevation': 'Fäll ut höjdprofil', + 'tours.planner.name': 'Turens namn', + 'tours.planner.namePlaceholder': 'Vandring utan namn', + 'tours.planner.mapHint': 'Klicka på kartan för att lägga till punkter.', + 'tours.planner.waypoints': 'Ruttpunkter', + 'tours.planner.waypointLabel': 'Ruttpunkt {n}', + 'tours.durationMinutes': '{count} min', + 'tours.planner.empty': 'Lägg till minst två punkter för att beräkna en vandringsrutt.', + 'tours.planner.start': 'Startpunkt', + 'tours.planner.via': 'Via', + 'tours.planner.end': 'Slutpunkt', + 'tours.planner.moveUp': 'Flytta ruttpunkt uppåt', + 'tours.planner.moveDown': 'Flytta ruttpunkt nedåt', + 'tours.planner.remove': 'Ta bort ruttpunkt', + 'tours.planner.undo': 'Ångra ändring av ruttpunkt', + 'tours.planner.redo': 'Gör om ändring av ruttpunkt', + 'tours.planner.discard': 'Kasta utkast', + 'tours.planner.discardTitle': 'Kasta osparade ändringar i turen?', + 'tours.planner.discardBody': + 'Dina osparade ändringar kastas. Den sparade turen, dess ruttpunkter, filer och tilldelningar ändras inte.', + 'tours.planner.save': 'Spara tur', + 'tours.planner.saved': 'Tur sparad', + 'tours.planner.planAnother': 'Planera en till', + 'tours.planner.assignToDay': 'Tilldela till en dag', + 'tours.planner.tripTours': 'Turer för den här resan', + 'tours.planner.safetyNote': 'Ruttberäkningen hjälper till med planeringen, men garanterar inte säkerheten.', + 'tours.planner.safetyNoteDetails': + 'Svårighetstaggar i OSM kan saknas eller vara felaktiga. Väder, avstängningar och aktuella förhållanden bedöms inte.', + 'tours.planner.maxDifficulty': 'Högsta svårighetsgrad för leden', + 'tours.planner.difficulty.t1': 'T1 — Vandring', + 'tours.planner.difficulty.t2': 'T2 — Bergsvandring', + 'tours.planner.difficulty.t3': 'T3 — Krävande bergsvandring', + 'tours.planner.difficulty.t4': 'T4 — Alpin vandring', + 'tours.planner.difficulty.t5': 'T5 — Krävande alpin vandring', + 'tours.planner.difficulty.t6': 'T6 — Svår alpin vandring', + 'tours.planner.difficulty.t3Warning': + 'T3-rutter kan innehålla exponerade eller tekniskt krävande bergsleder som kräver att du är stadig på foten och inte höjdrädd.', + 'tours.planner.difficulty.alpineTitle': 'Aktivera alpin ruttberäkning?', + 'tours.planner.difficulty.alpineBody': + 'Alpin ruttberäkning är aktiverad. Rutter kan gå över exponerad, omarkerad eller tekniskt svår terräng. Svårighetsdata i OSM kan saknas eller vara felaktiga.', + 'tours.planner.difficulty.enable': 'Aktivera alpina rutter', + 'tours.planner.openFailed': 'Den sparade turen kunde inte öppnas för redigering.', + 'tours.planner.status.dirty': 'Väntar på beräkning', + 'tours.planner.status.routing': 'Beräknar vandringsrutt…', + 'tours.planner.status.routingFailed': 'Ruttberäkningen misslyckades. Den senaste giltiga rutten visas fortfarande.', + 'tours.planner.status.elevation': 'Läser in höjddata…', + 'tours.planner.status.elevationFailed': 'Höjddata är inte tillgängliga. Den beräknade rutten visas fortfarande.', + 'tours.planner.status.ready': 'Rutt och höjddata klara', + 'tours.planner.status.saving': 'Sparar…', + 'tours.planner.status.saveFailed': 'Turen kunde inte sparas. Ditt utkast finns kvar.', + 'tours.planner.retry': 'Försök igen', + 'tours.planner.inspector.title': 'Turöversikt', + 'tours.planner.inspector.empty': 'Ruttdetaljer visas här när två ruttpunkter har kopplats ihop.', + 'tours.planner.inspector.distance': 'Avstånd', + 'tours.planner.inspector.duration': 'Vandringstid', + 'tours.planner.inspector.elevation': 'Höjdprofil', + 'tours.planner.inspector.profileFocus': 'Vid {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Profilen visas när höjddata för rutten finns tillgängliga.', + 'tours.planner.gpxNoGeometry': 'Ruttgeometrin är inte tillgänglig för den här GPX-importen.', + 'tours.planner.mobileHint': + 'Turplanering är tillgänglig på surfplatta och dator. Här kan du visa sparade turer och tilldela dem till en dag.', + 'tourTypes.hike': 'Vandring', + 'tourTypes.bike': 'Cykeltur', + 'tourTypes.run': 'Löptur', + 'tourTypes.ski': 'Skidtur', + 'tourTypes.kayak': 'Kajaktur', + 'tourTypes.walk': 'Stadspromenad / sightseeing', + 'tourTypes.food': 'Mat- och vintur', +}; +export default tours; diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index d7fad9f621..73301a1465 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -71,6 +72,7 @@ const locale = { ...collection, ...atlas, ...trip, + ...tours, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts new file mode 100644 index 0000000000..04b98d3bbb --- /dev/null +++ b/shared/src/i18n/th/tours.ts @@ -0,0 +1,131 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'ทัวร์', + 'tours.mode.places': 'สถานที่', + 'tours.mode.tours': 'ทัวร์', + 'tours.mode.label': 'มุมมองการเพิ่ม', + 'tours.title': 'ทัวร์', + 'tours.subtitle': 'เดินป่าและทัวร์แบบวันเดียวอื่นๆ ในทริปนี้', + 'tours.empty.title': 'ยังไม่มีทัวร์', + 'tours.empty.body': 'วางแผนทัวร์หรือนำเข้าไฟล์ GPX เพื่อเริ่มต้น', + 'tours.addToDay': 'เพิ่มไปยังวัน', + 'tours.addToDay.comingSoon': 'เพิ่มทัวร์จากรายการลงในวันของทริป', + 'tours.addToDay.pickDay': 'เลือกวัน', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'นำเข้าจาก GPX', + 'tours.badge.planned': 'วางแผนใน TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'ใช้ด้วยความระมัดระวัง', + 'tours.caution.tooltip': 'ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน ใช้ข้อมูลที่นำเข้าด้วยความระมัดระวัง', + 'tours.import.caution': 'นำเข้าแล้ว แต่ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน โปรดใช้ด้วยความระมัดระวัง', + 'tours.import.success': 'นำเข้าทัวร์ {count} รายการจาก GPX แล้ว', + 'tours.import.successOne': 'นำเข้าทัวร์ 1 รายการจาก GPX แล้ว', + 'tours.import.noTrack': 'ไม่พบแทร็กหรือเส้นทางในไฟล์ GPX นี้', + 'tours.import.error': 'ไม่สามารถนำเข้าไฟล์ GPX ได้', + 'tours.delete.confirmBody': + 'ลบทัวร์นี้อย่างถาวรหรือไม่? เส้นทาง จุดแวะที่แก้ไขได้ และการจัดตารางลงวันจะถูกลบ การดำเนินการนี้ไม่สามารถยกเลิกได้', + 'tours.delete.bulkConfirmBody': + 'รายการที่เลือกมีทัวร์อย่างน้อยหนึ่งรายการ สถานที่ที่เลือกทั้งหมดจะถูกลบอย่างถาวรและกู้คืนไม่ได้ รวมถึงเส้นทาง จุดแวะที่แก้ไขได้ และการจัดตารางลงวันของทัวร์ด้วย', + 'tours.delete.confirmAction': 'ลบอย่างถาวร', + 'tours.addedToDay': 'เพิ่มไปยังวันที่ {n} แล้ว', + 'tours.dayRoute.endpointUnknown': 'ไม่สามารถระบุจุดสิ้นสุดของทัวร์ได้ เส้นทางเชื่อมต่อถัดไปอาจไม่ครบถ้วน', + 'tours.detail.trackColor': 'สีแทร็ก', + 'tours.detail.distance': 'ระยะทาง', + 'tours.detail.minAltitude': 'ระดับความสูงต่ำสุด', + 'tours.detail.maxAltitude': 'ระดับความสูงสูงสุด', + 'tours.detail.ascent': 'ความสูงสะสมขาขึ้น', + 'tours.detail.descent': 'ความสูงสะสมขาลง', + 'tours.detail.difficulty': 'ระดับความยาก', + 'tours.detail.source': 'แหล่งข้อมูล', + 'tours.detail.gpxReadOnly': 'ทัวร์ GPX ดูได้อย่างเดียว ยังแก้ไขในตัววางแผนไม่ได้', + 'tours.detail.navigationHint': 'การนำทางจะเปิดจุดเริ่มต้นทางเดิน ไม่ได้นำทางตามเส้นทางทัวร์', + 'tours.planner.title': 'วางแผนทัวร์', + 'tours.planner.newTour': 'วางแผนทัวร์ใหม่', + 'tours.planner.neutralTitle': 'วางแผนทัวร์', + 'tours.planner.neutralBody': 'เริ่มทัวร์ใหม่ แล้วคลิกแผนที่เพื่อวางจุดของเส้นทาง', + 'tours.planner.newTitle': 'ทัวร์ใหม่', + 'tours.planner.editTitle': 'แก้ไขทัวร์', + 'tours.planner.gpxTitle': 'ทัวร์ GPX', + 'tours.planner.backToTours': 'กลับไปที่ทัวร์', + 'tours.planner.restored': 'กู้คืนฉบับร่างที่ยังไม่บันทึกแล้ว', + 'tours.planner.discardChanges': 'ยกเลิกการเปลี่ยนแปลง', + 'tours.planner.discardUnsaved': 'ทิ้งการเปลี่ยนแปลงที่ยังไม่บันทึก', + 'tours.planner.saveChanges': 'บันทึกการเปลี่ยนแปลง', + 'tours.planner.newTourConfirmTitle': 'เริ่มทัวร์ใหม่ไหม?', + 'tours.planner.newTourConfirmBody': + 'การเปลี่ยนแปลงของทัวร์นี้ที่ยังไม่บันทึกจะถูกทิ้ง ทัวร์ที่บันทึกแล้วจะไม่เปลี่ยนแปลง', + 'tours.planner.newTourConfirm': 'ทิ้งการเปลี่ยนแปลงและเริ่มทัวร์ใหม่', + 'tours.planner.startingPoint': 'คลิกแผนที่เพื่อเพิ่มจุดเริ่มต้น', + 'tours.planner.firstUseDetails': + 'เพิ่มอีกจุดเพื่อคำนวณเส้นทาง เส้นทางและระดับความสูงจะอัปเดตอัตโนมัติ บันทึกเมื่อพร้อม', + 'tours.planner.collapseElevation': 'ยุบกราฟระดับความสูง', + 'tours.planner.expandElevation': 'ขยายกราฟระดับความสูง', + 'tours.planner.name': 'ชื่อทัวร์', + 'tours.planner.namePlaceholder': 'เส้นทางเดินป่าที่ยังไม่มีชื่อ', + 'tours.planner.mapHint': 'คลิกแผนที่เพื่อเพิ่มจุด', + 'tours.planner.waypoints': 'จุดแวะ', + 'tours.planner.waypointLabel': 'จุดแวะ {n}', + 'tours.durationMinutes': '{count} นาที', + 'tours.planner.empty': 'เพิ่มอย่างน้อยสองจุดเพื่อคำนวณเส้นทางเดิน', + 'tours.planner.start': 'เริ่มต้น', + 'tours.planner.via': 'ผ่าน', + 'tours.planner.end': 'สิ้นสุด', + 'tours.planner.moveUp': 'เลื่อนจุดแวะขึ้น', + 'tours.planner.moveDown': 'เลื่อนจุดแวะลง', + 'tours.planner.remove': 'ลบจุดแวะ', + 'tours.planner.undo': 'เลิกทำการแก้ไขจุดแวะ', + 'tours.planner.redo': 'ทำซ้ำการแก้ไขจุดแวะ', + 'tours.planner.discard': 'ทิ้งฉบับร่าง', + 'tours.planner.discardTitle': 'ทิ้งการเปลี่ยนแปลงทัวร์ที่ยังไม่บันทึกไหม?', + 'tours.planner.discardBody': + 'การแก้ไขที่ยังไม่บันทึกจะถูกทิ้ง ทัวร์ที่บันทึกแล้ว รวมถึงจุดแวะ ไฟล์ และการจัดลงวันจะไม่เปลี่ยนแปลง', + 'tours.planner.save': 'บันทึกทัวร์', + 'tours.planner.saved': 'บันทึกทัวร์แล้ว', + 'tours.planner.planAnother': 'วางแผนทัวร์อื่น', + 'tours.planner.assignToDay': 'เพิ่มไปยังวัน', + 'tours.planner.tripTours': 'ทัวร์ในทริปนี้', + 'tours.planner.safetyNote': 'การคำนวณเส้นทางเป็นตัวช่วยวางแผน ไม่ใช่การรับประกันความปลอดภัย', + 'tours.planner.safetyNoteDetails': + 'แท็กระดับความยากใน OSM อาจไม่มีหรือไม่ถูกต้อง ระบบไม่ได้ประเมินสภาพอากาศ การปิดเส้นทาง หรือสภาพพื้นที่ปัจจุบัน', + 'tours.planner.maxDifficulty': 'ระดับความยากสูงสุดของทางเดิน', + 'tours.planner.difficulty.t1': 'T1 - เดินป่า', + 'tours.planner.difficulty.t2': 'T2 - เดินป่าบนภูเขา', + 'tours.planner.difficulty.t3': 'T3 - เดินป่าบนภูเขาระดับท้าทาย', + 'tours.planner.difficulty.t4': 'T4 - เดินป่าแบบอัลไพน์', + 'tours.planner.difficulty.t5': 'T5 - เดินป่าแบบอัลไพน์ระดับท้าทาย', + 'tours.planner.difficulty.t6': 'T6 - เดินป่าแบบอัลไพน์ระดับยาก', + 'tours.planner.difficulty.t3Warning': + 'เส้นทาง T3 อาจมีทางเดินบนภูเขาที่เสี่ยงตกจากที่สูงหรือต้องใช้ทักษะทางเทคนิค ต้องก้าวเดินได้มั่นคงและไม่กลัวความสูง', + 'tours.planner.difficulty.alpineTitle': 'เปิดใช้การคำนวณเส้นทางอัลไพน์ไหม?', + 'tours.planner.difficulty.alpineBody': + 'เปิดใช้การคำนวณเส้นทางอัลไพน์แล้ว เส้นทางอาจผ่านพื้นที่ที่เสี่ยงตกจากที่สูง ไม่มีเครื่องหมายบอกทาง หรือต้องใช้ทักษะทางเทคนิคสูง ข้อมูลระดับความยากใน OSM อาจไม่มีหรือไม่ถูกต้อง', + 'tours.planner.difficulty.enable': 'เปิดใช้การคำนวณเส้นทางอัลไพน์', + 'tours.planner.openFailed': 'ไม่สามารถเปิดทัวร์ที่บันทึกไว้เพื่อแก้ไขได้', + 'tours.planner.status.dirty': 'รอคำนวณ', + 'tours.planner.status.routing': 'กำลังคำนวณเส้นทางเดิน...', + 'tours.planner.status.routingFailed': 'คำนวณเส้นทางไม่สำเร็จ ยังคงแสดงเส้นทางล่าสุดที่คำนวณสำเร็จ', + 'tours.planner.status.elevation': 'กำลังโหลดระดับความสูง...', + 'tours.planner.status.elevationFailed': 'ไม่มีข้อมูลระดับความสูง ยังคงแสดงเส้นทางที่คำนวณแล้ว', + 'tours.planner.status.ready': 'เส้นทางและระดับความสูงพร้อมแล้ว', + 'tours.planner.status.saving': 'กำลังบันทึก...', + 'tours.planner.status.saveFailed': 'ไม่สามารถบันทึกทัวร์ได้ ฉบับร่างยังอยู่', + 'tours.planner.retry': 'ลองอีกครั้ง', + 'tours.planner.inspector.title': 'สรุปทัวร์', + 'tours.planner.inspector.empty': 'รายละเอียดเส้นทางจะแสดงที่นี่เมื่อเชื่อมต่อจุดแวะสองจุดแล้ว', + 'tours.planner.inspector.distance': 'ระยะทาง', + 'tours.planner.inspector.duration': 'เวลาเดิน', + 'tours.planner.inspector.elevation': 'กราฟระดับความสูง', + 'tours.planner.inspector.profileFocus': 'ที่ระยะ {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'กราฟจะแสดงเมื่อมีข้อมูลระดับความสูงของเส้นทาง', + 'tours.planner.gpxNoGeometry': 'ไม่มีข้อมูลรูปทรงเส้นทางสำหรับทัวร์ที่นำเข้าจาก GPX นี้', + 'tours.planner.mobileHint': 'วางแผนทัวร์ได้บนแท็บเล็ตและคอมพิวเตอร์ ที่นี่สามารถดูทัวร์ที่บันทึกไว้และจัดลงวันได้', + 'tourTypes.hike': 'เดินป่า', + 'tourTypes.bike': 'ทัวร์จักรยาน', + 'tourTypes.run': 'วิ่ง', + 'tourTypes.ski': 'ทัวร์สกี', + 'tourTypes.kayak': 'พายเรือคายัก', + 'tourTypes.walk': 'เดินชมเมือง / สถานที่ท่องเที่ยว', + 'tourTypes.food': 'ทัวร์อาหาร/ไวน์', +}; +export default tours; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts new file mode 100644 index 0000000000..b6ce63443d --- /dev/null +++ b/shared/src/i18n/tours-locales.spec.ts @@ -0,0 +1,79 @@ +/// +/// +import { SUPPORTED_LANGUAGE_CODES } from './languages'; +import type { TranslationStrings } from './types'; + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const domains = import.meta.glob<{ default: TranslationStrings }>('./*/tours.ts', { eager: true }); +const aggregates = import.meta.glob<{ default: TranslationStrings }>('./*/index.ts', { eager: true }); +const englishModule = domains['./en/tours.ts']; +if (!englishModule) throw new Error('Missing English Tours domain'); +const english = englishModule.default; +const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) => match[0]).sort(); + +function literalKeys(source: string): string[] { + const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); + const keys: string[] = []; + function visit(node: ts.Node): void { + if (ts.isPropertyAssignment(node) && ts.isStringLiteral(node.name)) keys.push(node.name.text); + ts.forEachChild(node, visit); + } + visit(tree); + return keys; +} + +describe('Tours locale contracts', () => { + it('covers all 27 supported locales with 118 source keys', () => { + expect(Object.keys(domains)).toHaveLength(27); + expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort()); + expect(Object.keys(english)).toHaveLength(118); + }); + + for (const [path, { default: strings }] of Object.entries(domains)) { + it(`${path} imports, preserves placeholders and registers every key exactly once`, () => { + const domainSource = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); + const domainKeys = literalKeys(domainSource); + expect(domainKeys).toHaveLength(118); + expect(new Set(domainKeys).size).toBe(118); + expect(Object.keys(strings).sort()).toEqual(Object.keys(english).sort()); + expect(domainSource).not.toMatch(/export\s*\{\s*default\s*\}\s*from/); + const indexPath = path.replace('/tours.ts', '/index.ts'); + const indexSource = readFileSync(fileURLToPath(new URL(indexPath, import.meta.url)), 'utf8'); + const indexTree = ts.createSourceFile(indexPath, indexSource, ts.ScriptTarget.Latest, true); + expect(indexSource.match(/import tours from ['"]\.\/tours['"]/g)).toHaveLength(1); + expect(indexSource.match(/\.\.\.tours\b/g)).toHaveLength(1); + const registeredKeys: string[] = []; + for (const statement of indexTree.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + const relativePath = statement.moduleSpecifier.text; + if (!relativePath.startsWith('./')) continue; + const domainUrl = new URL(`${relativePath}.ts`, new URL(indexPath, import.meta.url)); + registeredKeys.push(...literalKeys(readFileSync(fileURLToPath(domainUrl), 'utf8'))); + } + for (const [key, reference] of Object.entries(english)) { + expect(typeof strings[key], key).toBe('string'); + const value = strings[key] as string; + expect(value.trim(), key).not.toBe(''); + expect(value, key).not.toBe(key); + expect(tokens(value), key).toEqual(tokens(reference as string)); + expect( + registeredKeys.filter((registered) => registered === key), + key, + ).toHaveLength(1); + expect(aggregates[indexPath]?.default[key], key).toBe(value); + } + for (const key of [ + 'tours.planner.safetyNote', + 'tours.planner.safetyNoteDetails', + 'tours.planner.difficulty.t3Warning', + 'tours.planner.difficulty.alpineBody', + ]) { + expect(strings[key], key).toBeTruthy(); + } + }); + } +}); diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts new file mode 100644 index 0000000000..77019bbf71 --- /dev/null +++ b/shared/src/i18n/tr/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Turlar', + 'tours.mode.places': 'Yerler', + 'tours.mode.tours': 'Turlar', + 'tours.mode.label': 'Ekleme görünümü', + 'tours.title': 'Turlar', + 'tours.subtitle': 'Bu seyahatteki doğa yürüyüşleri ve diğer günübirlik turlar', + 'tours.empty.title': 'Henüz tur yok', + 'tours.empty.body': 'Başlamak için bir tur planlayın veya GPX dosyası içe aktarın.', + 'tours.addToDay': 'Güne ekle', + 'tours.addToDay.comingSoon': 'Tur listesinden turları seyahatin bir gününe atayın.', + 'tours.addToDay.pickDay': 'Gün seçin', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX içe aktarımı', + 'tours.badge.planned': 'TREK ile planlandı', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'dikkatli kullanın', + 'tours.caution.tooltip': 'Yükseklik verileri veya ölçümler eksik – çekinceyle içe aktarıldı', + 'tours.import.caution': 'Çekinceyle içe aktarıldı – yükseklik verileri veya ölçümler eksik', + 'tours.import.success': 'GPX dosyasından içe aktarılan tur sayısı: {count}', + 'tours.import.successOne': 'GPX dosyasından 1 tur içe aktarıldı', + 'tours.import.noTrack': 'Bu GPX dosyasında iz veya rota bulunamadı', + 'tours.import.error': 'GPX dosyası içe aktarılamadı', + 'tours.delete.confirmBody': + 'Bu tur kalıcı olarak silinsin mi? Rotası, düzenlenebilir ara noktaları ve gün atamaları kaldırılacak. Bu işlem geri alınamaz.', + 'tours.delete.bulkConfirmBody': + 'Bu seçim en az bir tur içeriyor. Seçilen tüm yerler kalıcı olarak silinecek ve geri yüklenemeyecek. Turların rotaları, düzenlenebilir ara noktaları ve gün atamaları da kaldırılacak.', + 'tours.delete.confirmAction': 'Kalıcı olarak sil', + 'tours.addedToDay': '{n}. güne eklendi', + 'tours.dayRoute.endpointUnknown': 'Turun bitiş noktası belirlenemedi. Sonraki bağlantı bölümleri eksik olabilir.', + 'tours.detail.trackColor': 'İz rengi', + 'tours.detail.distance': 'Mesafe', + 'tours.detail.minAltitude': 'En düşük rakım', + 'tours.detail.maxAltitude': 'En yüksek rakım', + 'tours.detail.ascent': 'Toplam yükselme', + 'tours.detail.descent': 'Toplam alçalma', + 'tours.detail.difficulty': 'Zorluk', + 'tours.detail.source': 'Kaynak', + 'tours.detail.gpxReadOnly': 'GPX turları yalnızca görüntülenebilir; henüz planlayıcıda düzenlenemez.', + 'tours.detail.navigationHint': 'Navigasyon yürüyüşün başlangıç noktasını açar; rota boyunca yol tarifi vermez.', + 'tours.planner.title': 'Tur planla', + 'tours.planner.newTour': 'Yeni tur planla', + 'tours.planner.neutralTitle': 'Tur planla', + 'tours.planner.neutralBody': 'Yeni bir tur başlatın ve noktalarını yerleştirmek için haritaya tıklayın.', + 'tours.planner.newTitle': 'Yeni tur', + 'tours.planner.editTitle': 'Turu düzenle', + 'tours.planner.gpxTitle': 'GPX turu', + 'tours.planner.backToTours': 'Turlara dön', + 'tours.planner.restored': 'Kaydedilmemiş taslak geri yüklendi', + 'tours.planner.discardChanges': 'Değişiklikleri sil', + 'tours.planner.discardUnsaved': 'Kaydedilmemiş değişiklikleri sil', + 'tours.planner.saveChanges': 'Değişiklikleri kaydet', + 'tours.planner.newTourConfirmTitle': 'Yeni bir tur başlatılsın mı?', + 'tours.planner.newTourConfirmBody': + 'Bu turdaki kaydedilmemiş değişiklikler silinecek. Kaydedilmiş turlar değişmeyecek.', + 'tours.planner.newTourConfirm': 'Değişiklikleri sil ve yeni tur başlat', + 'tours.planner.startingPoint': 'Başlangıç noktasını eklemek için haritaya tıklayın.', + 'tours.planner.firstUseDetails': + 'Rotayı hesaplamak için bir nokta daha ekleyin. Rota ve yükseklik verileri otomatik güncellenir; hazır olduğunda kaydedin.', + 'tours.planner.collapseElevation': 'Yükseklik profilini daralt', + 'tours.planner.expandElevation': 'Yükseklik profilini genişlet', + 'tours.planner.name': 'Tur adı', + 'tours.planner.namePlaceholder': 'Adsız doğa yürüyüşü', + 'tours.planner.mapHint': 'Nokta eklemek için haritaya tıklayın.', + 'tours.planner.waypoints': 'Rota noktaları', + 'tours.planner.waypointLabel': 'Rota noktası {n}', + 'tours.durationMinutes': '{count} dk', + 'tours.planner.empty': 'Yürüyüş rotasını hesaplamak için en az iki nokta ekleyin.', + 'tours.planner.start': 'Başlangıç', + 'tours.planner.via': 'Ara nokta', + 'tours.planner.end': 'Bitiş', + 'tours.planner.moveUp': 'Rota noktasını yukarı taşı', + 'tours.planner.moveDown': 'Rota noktasını aşağı taşı', + 'tours.planner.remove': 'Rota noktasını kaldır', + 'tours.planner.undo': 'Nokta düzenlemesini geri al', + 'tours.planner.redo': 'Nokta düzenlemesini yinele', + 'tours.planner.discard': 'Taslağı sil', + 'tours.planner.discardTitle': 'Kaydedilmemiş tur değişiklikleri silinsin mi?', + 'tours.planner.discardBody': + 'Kaydedilmemiş düzenlemeler silinecek. Kaydedilmiş tur, rota noktaları, dosyaları ve gün atamaları değişmeyecek.', + 'tours.planner.save': 'Turu kaydet', + 'tours.planner.saved': 'Tur kaydedildi', + 'tours.planner.planAnother': 'Başka tur planla', + 'tours.planner.assignToDay': 'Güne ata', + 'tours.planner.tripTours': 'Bu seyahatin turları', + 'tours.planner.safetyNote': 'Rota hesaplama planlamaya yardımcı olur, güvenlik garantisi vermez.', + 'tours.planner.safetyNoteDetails': + 'OSM zorluk etiketleri eksik veya hatalı olabilir. Hava durumu, kapalı yollar ve güncel koşullar değerlendirilmez.', + 'tours.planner.maxDifficulty': 'En yüksek patika zorluğu', + 'tours.planner.difficulty.t1': 'T1 – Doğa yürüyüşü', + 'tours.planner.difficulty.t2': 'T2 – Dağ yürüyüşü', + 'tours.planner.difficulty.t3': 'T3 – Zorlu dağ yürüyüşü', + 'tours.planner.difficulty.t4': 'T4 – Yüksek dağ yürüyüşü', + 'tours.planner.difficulty.t5': 'T5 – Zorlu yüksek dağ yürüyüşü', + 'tours.planner.difficulty.t6': 'T6 – Çok zor yüksek dağ yürüyüşü', + 'tours.planner.difficulty.t3Warning': + 'T3 rotaları, sağlam adım atmayı ve yükseklik korkusu olmamasını gerektiren, uçurum kenarında açıkta kalan veya teknik açıdan zorlu dağ patikaları içerebilir.', + 'tours.planner.difficulty.alpineTitle': 'Yüksek dağ rotaları etkinleştirilsin mi?', + 'tours.planner.difficulty.alpineBody': + 'Yüksek dağ rotaları etkinleştirildi. Rotalar uçurum kenarında açıkta kalan, işaretsiz veya teknik açıdan zor araziden geçebilir. OSM zorluk verileri eksik veya hatalı olabilir.', + 'tours.planner.difficulty.enable': 'Yüksek dağ rotalarını etkinleştir', + 'tours.planner.openFailed': 'Kaydedilmiş tur düzenleme için açılamadı.', + 'tours.planner.status.dirty': 'Hesaplama bekleniyor', + 'tours.planner.status.routing': 'Yürüyüş rotası hesaplanıyor…', + 'tours.planner.status.routingFailed': 'Rota hesaplanamadı. Son başarılı rota hâlâ gösteriliyor.', + 'tours.planner.status.elevation': 'Yükseklik verileri yükleniyor…', + 'tours.planner.status.elevationFailed': 'Yükseklik verileri kullanılamıyor. Hesaplanan rota hâlâ gösteriliyor.', + 'tours.planner.status.ready': 'Rota ve yükseklik verileri hazır', + 'tours.planner.status.saving': 'Kaydediliyor…', + 'tours.planner.status.saveFailed': 'Tur kaydedilemedi. Taslağınız hâlâ burada.', + 'tours.planner.retry': 'Yeniden dene', + 'tours.planner.inspector.title': 'Tur özeti', + 'tours.planner.inspector.empty': 'İki rota noktası bağlandığında rota ayrıntıları burada görünür.', + 'tours.planner.inspector.distance': 'Mesafe', + 'tours.planner.inspector.duration': 'Yürüyüş süresi', + 'tours.planner.inspector.elevation': 'Yükseklik profili', + 'tours.planner.inspector.profileFocus': '{distance} mesafede: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': + 'Rotanın yükseklik verileri kullanılabilir olduğunda profil gösterilir.', + 'tours.planner.gpxNoGeometry': 'Bu GPX içe aktarımı için rota geometrisi kullanılamıyor.', + 'tours.planner.mobileHint': + 'Tur planlama tablet ve bilgisayarda kullanılabilir. Burada kaydedilmiş turları görüntüleyebilir ve günlere atayabilirsiniz.', + 'tourTypes.hike': 'Doğa yürüyüşü', + 'tourTypes.bike': 'Bisiklet turu', + 'tourTypes.run': 'Koşu', + 'tourTypes.ski': 'Kayak turu', + 'tourTypes.kayak': 'Kayak kanosu', + 'tourTypes.walk': 'Şehir / gezi yürüyüşü', + 'tourTypes.food': 'Yemek / şarap turu', +}; +export default tours; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts new file mode 100644 index 0000000000..e1ad31971a --- /dev/null +++ b/shared/src/i18n/uk/tours.ts @@ -0,0 +1,133 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Тури', + 'tours.mode.places': 'Місця', + 'tours.mode.tours': 'Тури', + 'tours.mode.label': 'Режим додавання', + 'tours.title': 'Тури', + 'tours.subtitle': 'Походи та інші одноденні тури під час цієї подорожі', + 'tours.empty.title': 'Турів поки немає', + 'tours.empty.body': 'Для початку сплануйте тур або імпортуйте файл GPX.', + 'tours.addToDay': 'Додати до дня', + 'tours.addToDay.comingSoon': 'Призначайте тури на дні подорожі зі списку турів.', + 'tours.addToDay.pickDay': 'Виберіть день', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Імпорт GPX', + 'tours.badge.planned': 'сплановано в TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'використовуйте обережно', + 'tours.caution.tooltip': 'Дані про висоту або показники неповні — імпорт із застереженнями', + 'tours.import.caution': 'Імпорт із застереженнями — дані про висоту або показники неповні', + 'tours.import.success': 'Імпортовано турів із GPX: {count}', + 'tours.import.successOne': 'Імпортовано 1 тур із GPX', + 'tours.import.noTrack': 'У цьому файлі GPX не знайдено треку або маршруту', + 'tours.import.error': 'Не вдалося імпортувати файл GPX', + 'tours.delete.confirmBody': + 'Видалити цей маршрут назавжди? Його шлях, редаговані точки та призначення на дні буде видалено. Цю дію не можна скасувати.', + 'tours.delete.bulkConfirmBody': + 'Вибір містить щонайменше один маршрут. Усі вибрані місця буде видалено назавжди, і їх не можна буде відновити. Шляхи маршрутів, редаговані точки та призначення на дні також буде видалено.', + 'tours.delete.confirmAction': 'Видалити назавжди', + 'tours.addedToDay': 'Додано до дня {n}', + 'tours.dayRoute.endpointUnknown': + 'Не вдалося визначити кінцеву точку туру. Наступні з’єднувальні ділянки можуть бути неповними.', + 'tours.detail.trackColor': 'Колір треку', + 'tours.detail.distance': 'Відстань', + 'tours.detail.minAltitude': 'Мінімальна висота', + 'tours.detail.maxAltitude': 'Максимальна висота', + 'tours.detail.ascent': 'Набір висоти', + 'tours.detail.descent': 'Втрата висоти', + 'tours.detail.difficulty': 'Складність', + 'tours.detail.source': 'Джерело', + 'tours.detail.gpxReadOnly': 'Тури GPX доступні лише для перегляду; редагувати їх у планувальнику поки не можна.', + 'tours.detail.navigationHint': 'Навігація відкриває початкову точку стежки, але не веде вздовж маршруту.', + 'tours.planner.title': 'Спланувати тур', + 'tours.planner.newTour': 'Спланувати новий тур', + 'tours.planner.neutralTitle': 'Спланувати тур', + 'tours.planner.neutralBody': 'Почніть новий маршрут і натискайте на карту, щоб розставити точки.', + 'tours.planner.newTitle': 'Новий тур', + 'tours.planner.editTitle': 'Редагувати тур', + 'tours.planner.gpxTitle': 'Тур GPX', + 'tours.planner.backToTours': 'До турів', + 'tours.planner.restored': 'Незбережену чернетку відновлено', + 'tours.planner.discardChanges': 'Скасувати зміни', + 'tours.planner.discardUnsaved': 'Скасувати незбережені зміни', + 'tours.planner.saveChanges': 'Зберегти зміни', + 'tours.planner.newTourConfirmTitle': 'Почати новий тур?', + 'tours.planner.newTourConfirmBody': 'Незбережені зміни цього туру буде скасовано. Збережені тури не зміняться.', + 'tours.planner.newTourConfirm': 'Скасувати зміни й почати новий тур', + 'tours.planner.startingPoint': 'Натисніть на карту, щоб додати початкову точку.', + 'tours.planner.firstUseDetails': + 'Додайте ще одну точку для розрахунку маршруту. Маршрут і висоти оновлюються автоматично; збережіть тур, коли він буде готовий.', + 'tours.planner.collapseElevation': 'Згорнути профіль висот', + 'tours.planner.expandElevation': 'Розгорнути профіль висот', + 'tours.planner.name': 'Назва туру', + 'tours.planner.namePlaceholder': 'Похід без назви', + 'tours.planner.mapHint': 'Натисніть на карту, щоб додати точки.', + 'tours.planner.waypoints': 'Маршрутні точки', + 'tours.planner.waypointLabel': 'Маршрутна точка {n}', + 'tours.durationMinutes': '{count} хв', + 'tours.planner.empty': 'Додайте щонайменше дві точки для розрахунку пішого маршруту.', + 'tours.planner.start': 'Початок', + 'tours.planner.via': 'Через', + 'tours.planner.end': 'Кінець', + 'tours.planner.moveUp': 'Перемістити точку вгору', + 'tours.planner.moveDown': 'Перемістити точку вниз', + 'tours.planner.remove': 'Видалити точку', + 'tours.planner.undo': 'Скасувати зміну точки', + 'tours.planner.redo': 'Повторити зміну точки', + 'tours.planner.discard': 'Видалити чернетку', + 'tours.planner.discardTitle': 'Скасувати незбережені зміни туру?', + 'tours.planner.discardBody': + 'Незбережені зміни буде скасовано. Збережений тур, його точки, файли та призначення на дні не зміняться.', + 'tours.planner.save': 'Зберегти тур', + 'tours.planner.saved': 'Тур збережено', + 'tours.planner.planAnother': 'Спланувати ще', + 'tours.planner.assignToDay': 'Призначити на день', + 'tours.planner.tripTours': 'Тури цієї подорожі', + 'tours.planner.safetyNote': 'Розрахунок маршруту допомагає в плануванні, але не гарантує безпеки.', + 'tours.planner.safetyNoteDetails': + 'Теги складності OSM можуть бути відсутні або хибні. Погода, закриті ділянки й поточні умови не оцінюються.', + 'tours.planner.maxDifficulty': 'Максимальна складність стежки', + 'tours.planner.difficulty.t1': 'T1 — Піший похід', + 'tours.planner.difficulty.t2': 'T2 — Гірський похід', + 'tours.planner.difficulty.t3': 'T3 — Складний гірський похід', + 'tours.planner.difficulty.t4': 'T4 — Високогірний похід', + 'tours.planner.difficulty.t5': 'T5 — Складний високогірний похід', + 'tours.planner.difficulty.t6': 'T6 — Дуже складний високогірний похід', + 'tours.planner.difficulty.t3Warning': + 'Маршрути T3 можуть містити відкриті ділянки над урвищами або технічно складні гірські стежки, які потребують впевненого кроку та відсутності страху висоти.', + 'tours.planner.difficulty.alpineTitle': 'Дозволити високогірні маршрути?', + 'tours.planner.difficulty.alpineBody': + 'Високогірні маршрути дозволено. Вони можуть проходити відкритими ділянками над урвищами, немаркованою або технічно складною місцевістю. Дані про складність в OSM можуть бути відсутні або хибні.', + 'tours.planner.difficulty.enable': 'Дозволити високогірні маршрути', + 'tours.planner.openFailed': 'Не вдалося відкрити збережений тур для редагування.', + 'tours.planner.status.dirty': 'Очікування розрахунку', + 'tours.planner.status.routing': 'Розрахунок пішого маршруту…', + 'tours.planner.status.routingFailed': + 'Не вдалося розрахувати маршрут. Далі відображається останній успішно розрахований маршрут.', + 'tours.planner.status.elevation': 'Завантаження висот…', + 'tours.planner.status.elevationFailed': 'Дані про висоту недоступні. Розрахований маршрут далі відображається.', + 'tours.planner.status.ready': 'Маршрут і висоти готові', + 'tours.planner.status.saving': 'Збереження…', + 'tours.planner.status.saveFailed': 'Не вдалося зберегти тур. Чернетка залишилася доступною.', + 'tours.planner.retry': 'Повторити', + 'tours.planner.inspector.title': 'Огляд туру', + 'tours.planner.inspector.empty': 'Відомості про маршрут з’являться тут після з’єднання двох маршрутних точок.', + 'tours.planner.inspector.distance': 'Відстань', + 'tours.planner.inspector.duration': 'Час ходьби', + 'tours.planner.inspector.elevation': 'Профіль висот', + 'tours.planner.inspector.profileFocus': 'На відстані {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Профіль з’явиться, коли будуть доступні дані про висоту маршруту.', + 'tours.planner.gpxNoGeometry': 'Геометрія маршруту недоступна для цього імпорту GPX.', + 'tours.planner.mobileHint': + 'Планування турів доступне на планшеті й комп’ютері. Тут можна переглядати збережені тури та призначати їх на дні.', + 'tourTypes.hike': 'Піший похід', + 'tourTypes.bike': 'Велотур', + 'tourTypes.run': 'Пробіжка', + 'tourTypes.ski': 'Лижний тур', + 'tourTypes.kayak': 'Каякінг', + 'tourTypes.walk': 'Прогулянка містом / визначними місцями', + 'tourTypes.food': 'Гастрономічний / винний тур', +}; +export default tours; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts new file mode 100644 index 0000000000..89b4e16dd5 --- /dev/null +++ b/shared/src/i18n/vi/tours.ts @@ -0,0 +1,134 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': 'Chuyến tham quan', + 'tours.mode.places': 'Địa điểm', + 'tours.mode.tours': 'Chuyến tham quan', + 'tours.mode.label': 'Chế độ thêm', + 'tours.title': 'Chuyến tham quan', + 'tours.subtitle': 'Các chuyến đi bộ đường dài và chuyến tham quan trong ngày của chuyến đi này', + 'tours.empty.title': 'Chưa có chuyến tham quan', + 'tours.empty.body': 'Lên kế hoạch cho một chuyến tham quan hoặc nhập tệp GPX để bắt đầu.', + 'tours.addToDay': 'Thêm vào ngày', + 'tours.addToDay.comingSoon': 'Gán chuyến tham quan vào một ngày của chuyến đi từ danh sách chuyến tham quan.', + 'tours.addToDay.pickDay': 'Chọn ngày', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'Nhập từ GPX', + 'tours.badge.planned': 'lên kế hoạch trong TREK', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': 'cần thận trọng khi sử dụng', + 'tours.caution.tooltip': 'Dữ liệu độ cao hoặc thông số chưa đầy đủ; đã nhập kèm cảnh báo', + 'tours.import.caution': 'Đã nhập kèm cảnh báo; dữ liệu độ cao hoặc thông số chưa đầy đủ', + 'tours.import.success': 'Đã nhập {count} chuyến tham quan từ GPX', + 'tours.import.successOne': 'Đã nhập 1 chuyến tham quan từ GPX', + 'tours.import.noTrack': 'Không tìm thấy vệt hành trình hoặc tuyến đường trong tệp GPX này', + 'tours.import.error': 'Không thể nhập tệp GPX', + 'tours.delete.confirmBody': + 'Xóa vĩnh viễn chuyến đi này? Tuyến đường, các điểm trung gian có thể chỉnh sửa và lịch gán ngày sẽ bị xóa. Không thể hoàn tác thao tác này.', + 'tours.delete.bulkConfirmBody': + 'Lựa chọn này có ít nhất một chuyến đi. Tất cả địa điểm đã chọn sẽ bị xóa vĩnh viễn và không thể khôi phục. Tuyến đường, điểm trung gian có thể chỉnh sửa và lịch gán ngày của các chuyến đi cũng sẽ bị xóa.', + 'tours.delete.confirmAction': 'Xóa vĩnh viễn', + 'tours.addedToDay': 'Đã thêm vào ngày {n}', + 'tours.dayRoute.endpointUnknown': + 'Không xác định được điểm kết thúc của chuyến tham quan. Các đoạn nối tiếp theo có thể chưa đầy đủ.', + 'tours.detail.trackColor': 'Màu vệt hành trình', + 'tours.detail.distance': 'Quãng đường', + 'tours.detail.minAltitude': 'Độ cao thấp nhất', + 'tours.detail.maxAltitude': 'Độ cao cao nhất', + 'tours.detail.ascent': 'Tổng độ cao leo lên', + 'tours.detail.descent': 'Tổng độ cao đi xuống', + 'tours.detail.difficulty': 'Độ khó', + 'tours.detail.source': 'Nguồn', + 'tours.detail.gpxReadOnly': 'Chuyến tham quan GPX chỉ có thể xem, chưa thể chỉnh sửa trong công cụ lập kế hoạch.', + 'tours.detail.navigationHint': 'Chỉ đường sẽ mở điểm bắt đầu đường mòn, không hướng dẫn đi dọc theo tuyến đường.', + 'tours.planner.title': 'Lên kế hoạch tham quan', + 'tours.planner.newTour': 'Lên kế hoạch tham quan mới', + 'tours.planner.neutralTitle': 'Lên kế hoạch tham quan', + 'tours.planner.neutralBody': 'Bắt đầu chuyến đi mới và nhấp vào bản đồ để đặt các điểm.', + 'tours.planner.newTitle': 'Chuyến tham quan mới', + 'tours.planner.editTitle': 'Chỉnh sửa chuyến tham quan', + 'tours.planner.gpxTitle': 'Chuyến tham quan GPX', + 'tours.planner.backToTours': 'Về danh sách chuyến tham quan', + 'tours.planner.restored': 'Đã khôi phục bản nháp chưa lưu', + 'tours.planner.discardChanges': 'Bỏ thay đổi', + 'tours.planner.discardUnsaved': 'Bỏ thay đổi chưa lưu', + 'tours.planner.saveChanges': 'Lưu thay đổi', + 'tours.planner.newTourConfirmTitle': 'Bắt đầu chuyến tham quan mới?', + 'tours.planner.newTourConfirmBody': + 'Các thay đổi chưa lưu của chuyến tham quan này sẽ bị bỏ. Các chuyến tham quan đã lưu sẽ không thay đổi.', + 'tours.planner.newTourConfirm': 'Bỏ thay đổi và bắt đầu chuyến tham quan mới', + 'tours.planner.startingPoint': 'Nhấp vào bản đồ để thêm điểm xuất phát.', + 'tours.planner.firstUseDetails': + 'Thêm một điểm nữa để tính tuyến đường. Tuyến đường và độ cao được cập nhật tự động; hãy lưu khi hoàn tất.', + 'tours.planner.collapseElevation': 'Thu gọn biểu đồ độ cao', + 'tours.planner.expandElevation': 'Mở rộng biểu đồ độ cao', + 'tours.planner.name': 'Tên chuyến tham quan', + 'tours.planner.namePlaceholder': 'Chuyến đi bộ đường dài chưa có tên', + 'tours.planner.mapHint': 'Nhấp vào bản đồ để thêm điểm.', + 'tours.planner.waypoints': 'Các điểm trên tuyến', + 'tours.planner.waypointLabel': 'Điểm {n}', + 'tours.durationMinutes': '{count} phút', + 'tours.planner.empty': 'Thêm ít nhất hai điểm để tính tuyến đi bộ.', + 'tours.planner.start': 'Xuất phát', + 'tours.planner.via': 'Đi qua', + 'tours.planner.end': 'Kết thúc', + 'tours.planner.moveUp': 'Di chuyển điểm lên trên', + 'tours.planner.moveDown': 'Di chuyển điểm xuống dưới', + 'tours.planner.remove': 'Xóa điểm', + 'tours.planner.undo': 'Hoàn tác chỉnh sửa điểm', + 'tours.planner.redo': 'Làm lại chỉnh sửa điểm', + 'tours.planner.discard': 'Bỏ bản nháp', + 'tours.planner.discardTitle': 'Bỏ các thay đổi chưa lưu của chuyến tham quan?', + 'tours.planner.discardBody': + 'Các chỉnh sửa chưa lưu sẽ bị bỏ. Chuyến tham quan đã lưu cùng các điểm, tệp và ngày được gán sẽ không thay đổi.', + 'tours.planner.save': 'Lưu chuyến tham quan', + 'tours.planner.saved': 'Đã lưu chuyến tham quan', + 'tours.planner.planAnother': 'Lên kế hoạch chuyến khác', + 'tours.planner.assignToDay': 'Gán vào ngày', + 'tours.planner.tripTours': 'Các chuyến tham quan của chuyến đi này', + 'tours.planner.safetyNote': 'Việc tính tuyến đường chỉ hỗ trợ lập kế hoạch, không bảo đảm an toàn.', + 'tours.planner.safetyNoteDetails': + 'Nhãn độ khó trên OSM có thể bị thiếu hoặc sai. Thời tiết, việc đóng đường và điều kiện hiện tại không được đánh giá.', + 'tours.planner.maxDifficulty': 'Độ khó tối đa của đường mòn', + 'tours.planner.difficulty.t1': 'T1 - Đi bộ đường dài', + 'tours.planner.difficulty.t2': 'T2 - Đi bộ đường dài trên núi', + 'tours.planner.difficulty.t3': 'T3 - Đi bộ đường dài trên núi đòi hỏi kỹ năng cao', + 'tours.planner.difficulty.t4': 'T4 - Đi bộ đường dài trên núi cao', + 'tours.planner.difficulty.t5': 'T5 - Đi bộ đường dài trên núi cao đòi hỏi kỹ năng cao', + 'tours.planner.difficulty.t6': 'T6 - Đi bộ đường dài trên núi cao rất khó', + 'tours.planner.difficulty.t3Warning': + 'Tuyến T3 có thể có đường mòn trên núi ở vị trí cheo leo, có nguy cơ rơi ngã hoặc đòi hỏi kỹ thuật cao. Cần bước chân vững vàng và khả năng giữ bình tĩnh ở độ cao lớn.', + 'tours.planner.difficulty.alpineTitle': 'Bật tính tuyến đường núi cao?', + 'tours.planner.difficulty.alpineBody': + 'Tính tuyến đường núi cao được bật. Tuyến có thể đi qua địa hình cheo leo có nguy cơ rơi ngã, không có dấu chỉ đường hoặc khó về kỹ thuật. Dữ liệu độ khó trên OSM có thể bị thiếu hoặc sai.', + 'tours.planner.difficulty.enable': 'Bật tính tuyến đường núi cao', + 'tours.planner.openFailed': 'Không thể mở chuyến tham quan đã lưu để chỉnh sửa.', + 'tours.planner.status.dirty': 'Đang chờ tính toán', + 'tours.planner.status.routing': 'Đang tính tuyến đi bộ...', + 'tours.planner.status.routingFailed': + 'Tính tuyến đường thất bại. Tuyến được tính thành công gần nhất vẫn được hiển thị.', + 'tours.planner.status.elevation': 'Đang tải độ cao...', + 'tours.planner.status.elevationFailed': 'Không có dữ liệu độ cao. Tuyến đường đã tính vẫn được hiển thị.', + 'tours.planner.status.ready': 'Tuyến đường và độ cao đã sẵn sàng', + 'tours.planner.status.saving': 'Đang lưu...', + 'tours.planner.status.saveFailed': 'Không thể lưu chuyến tham quan. Bản nháp vẫn được giữ lại.', + 'tours.planner.retry': 'Thử lại', + 'tours.planner.inspector.title': 'Tóm tắt chuyến tham quan', + 'tours.planner.inspector.empty': 'Chi tiết tuyến đường sẽ xuất hiện ở đây sau khi hai điểm được nối với nhau.', + 'tours.planner.inspector.distance': 'Quãng đường', + 'tours.planner.inspector.duration': 'Thời gian đi bộ', + 'tours.planner.inspector.elevation': 'Biểu đồ độ cao', + 'tours.planner.inspector.profileFocus': 'Tại {distance}: {elevation}', + 'tours.planner.inspector.elevationPlaceholder': 'Biểu đồ sẽ hiển thị khi có dữ liệu độ cao của tuyến đường.', + 'tours.planner.gpxNoGeometry': 'Không có dữ liệu hình dạng tuyến đường cho bản nhập GPX này.', + 'tours.planner.mobileHint': + 'Có thể lập kế hoạch tham quan trên máy tính bảng và máy tính. Tại đây, bạn có thể xem và xếp lịch cho các chuyến tham quan đã lưu.', + 'tourTypes.hike': 'Đi bộ đường dài', + 'tourTypes.bike': 'Chuyến đạp xe', + 'tourTypes.run': 'Chạy bộ', + 'tourTypes.ski': 'Chuyến trượt tuyết', + 'tourTypes.kayak': 'Chèo kayak', + 'tourTypes.walk': 'Đi bộ trong thành phố / ngắm cảnh', + 'tourTypes.food': 'Chuyến khám phá ẩm thực / rượu vang', +}; +export default tours; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index b51d0de98d..17117e8574 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts new file mode 100644 index 0000000000..b92ef4b0f9 --- /dev/null +++ b/shared/src/i18n/zh-TW/tours.ts @@ -0,0 +1,126 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': '遊程', + 'tours.mode.places': '地點', + 'tours.mode.tours': '遊程', + 'tours.mode.label': '新增檢視', + 'tours.title': '遊程', + 'tours.subtitle': '這趟旅行的健行與其他一日遊活動', + 'tours.empty.title': '尚無遊程', + 'tours.empty.body': '規劃遊程或匯入 GPX 檔案,即可開始。', + 'tours.addToDay': '加入某一天', + 'tours.addToDay.comingSoon': '從遊程清單將活動安排到旅行的某一天。', + 'tours.addToDay.pickDay': '選擇日期', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX 匯入', + 'tours.badge.planned': '在 TREK 中規劃', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': '請謹慎使用', + 'tours.caution.tooltip': '海拔或指標資料不完整,已附警告匯入', + 'tours.import.caution': '已附警告匯入,海拔或指標資料不完整', + 'tours.import.success': '已從 GPX 匯入 {count} 個遊程', + 'tours.import.successOne': '已從 GPX 匯入 1 個遊程', + 'tours.import.noTrack': '這個 GPX 檔案中找不到軌跡或路線', + 'tours.import.error': '無法匯入 GPX 檔案', + 'tours.delete.confirmBody': '要永久刪除此行程嗎?其路線、可編輯的途經點和日期指派都會一併移除。此操作無法復原。', + 'tours.delete.bulkConfirmBody': + '所選內容至少包含一個行程。所有選取的地點都會永久刪除且無法復原;行程路線、可編輯的途經點和日期指派也會一併移除。', + 'tours.delete.confirmAction': '永久刪除', + 'tours.addedToDay': '已加入第 {n} 天', + 'tours.dayRoute.endpointUnknown': '無法判定遊程的終點。後續銜接路線可能不完整。', + 'tours.detail.trackColor': '軌跡顏色', + 'tours.detail.distance': '距離', + 'tours.detail.minAltitude': '最低海拔', + 'tours.detail.maxAltitude': '最高海拔', + 'tours.detail.ascent': '累計爬升', + 'tours.detail.descent': '累計下降', + 'tours.detail.difficulty': '難度', + 'tours.detail.source': '來源', + 'tours.detail.gpxReadOnly': 'GPX 遊程僅供檢視,目前還無法在規劃工具中編輯。', + 'tours.detail.navigationHint': '導航會開啟登山口或步道入口,不會沿著遊程路線提供導航。', + 'tours.planner.title': '規劃遊程', + 'tours.planner.newTour': '規劃新遊程', + 'tours.planner.neutralTitle': '規劃遊程', + 'tours.planner.neutralBody': '開始新路線,點選地圖放置路線點。', + 'tours.planner.newTitle': '新遊程', + 'tours.planner.editTitle': '編輯遊程', + 'tours.planner.gpxTitle': 'GPX 遊程', + 'tours.planner.backToTours': '返回遊程清單', + 'tours.planner.restored': '已還原尚未儲存的草稿', + 'tours.planner.discardChanges': '捨棄變更', + 'tours.planner.discardUnsaved': '捨棄尚未儲存的變更', + 'tours.planner.saveChanges': '儲存變更', + 'tours.planner.newTourConfirmTitle': '開始新的遊程?', + 'tours.planner.newTourConfirmBody': '這個遊程中尚未儲存的變更將被捨棄。已儲存的遊程不會變更。', + 'tours.planner.newTourConfirm': '捨棄變更並開始新遊程', + 'tours.planner.startingPoint': '點選地圖加入起點。', + 'tours.planner.firstUseDetails': '再加入一個點即可計算路線。路線與海拔會自動更新,完成後請儲存。', + 'tours.planner.collapseElevation': '收合海拔剖面圖', + 'tours.planner.expandElevation': '展開海拔剖面圖', + 'tours.planner.name': '遊程名稱', + 'tours.planner.namePlaceholder': '未命名的健行', + 'tours.planner.mapHint': '點選地圖以加入地點。', + 'tours.planner.waypoints': '路線點', + 'tours.planner.waypointLabel': '路線點 {n}', + 'tours.durationMinutes': '{count} 分鐘', + 'tours.planner.empty': '至少加入兩個點才能計算步行路線。', + 'tours.planner.start': '起點', + 'tours.planner.via': '途經', + 'tours.planner.end': '終點', + 'tours.planner.moveUp': '上移路線點', + 'tours.planner.moveDown': '下移路線點', + 'tours.planner.remove': '刪除路線點', + 'tours.planner.undo': '復原路線點編輯', + 'tours.planner.redo': '重做路線點編輯', + 'tours.planner.discard': '捨棄草稿', + 'tours.planner.discardTitle': '捨棄遊程中尚未儲存的變更?', + 'tours.planner.discardBody': '尚未儲存的編輯內容將被捨棄。已儲存的遊程及其路線點、檔案與日期安排不會變更。', + 'tours.planner.save': '儲存遊程', + 'tours.planner.saved': '遊程已儲存', + 'tours.planner.planAnother': '規劃另一個遊程', + 'tours.planner.assignToDay': '安排到某一天', + 'tours.planner.tripTours': '這趟旅行的遊程', + 'tours.planner.safetyNote': '路線計算僅協助規劃,不保證安全。', + 'tours.planner.safetyNoteDetails': 'OSM 難度標籤可能缺漏或有誤。系統不會評估天候、封路情形及當前現場狀況。', + 'tours.planner.maxDifficulty': '步道最高難度', + 'tours.planner.difficulty.t1': 'T1:健行', + 'tours.planner.difficulty.t2': 'T2:山區健行', + 'tours.planner.difficulty.t3': 'T3:具挑戰性的山區健行', + 'tours.planner.difficulty.t4': 'T4:高山健行', + 'tours.planner.difficulty.t5': 'T5:具挑戰性的高山健行', + 'tours.planner.difficulty.t6': 'T6:高難度高山健行', + 'tours.planner.difficulty.t3Warning': + 'T3 路線可能包含有墜落風險的暴露山區步道,或技術要求較高的山區步道。需要穩健的步伐,以及不懼高、能在高處保持鎮定的能力。', + 'tours.planner.difficulty.alpineTitle': '啟用高山路線規劃?', + 'tours.planner.difficulty.alpineBody': + '已啟用高山路線規劃。路線可能穿越有墜落風險的暴露地形、沒有路線標記的地形,或技術難度較高的地形。OSM 難度資料可能缺漏或有誤。', + 'tours.planner.difficulty.enable': '啟用高山路線規劃', + 'tours.planner.openFailed': '無法開啟已儲存的遊程進行編輯。', + 'tours.planner.status.dirty': '等待計算', + 'tours.planner.status.routing': '正在計算步行路線…', + 'tours.planner.status.routingFailed': '路線計算失敗。仍顯示上次成功計算的路線。', + 'tours.planner.status.elevation': '正在載入海拔…', + 'tours.planner.status.elevationFailed': '無法取得海拔資料。仍顯示已計算的路線。', + 'tours.planner.status.ready': '路線與海拔已就緒', + 'tours.planner.status.saving': '正在儲存…', + 'tours.planner.status.saveFailed': '無法儲存遊程。草稿仍保留在這裡。', + 'tours.planner.retry': '重試', + 'tours.planner.inspector.title': '遊程摘要', + 'tours.planner.inspector.empty': '連接兩個路線點後,這裡會顯示路線詳情。', + 'tours.planner.inspector.distance': '距離', + 'tours.planner.inspector.duration': '步行時間', + 'tours.planner.inspector.elevation': '海拔剖面圖', + 'tours.planner.inspector.profileFocus': '距離 {distance} 處:{elevation}', + 'tours.planner.inspector.elevationPlaceholder': '取得路線的海拔資料後,會顯示剖面圖。', + 'tours.planner.gpxNoGeometry': '這次 GPX 匯入沒有可用的路線幾何資料。', + 'tours.planner.mobileHint': '可在平板電腦與電腦上規劃遊程。在這裡可以檢視已儲存的遊程並安排日期。', + 'tourTypes.hike': '健行', + 'tourTypes.bike': '單車遊程', + 'tourTypes.run': '跑步', + 'tourTypes.ski': '滑雪登山', + 'tourTypes.kayak': '獨木舟', + 'tourTypes.walk': '城市散步 / 觀光', + 'tourTypes.food': '美食 / 葡萄酒之旅', +}; +export default tours; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index d7fad9f621..fd08a5f2d6 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -48,6 +48,7 @@ import stats from './stats'; import storage from './storage'; import system_notice from './system_notice'; import todo from './todo'; +import tours from './tours'; import transport from './transport'; import trip from './trip'; import trips from './trips'; @@ -60,6 +61,7 @@ const locale = { ...nav, ...dashboard, ...roadtrip, + ...tours, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts new file mode 100644 index 0000000000..289db09c6f --- /dev/null +++ b/shared/src/i18n/zh/tours.ts @@ -0,0 +1,126 @@ +import type { TranslationStrings } from '../types'; + +const tours: TranslationStrings = { + 'trip.tabs.tourPlanner': '游览', + 'tours.mode.places': '地点', + 'tours.mode.tours': '游览', + 'tours.mode.label': '添加视图', + 'tours.title': '游览', + 'tours.subtitle': '本次旅行的徒步及其他一日游活动', + 'tours.empty.title': '暂无游览活动', + 'tours.empty.body': '规划游览路线或导入 GPX 文件,即可开始。', + 'tours.addToDay': '添加到某天', + 'tours.addToDay.comingSoon': '从游览列表将活动安排到旅行的某一天。', + 'tours.addToDay.pickDay': '选择日期', + 'tours.badge.wanderer': 'wanderer', + 'tours.badge.gpx': 'GPX 导入', + 'tours.badge.planned': '在 TREK 中规划', + 'tours.badge.trek': 'TREK', + 'tours.caution.badge': '请谨慎使用', + 'tours.caution.tooltip': '海拔或指标数据不完整,已带警告导入', + 'tours.import.caution': '已带警告导入,海拔或指标数据不完整', + 'tours.import.success': '已从 GPX 导入 {count} 项游览活动', + 'tours.import.successOne': '已从 GPX 导入 1 项游览活动', + 'tours.import.noTrack': '该 GPX 文件中未找到轨迹或路线', + 'tours.import.error': '无法导入 GPX 文件', + 'tours.delete.confirmBody': '要永久删除此行程吗?其路线、可编辑的途经点和日期安排都将被删除。此操作无法撤销。', + 'tours.delete.bulkConfirmBody': + '所选内容至少包含一个行程。所有选中的地点都将被永久删除且无法恢复;行程路线、可编辑的途经点和日期安排也会一并删除。', + 'tours.delete.confirmAction': '永久删除', + 'tours.addedToDay': '已添加到第 {n} 天', + 'tours.dayRoute.endpointUnknown': '无法确定游览路线的终点。后续连接路线可能不完整。', + 'tours.detail.trackColor': '轨迹颜色', + 'tours.detail.distance': '距离', + 'tours.detail.minAltitude': '最低海拔', + 'tours.detail.maxAltitude': '最高海拔', + 'tours.detail.ascent': '累计爬升', + 'tours.detail.descent': '累计下降', + 'tours.detail.difficulty': '难度', + 'tours.detail.source': '来源', + 'tours.detail.gpxReadOnly': 'GPX 游览路线仅供查看,暂时无法在规划器中编辑。', + 'tours.detail.navigationHint': '导航打开的是步道起点,不会沿游览路线提供导航。', + 'tours.planner.title': '规划游览路线', + 'tours.planner.newTour': '规划新游览路线', + 'tours.planner.neutralTitle': '规划游览路线', + 'tours.planner.neutralBody': '开始新路线,点击地图放置路线点。', + 'tours.planner.newTitle': '新游览路线', + 'tours.planner.editTitle': '编辑游览路线', + 'tours.planner.gpxTitle': 'GPX 游览路线', + 'tours.planner.backToTours': '返回游览列表', + 'tours.planner.restored': '已恢复未保存的草稿', + 'tours.planner.discardChanges': '放弃更改', + 'tours.planner.discardUnsaved': '放弃未保存的更改', + 'tours.planner.saveChanges': '保存更改', + 'tours.planner.newTourConfirmTitle': '开始规划新游览路线?', + 'tours.planner.newTourConfirmBody': '此游览路线中未保存的更改将被丢弃。已保存的游览路线不会改变。', + 'tours.planner.newTourConfirm': '放弃更改并开始新游览路线', + 'tours.planner.startingPoint': '点击地图添加起点。', + 'tours.planner.firstUseDetails': '再添加一个点即可计算路线。路线和海拔会自动更新,完成后请保存。', + 'tours.planner.collapseElevation': '收起海拔剖面图', + 'tours.planner.expandElevation': '展开海拔剖面图', + 'tours.planner.name': '游览名称', + 'tours.planner.namePlaceholder': '未命名的徒步活动', + 'tours.planner.mapHint': '点击地图添加地点。', + 'tours.planner.waypoints': '路线点', + 'tours.planner.waypointLabel': '路线点 {n}', + 'tours.durationMinutes': '{count} 分钟', + 'tours.planner.empty': '至少添加两个点才能计算步行路线。', + 'tours.planner.start': '起点', + 'tours.planner.via': '途经', + 'tours.planner.end': '终点', + 'tours.planner.moveUp': '上移路线点', + 'tours.planner.moveDown': '下移路线点', + 'tours.planner.remove': '删除路线点', + 'tours.planner.undo': '撤销路线点编辑', + 'tours.planner.redo': '重做路线点编辑', + 'tours.planner.discard': '丢弃草稿', + 'tours.planner.discardTitle': '放弃游览路线中未保存的更改?', + 'tours.planner.discardBody': '未保存的编辑内容将被丢弃。已保存的游览路线及其路线点、文件和日期安排不会改变。', + 'tours.planner.save': '保存游览路线', + 'tours.planner.saved': '游览路线已保存', + 'tours.planner.planAnother': '规划另一条路线', + 'tours.planner.assignToDay': '安排到某天', + 'tours.planner.tripTours': '本次旅行的游览活动', + 'tours.planner.safetyNote': '路线计算仅辅助规划,不保证安全。', + 'tours.planner.safetyNoteDetails': 'OSM 难度标签可能缺失或有误。系统不会评估天气、道路封闭情况及当前环境状况。', + 'tours.planner.maxDifficulty': '步道最高难度', + 'tours.planner.difficulty.t1': 'T1:徒步', + 'tours.planner.difficulty.t2': 'T2:山地徒步', + 'tours.planner.difficulty.t3': 'T3:挑战性山地徒步', + 'tours.planner.difficulty.t4': 'T4:高山徒步', + 'tours.planner.difficulty.t5': 'T5:挑战性高山徒步', + 'tours.planner.difficulty.t6': 'T6:高难度高山徒步', + 'tours.planner.difficulty.t3Warning': + 'T3 路线可能包含存在坠落风险的暴露山地步道,或技术要求较高的山地步道。需要稳健的脚步,以及在高处不畏高、能保持镇定的能力。', + 'tours.planner.difficulty.alpineTitle': '启用高山路线规划?', + 'tours.planner.difficulty.alpineBody': + '已启用高山路线规划。路线可能穿越存在坠落风险的暴露地形、无路线标记的地形或技术难度较高的地形。OSM 难度数据可能缺失或有误。', + 'tours.planner.difficulty.enable': '启用高山路线规划', + 'tours.planner.openFailed': '无法打开已保存的游览路线进行编辑。', + 'tours.planner.status.dirty': '等待计算', + 'tours.planner.status.routing': '正在计算步行路线…', + 'tours.planner.status.routingFailed': '路线计算失败。仍显示上次成功计算的路线。', + 'tours.planner.status.elevation': '正在加载海拔…', + 'tours.planner.status.elevationFailed': '无法获取海拔数据。仍显示已计算的路线。', + 'tours.planner.status.ready': '路线和海拔已就绪', + 'tours.planner.status.saving': '正在保存…', + 'tours.planner.status.saveFailed': '无法保存游览路线。草稿仍保留在此处。', + 'tours.planner.retry': '重试', + 'tours.planner.inspector.title': '游览概览', + 'tours.planner.inspector.empty': '连接两个路线点后,此处会显示路线详情。', + 'tours.planner.inspector.distance': '距离', + 'tours.planner.inspector.duration': '步行时间', + 'tours.planner.inspector.elevation': '海拔剖面图', + 'tours.planner.inspector.profileFocus': '距离 {distance} 处:{elevation}', + 'tours.planner.inspector.elevationPlaceholder': '获取到路线海拔数据后将显示剖面图。', + 'tours.planner.gpxNoGeometry': '本次 GPX 导入没有可用的路线几何数据。', + 'tours.planner.mobileHint': '可在平板电脑和电脑上规划游览路线。在这里可以查看已保存的游览活动并安排日期。', + 'tourTypes.hike': '徒步', + 'tourTypes.bike': '骑行', + 'tourTypes.run': '跑步', + 'tourTypes.ski': '滑雪穿越', + 'tourTypes.kayak': '皮划艇', + 'tourTypes.walk': '城市漫步 / 观光', + 'tourTypes.food': '美食 / 葡萄酒之旅', +}; +export default tours; diff --git a/shared/src/index.ts b/shared/src/index.ts index 038d1707f7..67b06aaaec 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -47,6 +47,7 @@ export * from './place/place-match'; export * from './place/place-website'; export * from './roadtrip/roadtrip.schema'; export * from './place/track-colors'; +export * from './tours/tours.schema'; export * from './collection/collection.schema'; export * from './collection/collection-file.schema'; export * from './trip/trip.schema'; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index de4e2e5968..01128ba135 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -156,6 +156,8 @@ export const placeSchema = z.object({ */ source: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(), + /** Present on the trip Place list when this Place has a Tours facet. */ + tour_place_id: z.number().nullable().optional(), // Manual track colour (#776). null = inherit the category colour like before. route_color: hexColorSchema.nullable().optional(), /** diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index d485b96325..9f90f8ad68 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -19,6 +19,7 @@ const FIXTURES: Record> = { 'place:created': { place: { id: 3, name: 'Louvre' } }, 'place:updated': { place: { id: 3, name: 'Louvre' } }, 'place:deleted': { placeId: 3 }, + 'tours:changed': { placeIds: [3] }, 'assignment:created': { assignment: { id: 9, day_id: 2 } }, 'assignment:updated': { assignment: { id: 9, day_id: 2 } }, 'assignment:deleted': { assignmentId: 9, dayId: 2 }, @@ -153,9 +154,9 @@ describe('@trek/shared realtime event registry', () => { // panel without every member polling for it. // 33rd user event: journey:photos:updated, so the gallery re-sorts once the // capture times of an import have landed (#1587). - expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(74); + expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(75); expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(33); - expect(TREK_WS_EVENT_NAMES).toHaveLength(107); + expect(TREK_WS_EVENT_NAMES).toHaveLength(108); }); it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index 6ebd3a250b..20ad12b46d 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -63,6 +63,8 @@ export const TREK_WS_EVENTS = { 'place:created': { scope: 'trip', payload: z.object({ place: entity }) }, 'place:updated': { scope: 'trip', payload: z.object({ place: entity }) }, 'place:deleted': { scope: 'trip', payload: z.object({ placeId: id }) }, + // Invalidate the trip's Tours list; affected ids let peers exclude new Tours immediately. + 'tours:changed': { scope: 'trip', payload: z.object({ placeIds: idList.optional() }) }, // ── Assignments ────────────────────────────────────────────────────────── 'assignment:created': { scope: 'trip', payload: z.object({ assignment: entity }) }, diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts new file mode 100644 index 0000000000..d9fd86fe64 --- /dev/null +++ b/shared/src/tours/tours.schema.test.ts @@ -0,0 +1,30 @@ +import { tourCreateRequestSchema, tourMaxHikingDifficultySchema } from './tours.schema'; + +import { describe, expect, it } from 'vitest'; + +const baseRequest = { + name: 'Ridge walk', + route_geometry: [ + [48, 11, 600], + [48.01, 11.02, 650], + ], + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], +}; + +describe('Tours maximum hiking difficulty', () => { + it('defaults create requests to T2', () => { + expect(tourCreateRequestSchema.parse(baseRequest).max_hiking_difficulty).toBe(2); + }); + + it('accepts only integer values from T1 through T6', () => { + for (const value of [1, 2, 3, 4, 5, 6]) { + expect(tourMaxHikingDifficultySchema.parse(value)).toBe(value); + } + for (const value of [0, -1, 7, 1.5, '2', null]) { + expect(() => tourMaxHikingDifficultySchema.parse(value)).toThrow(); + } + }); +}); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts new file mode 100644 index 0000000000..6f41b641cb --- /dev/null +++ b/shared/src/tours/tours.schema.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; + +/** + * Tours contracts — the generalisation of "hike" into a domain of self-contained, + * single-day, non-motorized activities that follow a track (hike, bike, run, ski, + * kayak, city walk, food tour, ...). Tour types use a controlled key list. + * + * Isolated bounded context: + * no columns on `places`/`days`/`users`. A tour is a Place (route_geometry reused + * as-is) plus a day_assignments row (reused as-is, `assignPlaceToDay`) plus this + * facet table keyed by place_id. Boundary vs. Road-Trip: Tours = non-motorized + + * single-day, Road-Trip = motorized + multi-day. + */ + +export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak', 'walk', 'food']); +export type TourTypeKey = z.infer; + +export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6); +export type TourMaxHikingDifficulty = z.infer; + +export const tourTypeSchema = z.object({ + key: tourTypeKeySchema, + label_key: z.string(), + icon: z.string(), + color: z.string(), + /** NULL = no engine routing (e.g. kayak — manual waypoints only). */ + routing_profile: z.string().nullable(), + is_sport: z.boolean(), + enabled: z.boolean(), + sort_order: z.number().int(), +}); +export type TourType = z.infer; + +export const tourSchema = z.object({ + place_id: z.number().int().positive(), + tour_type: tourTypeKeySchema, + distance: z.number().nullable(), + elevation_gain: z.number().nullable(), + elevation_loss: z.number().nullable(), + duration: z.number().nullable(), + difficulty: z.string().nullable(), + // Nullable reference to a tour in wanderer. + wanderer_ref: z.string().nullable(), + match_confidence: z.number().min(0).max(1).nullable(), + tour_group_id: z.number().int().nullable(), + max_hiking_difficulty: tourMaxHikingDifficultySchema, +}); +export type Tour = z.infer; + +export const tourTypeListResponseSchema = z.object({ + tourTypes: z.array(tourTypeSchema), +}); +export type TourTypeListResponse = z.infer; + +/** + * A tour as shown in the Tours selection list: the `tours` facet joined with + * its owning place's name, plus + * two read-model-only fields the list needs and the facet table does not carry: + * `planned` (whether the place already has a day_assignments row — the same + * "attach only" fact PlacesSidebar's All/Unplanned/Planned filter uses) and + * `caution` (derived from match_confidence < 0.5, the "use with caution" + * threshold — surfaced as a badge instead of making the client re-derive it). + */ +export const tourListItemSchema = tourSchema.extend({ + name: z.string(), + planned: z.boolean(), + caution: z.boolean(), + /** Read-model provenance signal: planner-authored tours persist routing controls; GPX imports do not. */ + has_waypoints: z.boolean().optional(), +}); +export type TourListItem = z.infer; + +export const tourListResponseSchema = z.object({ + tours: z.array(tourListItemSchema), +}); +export type TourListResponse = z.infer; + +export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']); +export type TourWaypointRole = z.infer; + +export const tourWaypointSchema = z.object({ + lat: z.number().finite().min(-90).max(90), + lng: z.number().finite().min(-180).max(180), + role: tourWaypointRoleSchema, + sequence: z.number().int().nonnegative(), +}); +export type TourWaypoint = z.infer; + +const enrichedRoutePointSchema = z.tuple([ + z.number().finite().min(-90).max(90), + z.number().finite().min(-180).max(180), + z.number().finite(), +]); + +export const tourCreateRequestSchema = z + .object({ + name: z.string().trim().min(1).max(255), + tour_type: z.literal('hike').default('hike'), + route_geometry: z.array(enrichedRoutePointSchema).min(2), + waypoints: z.array(tourWaypointSchema).min(2), + max_hiking_difficulty: tourMaxHikingDifficultySchema.default(2), + duration_seconds: z.number().finite().nonnegative().nullable().optional(), + }) + .superRefine((value, ctx) => { + value.waypoints.forEach((point, index) => { + const expectedRole: TourWaypointRole = + index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via'; + if (point.sequence !== index || point.role !== expectedRole) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['waypoints', index], + message: `Waypoint ${index} must be ${expectedRole} with sequence ${index}`, + }); + } + }); + }); +export type TourCreateRequest = z.infer; + +export const tourCreateResponseSchema = z.object({ + tour: tourListItemSchema, + waypoints: z.array(tourWaypointSchema), +}); +export type TourCreateResponse = z.infer; + +/** Saved-tour editor payload: list metadata plus its persisted routing controls. */ +export const tourDetailResponseSchema = tourCreateResponseSchema; +export type TourDetailResponse = z.infer; + +export const tourImportGpxResponseSchema = z.object({ + tours: z.array(tourListItemSchema), + caution: z.boolean(), + skipped: z.number().int(), +}); +export type TourImportGpxResponse = z.infer;