diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2f42296ef..bff0db6b0 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 ba7233fe6..6bf279493 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 c4579c3d9..6414d1b77 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 3dd3fb8aa..45455be01 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 f76fe298d..b5525a6d3 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 4bdfb8e5b..d63790d3a 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,90 @@ describe('MapView', () => { expect(screen.queryByTestId('polyline')).toBeNull() }) + it('moves a passive semantic profile focus marker without changing the map camera', () => { + const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 } + const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]] + const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]] + mapMock.fitBounds.mockClear() + mapMock.panTo.mockClear() + mapMock.setView.mockClear() + const { rerender } = render() + 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('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 f931742b1..aaaec16a6 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 @@ -734,6 +749,16 @@ const MemoMarker = memo(function MemoMarker({ ) }) +function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon { + const size = selected ? 30 : 26 + return L.divIcon({ + className: '', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + html: `${index + 1}`, + }) +} + export const MapView = memo(function MapView({ places = [], dayPlaces = [], @@ -799,7 +824,19 @@ export const MapView = memo(function MapView({ activeAlternative, onChooseAlternative, onHighlightAlternative, + plannerWaypoints = [], + selectedPlannerWaypointId = null, + onPlannerWaypointClick, + routeProfileFocus = null, + viewBaseLayer, + onViewBaseLayerChange, }: any) { + const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const updateSetting = useSettingsStore(s => s.updateSetting) + const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer + const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' + const isSatellite = baseLayer === 'satellite' + const isTopo = baseLayer === 'topo' // The caller hands over whatever the user configured; what kind of basemap // that is decides which layer draws it. A saved raster template still wins, // the default is a vector style. @@ -811,7 +848,7 @@ export const MapView = memo(function MapView({ * decided before the map is constructed, because Leaflet cannot change a map's * CRS afterwards — hence the whole map, not just the tile layer. */ - const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url) + const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url) const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap]) const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( (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 +1086,10 @@ export const MapView = memo(function MapView({ // of points), so it hangs off `places` alone — a selection change must not // re-parse every track. const gpxTracks = useMemo(() => places.flatMap(place => { - if (!place.route_geometry) return [] - try { - const coords = JSON.parse(place.route_geometry) as [number, number][] - if (!coords || coords.length < 2) return [] - return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }] - } catch { return [] } + const geometry = parseRenderableRouteGeometry(place.route_geometry) + if (!geometry) return [] + const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng]) + return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }] }), [places]) // Keeps the click handler out of the polyline memo below: `handleMarkerClick` @@ -1119,9 +1158,6 @@ export const MapView = memo(function MapView({ ? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 84px) + 12px)' - const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' - const updateSetting = useSettingsStore(s => s.updateSetting) - const isSatellite = baseLayer === 'satellite' const toggleBaseLayer = useCallback(() => { // Store flips state synchronously (instant, works offline); a failed save is logged there. updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) @@ -1149,8 +1185,8 @@ export const MapView = memo(function MapView({ // the cluster group away on every template or API-key edit. key={isGcjBasemap ? 'gcj02' : 'wgs84'} id="trek-map" - center={initialView.center} - zoom={initialView.zoom} + center={cameraSnapshotRef.current?.center ?? initialView.center} + zoom={cameraSnapshotRef.current?.zoom ?? initialView.zoom} zoomControl={false} // On the map itself, not left to the base layer. Leaflet reads its zoom // ceiling from the map options or, failing that, from a GridLayer that @@ -1184,6 +1220,18 @@ export const MapView = memo(function MapView({ updateWhenIdle={true} referrerPolicy="strict-origin-when-cross-origin" /> + ) : isTopo ? ( + ) : basemap.kind === 'vector' ? ( ) : ( @@ -1200,6 +1248,7 @@ export const MapView = memo(function MapView({ )} + {hasViewBaseLayer && } 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} /> @@ -1311,10 +1360,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 5acb542b3..274c10e94 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -889,6 +889,65 @@ describe('MapViewGL', () => { return calls[calls.length - 1][0] as { features: GeoFeature[] } } + it('moves a passive semantic profile focus pin without changing the map camera', async () => { + loadOnAttach() + const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 } + const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]] + const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]] + const { rerender } = render() + 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('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 +1841,33 @@ describe('MapViewGL', () => { expect(features[1].properties.color).toBeNull() }) + it('uses view-local Topo tiles and controls without writing settings or moving the camera', async () => { + glMap.on.mockImplementation((event, handlerOrLayer) => { + if (event === 'load' && typeof handlerOrLayer === 'function') handlerOrLayer() + return glMap + }) + const onChange = vi.fn() + const settings = useSettingsStore.getState().settings.map_base_layer + const { container, rerender } = render() + 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 +1886,47 @@ describe('MapViewGL', () => { expect(features[0].properties.place_id).toBe(74) }) + it.each([ + ['two-point', [[48, 2], [49, 3]]], + ['elevation', [[48, 2, 12.5], [49, 3, 22.75]]], + ['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])], + ['loop', [[48, 2], [48.1, 2.1], [48, 2]]], + ['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]], + ])('renders valid %s geometry with MapLibre coordinate order', async (_label, geometry) => { + const features = gpxFeatures() + render() + 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 ebb398e58..0f2ffc39e 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. * @@ -330,23 +339,27 @@ function addPlaceClusterLayers(map: any): void { * yet. The catch covers that case, and `styledata` brings the pass back once it is in. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function applySatellite(map: any, on: boolean): void { +function applySatellite(map: any, on: boolean, topo = false): void { + const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID + const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID try { - if (map.getLayer(SATELLITE_LAYER_ID)) { - map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none') + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none') return } if (!on) return // Nothing to build while it is switched off. // The layer is what is missing, not necessarily the source: a pass that got the // source in and then failed on the layer would otherwise leave a source that stops // every later pass from ever building the layer. - if (!map.getSource(SATELLITE_SOURCE_ID)) { - map.addSource(SATELLITE_SOURCE_ID, { + if (!map.getSource(sourceId)) { + map.addSource(sourceId, { type: 'raster', - tiles: [SATELLITE_TILE_URL], + tiles: topo + ? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain)) + : [SATELLITE_TILE_URL], tileSize: 256, - maxzoom: SATELLITE_TILE_MAXZOOM, - attribution: SATELLITE_TILE_ATTRIBUTION, + maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM, + attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION, }) } // Under the first thing TREK draws, over everything the basemap style draws. @@ -355,9 +368,9 @@ function applySatellite(map: any, on: boolean): void { const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix))) map.addLayer({ - id: SATELLITE_LAYER_ID, + id: layerId, type: 'raster', - source: SATELLITE_SOURCE_ID, + source: sourceId, paint: { 'raster-opacity': 1 }, }, firstOwn?.id) } catch { /* a style that refuses the layer keeps the plain basemap */ } @@ -727,6 +740,12 @@ export function MapViewGL({ activeAlternative, onChooseAlternative, onHighlightAlternative, + plannerWaypoints = [], + selectedPlannerWaypointId = null, + onPlannerWaypointClick, + routeProfileFocus = null, + viewBaseLayer, + onViewBaseLayerChange, roadtripVias, onMoveVia, onRemoveVia, @@ -740,7 +759,9 @@ export function MapViewGL({ const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') // The same stored choice the Leaflet map reads, so the two renderers agree. - const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const baseLayer = viewBaseLayer ?? globalBaseLayer + const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' const updateSetting = useSettingsStore(s => s.updateSetting) const isSatellite = baseLayer === 'satellite' const toggleBaseLayer = useCallback(() => { @@ -809,6 +830,8 @@ export function MapViewGL({ const routeViaMarkersRef = useRef([]) /** 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 +842,8 @@ export function MapViewGL({ pluginMarkersRef.current.forEach(pin => pin.reposition()) routeViaMarkersRef.current.forEach(pin => pin.reposition()) viaPinsRef.current.forEach(pin => pin.reposition()) + plannerWaypointPinsRef.current.forEach(pin => pin.reposition()) + profileFocusPinRef.current?.reposition() altLabelsRef.current.forEach(pin => pin.reposition()) }, []) // Single reusable hover popup for POI markers. Planned places use the @@ -837,6 +862,61 @@ export function MapViewGL({ * drag is therefore hand-rolled — pointer events on the element, unproject on move. */ const viaCleanupRef = useRef<(() => void)[]>([]) + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + plannerWaypointPinsRef.current.forEach(pin => pin.remove()) + plannerWaypointPinsRef.current = [] + + for (const [index, point] of plannerWaypoints.entries()) { + const selected = point.id === selectedPlannerWaypointId + const size = selected ? 30 : 26 + const el = document.createElement('button') + el.type = 'button' + el.textContent = String(index + 1) + el.setAttribute('aria-label', `Waypoint ${index + 1}`) + el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0` + const swallow = (event: Event) => event.stopPropagation() + const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) } + el.addEventListener('pointerdown', swallow) + el.addEventListener('touchstart', swallow, { passive: true }) + el.addEventListener('click', select) + plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)) + } + + return () => { + plannerWaypointPinsRef.current.forEach(pin => pin.remove()) + plannerWaypointPinsRef.current = [] + } + }, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId]) + + useEffect(() => () => { + profileFocusPinRef.current?.remove() + profileFocusPinRef.current = null + }, [gl, mapReady]) + + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + if (!routeProfileFocus) { + profileFocusPinRef.current?.remove() + profileFocusPinRef.current = null + return + } + let pin = profileFocusPinRef.current + if (!pin) { + const element = document.createElement('div') + element.setAttribute('aria-hidden', 'true') + element.dataset.tourProfileFocus = 'true' + element.style.cssText = 'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;' + pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat) + profileFocusPinRef.current = pin + } else { + pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat]) + } + pin.el.style.pointerEvents = 'none' + }, [gl, mapReady, routeProfileFocus]) + /** * The list only changes when a via does, and the callbacks are read through a ref. * @@ -2188,20 +2268,17 @@ export function MapViewGL({ const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined if (!src) return const features = places.flatMap(place => { - if (!place.route_geometry) return [] - try { - const coords = JSON.parse(place.route_geometry) as [number, number][] - if (!coords || coords.length < 2) return [] - return [{ - type: 'Feature' as const, - properties: { - color: resolveTrackColor(place), - cased: hasManualTrackColor(place), - place_id: place.id, - }, - geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) }, - }] - } catch { return [] } + const coords = parseRenderableRouteGeometry(place.route_geometry) + if (!coords) return [] + return [{ + type: 'Feature' as const, + properties: { + color: resolveTrackColor(place), + cased: hasManualTrackColor(place), + place_id: place.id, + }, + geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) }, + }] }) src.setData({ type: 'FeatureCollection', features }) }, [places, mapReady]) @@ -2405,11 +2482,14 @@ export function MapViewGL({ useEffect(() => { const map = mapRef.current if (!map || !mapReady) return - const apply = () => applySatellite(map, isSatellite) + const apply = () => { + applySatellite(map, isSatellite) + applySatellite(map, baseLayer === 'topo', true) + } apply() map.on('styledata', apply) return () => { map.off('styledata', apply) } - }, [isSatellite, mapReady, glProvider]) + }, [isSatellite, baseLayer, mapReady, glProvider]) if (!isMapLibre && !mapboxToken) { return ( @@ -2455,7 +2535,9 @@ export function MapViewGL({ : 'calc(var(--bottom-nav-h, 0px) + 12px)', }}> {onToggleFollow && } - + {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 5c808e70d..406a72adb 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 0a17784d4..e88d88769 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 000000000..4866fa3ab --- /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 8f14f676e..dbdd191b7 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 4561eb118..52ec28739 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 d35e52421..25ec5d67e 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 49b1c7f5b..7e0198a9e 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 5c4369327..a8deff865 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 99494353b..17e8f144d 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 3a4bda72f..bfee18723 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 194b28ec3..fd0960425 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 5c3640651..f8abb4f58 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 a2b1dd22f..78afdf430 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -2857,6 +2857,76 @@ describe('DayPlanSidebar', () => { expect(pairs).toContainEqual([{ lat: 43.66, lng: 7.21 }, { lat: 43.70, lng: 7.26 }]) }) + it.each([ + { enabled: true, excluded: false, pairs: [[48.85, 48.86], [48.88, 48.89]] }, + { enabled: true, excluded: true, pairs: [[48.85, 48.89]] }, + { enabled: false, excluded: false, pairs: [[48.85, 48.86], [48.86, 48.89]] }, + ])('B5 sidebar Tour connector parity enabled=$enabled excluded=$excluded', async ({ enabled, excluded, pairs }) => { + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', icon: 'Route', type: 'feature', enabled }] }) + const day = buildDay({ id: 10, date: '2025-06-01' }) + const places = [ + buildPlace({ id: 1, name: 'Before Tour', lat: 48.85, lng: 2.35 }), + buildPlace({ id: 2, name: 'Assigned Tour', lat: 48.86, lng: 2.35, route_geometry: '[[48.86,2.35],[48.88,2.35]]' }), + buildPlace({ id: 3, name: 'After Tour', lat: 48.89, lng: 2.35 }), + ] + const assignments = { '10': places.map((place, order) => buildAssignment({ + id: order + 1, day_id: 10, order_index: order, place, + ...(order === 1 ? { tour_place_id: 2, tour_route_geometry: places[1].route_geometry, route_excluded: excluded } : {}), + })) } + render() + 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 a9e5eea75..4ffdcca4f 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' @@ -130,7 +131,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 +234,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) + const toursEnabled = useAddonStore(s => s.isEnabled('tours')) const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -643,6 +645,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // legs to draw. Side-effect free, so the async loop below only does OSRM I/O. const planDay = (dayId: number) => { const merged = mergedItemsMap[dayId] || [] + const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item])) // Each run point carries the ORIGIN assignment's per-segment travel mode // (#1281): the leg from this point to the next is routed with `mode` (null = // inherit the day default). Transport endpoints carry no mode → day default. @@ -654,8 +657,24 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // flight, not a drive — and surface it as a bogus connector distance (#1394). let curHasPlace = false for (const it of merged) { - // A stop out of the route (#2532) is passed by, like a note. if (it.type === 'place' && it.data.route_excluded) continue + const tour = it.type === 'place' ? projected.get(it.data.id) : undefined + if (tour?.kind === 'tour') { + if (tour.start) { + const prev = cur[cur.length - 1] + if (prev && !prev.isPlace && !withinDriveRange(prev, tour.start)) { + if (cur.length >= 2 && curHasPlace) runs.push(cur) + cur = [] + curHasPlace = false + } + cur.push({ id: it.data.id, ...tour.start, isPlace: true, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) + curHasPlace = true + } + if (cur.length >= 2 && curHasPlace) runs.push(cur) + cur = tour.end ? [{ id: it.data.id, ...tour.end, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null }] : [] + curHasPlace = !!tour.end + continue + } if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { // Mirror of the guard below: the open run may hold nothing but a far-away // arrival endpoint, which is no more drivable in this direction (#2133). @@ -714,7 +733,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // is one you flew out of or landed at rather than drove between (#2133). const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = [] for (const it of merged) { - if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng && !it.data.route_excluded) { + if (it.type === 'place' && it.data.route_excluded) continue + const tour = it.type === 'place' ? projected.get(it.data.id) : undefined + if (tour?.kind === 'tour') { + if (tour.start) wayPts.push({ ...tour.start, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) + if (tour.end) wayPts.push({ ...tour.end, isPlace: true, time: it.data.place?.place_time ?? null, id: it.data.id, leg_transport_mode: it.data.leg_transport_mode ?? null }) + continue + } + if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, dayId) @@ -725,14 +751,31 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] + const hasRouteWaypoint = (item: typeof merged[number]) => { + if (item.type === 'place') { + if (item.data.route_excluded) return false + const tour = projected.get(item.data.id) + if (tour?.kind === 'tour') return !!tour.start || !!tour.end + return !!item.data.place?.lat && !!item.data.place?.lng + } + if (item.type === 'transport') { + const { from, to } = getTransportRouteEndpoints(item.data, dayId) + return !!from || !!to + } + return false + } const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) => !h || !w || w.isPlace || h.place_lat == null || h.place_lng == null || withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w) // Same carrier evidence the map route uses (#2157): with a located carrier // endpoint on the day, the no-time default must not open a hotel leg. const dayHasCarrier = wayPts.some(w => w.carrierEdge != null) - const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay) - const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay) + const firstItem = merged.find(hasRouteWaypoint) + const firstTour = firstItem?.type === 'place' ? projected.get(firstItem.data.id) : undefined + const wantTop = !(firstTour?.kind === 'tour' && !firstTour.start) && !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay) + const lastItem = [...merged].reverse().find(hasRouteWaypoint) + const lastTour = lastItem?.type === 'place' ? projected.get(lastItem.data.id) : undefined + const wantBottom = !(lastTour?.kind === 'tour' && !lastTour.end) && !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay) return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } } @@ -822,7 +865,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // routeDayIds is memoized from the same inputs as routeDayKey below, so keying the // effect on the string is equivalent while staying stable across unrelated renders. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit]) + }, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places]) const openAddNote = (dayId, e) => { e?.stopPropagation() @@ -842,7 +885,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { // Capture previous place order for undo - const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const previousAssignments = assignments[String(dayId)] || [] + const prevAssignmentIds = previousAssignments.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const prevPlaceIds = [...new Set(previousAssignments.map(a => a.place?.id).filter((id): id is number => id != null))] // …and, per booking, the fields this call is about to overwrite, so a failed write // can put the visible order back instead of leaving a phantom one behind the error // toast. Restoring the whole array instead would also drop what a collaborator's @@ -958,7 +1003,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const capturedPrevIds = prevAssignmentIds pushUndo?.(t('undo.reorder'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) - }, [capturedDayId]) + }, [capturedDayId], prevPlaceIds) } } catch (err: unknown) { rollBackReservations() @@ -1090,6 +1135,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (da.length < 3) return const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const placeIds = [...new Set((assignments[String(dayId)] || []).map(a => a.place?.id).filter((id): id is number => id != null))] // Separate fixed (stay at their index) and movable assignments. A place is // fixed if it's locked OR has a set time — timed places are anchored by their @@ -1133,7 +1179,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const capturedDayId = dayId pushUndo?.(t('undo.optimize'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) - }, [capturedDayId]) + }, [capturedDayId], placeIds) } @@ -1364,6 +1410,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) const dateFirst = useSettingsStore(s => s.settings.day_date_first === true) const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) + const toursEnabled = useAddonStore(s => s.isEnabled('tours')) // Plugin time contributions in the day plan (dayScheduleProvider hook). const daySchedule = usePluginDaySchedule(S.tripId) // Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a @@ -1625,6 +1672,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // Where nothing sits above it (the mobile shell), the height still fills the panel.
{/* Toolbar */} + {toursEnabled && selectedDayId != null && projectDayItinerary(assignments[String(selectedDayId)] || [], true, places).some(item => item.kind === 'tour' && !item.valid) && ( +
+ {t('tours.dayRoute.endpointUnknown')} +
+ )} + + {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]) + rerender() + expect(sidebar.filter).toBe('all') + expect(sidebar.filtered.map(place => place.id)).toEqual([2]) + }) + + it('blocks Places file drops while Tours is on and restores them while off', () => { + const props = makeProps({ toursEnabled: true }) + const { rerender } = render() + 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 69592e20e..1c3b3553d 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -409,7 +409,7 @@ function LocalityFilterDropdown(S: SidebarState) { } function AddRow(S: SidebarState) { - const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders } = S + const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, toursEnabled } = S const dayOpen = selectedDayId != null const split = dayOpen && !!onAddPlaceToSelectedDay const { ref: rowRef, width: rowWidth } = useElementSize() @@ -419,7 +419,7 @@ function AddRow(S: SidebarState) { const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace') const importMenu = useContextMenu() const importItems = [ - { label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) }, + ...(!toursEnabled ? [{ label: t('places.importFile'), icon: FileDown, onClick: () => setFileImportOpen(true) }] : []), { label: t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList'), icon: ListPlus, onClick: () => setListImportOpen(true) }, ] return ( diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 8a9669849..96d196385 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -65,6 +65,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 } /** @@ -77,6 +84,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS, pushUndo, initialScrollTop, onScrollTopChange, onEditPlace, onAssignToDay, onDeletePlace, } = props + const poolPlaces = useMemo( + () => props.toursEnabled && props.excludePlaceIds ? places.filter(p => !props.excludePlaceIds!.has(p.id)) : places, + [places, props.toursEnabled, props.excludePlaceIds], + ) const { t, language } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() @@ -107,14 +118,14 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { }, []) const handleSidebarDragEnter = (e: React.DragEvent) => { - if (!canEditPlaces) return + if (!canEditPlaces || props.toursEnabled) return e.preventDefault() sidebarDragCounter.current++ setSidebarDragOver(true) } const handleSidebarDragOver = (e: React.DragEvent) => { - if (!canEditPlaces) return + if (!canEditPlaces || props.toursEnabled) return e.preventDefault() } @@ -127,7 +138,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { e.preventDefault() sidebarDragCounter.current = 0 setSidebarDragOver(false) - if (!canEditPlaces) return + if (!canEditPlaces || props.toursEnabled) return const f = e.dataTransfer.files[0] if (!f) return setSidebarDropFile(f) @@ -195,7 +206,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { // like the rating floor. const [localityFilter, setLocalityFilter] = useState(null) const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language]) - const localities = useMemo(() => localityGroups(places.map(p => localityOf.get(p.id)!)), [places, localityOf]) + const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf]) const [selectedIds, setSelectedIds] = useState>(new Set()) const [pendingDeleteIds, setPendingDeleteIds] = useState(null) const [categoryPickerOpen, setCategoryPickerOpen] = useState(false) @@ -257,7 +268,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) } // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) - const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places]) + const hasTracks = useMemo( + () => !props.toursEnabled && poolPlaces.some(p => p.route_geometry), + [poolPlaces, props.toursEnabled], + ) useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter]) const plannedIds = useMemo( @@ -286,7 +300,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const dayScoped = filter === 'planned' && plannedInDayIds !== null const filtered = useMemo(() => { - const list = places.filter(p => { + const list = poolPlaces.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false if (filter === 'tracks' && !p.route_geometry) return false @@ -301,7 +315,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { return true }) return sortPlaces(list, placesSort, language) - }, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter, localityFilter, localityOf, placesSort, language]) + }, [poolPlaces, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter, localityFilter, localityOf, placesSort, language]) /** * How many places each "show" choice would leave, under the category and search @@ -311,7 +325,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { * different questions. */ const filterCounts = useMemo(() => { - const base = places.filter(p => { + const base = poolPlaces.filter(p => { if (categoryFilters.size > 0) { if (p.category_id == null) { if (!categoryFilters.has('uncategorized')) return false @@ -325,7 +339,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { planned: base.filter(p => plannedFilterIds.has(p.id)).length, tracks: base.filter(p => p.route_geometry).length, } satisfies Record - }, [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 000000000..d2b158b4b --- /dev/null +++ b/client/src/components/Tours/PlacesToursModeSwitch.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { MapPin, Mountain } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { NEUTRAL_TINT } from '../shared/DialogShell' + +interface PlacesToursModeSwitchProps { + active: boolean + onChange: (tours: boolean) => void +} + +/** + * Switches the right add-panel between the ordinary Places sidebar and the + * Tours selection list. Top level of the panel, mirroring RoadtripModeSwitch + * (Days <-> Roadtrip), using the same placement and addon gating convention. + * Only rendered while the tours addon is on. + */ +export default function PlacesToursModeSwitch({ active, onChange }: PlacesToursModeSwitchProps): React.ReactElement { + const { t } = useTranslation() + const options: [boolean, string, typeof MapPin][] = [ + [false, t('tours.mode.places'), MapPin], + [true, t('tours.mode.tours'), Mountain], + ] + return ( +
+
+ {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 000000000..b0bf855ef --- /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 000000000..fd2eef404 --- /dev/null +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -0,0 +1,288 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, ArrowDown, ArrowUp, Check, Clock, File, FileImage, FileText, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Ruler, Trash2, Upload } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' +import type { TourListItem } from '@trek/shared' +import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types' +import { useTranslation, translateApiError } from '../../i18n' +import { useToast } from '../shared/Toast' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance, formatElevation } from '../../utils/units' +import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' +import TrackColorPicker from '../shared/TrackColorPicker' +import DetailShell from '../shared/DetailShell' +import ElevationProfile from '../shared/ElevationProfile' +import { NavigationMenu } from '../shared/NavigationMenu' +import { markdownLinkComponents } from '../shared/markdownLink' +import { analyzeRouteGeometry } from '../../utils/routeGeometry' +import { filesForPlace } from '../../utils/placeFiles' +import { openFile } from '../../utils/fileDownload' +import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation' +import { hikeSourceBadgeLabel, tourSource } from './tourPresentation' +import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' + +interface TourDetailDialogProps extends TourPermissionProps { + tour: TourListItem + place: Place + days?: Day[] + selectedDayId?: number | null + selectedAssignmentId?: number | null + assignments?: AssignmentsMap + files?: TripFile[] + onClose: () => void + onUpdatePlace?: (placeId: number, data: Partial) => 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 +} + +export default function TourDetailDialog({ + tour, + place, + selectedDayId = null, + selectedAssignmentId = null, + assignments = {}, + files = [], + onClose, + onUpdatePlace, + onFileUpload, + onAssignToDay, + onRemoveAssignment, + onDelete, + leftWidth = 0, + rightWidth = 0, + desktopNonModal = false, + readOnly = false, + desktopFocusReturnTarget = null, + canEdit: editPermission, + canAssign: assignPermission, +}: TourDetailDialogProps) { + const { canEdit: hasEditPermission, canAssign } = useTourPermissions({ tripId: place.trip_id, canEdit: editPermission, canAssign: assignPermission }) + const canEdit = hasEditPermission && !readOnly + const { t } = useTranslation() + const toast = useToast() + const distanceUnit = useSettingsStore(state => state.settings.distance_unit) + const [editingName, setEditingName] = useState(false) + const [nameDraft, setNameDraft] = useState(place.name) + const [savingName, setSavingName] = useState(false) + const [filesExpanded, setFilesExpanded] = useState(false) + const [uploading, setUploading] = useState(false) + const [navigationOpen, setNavigationOpen] = useState(false) + const navigationButtonRef = useRef(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 source = tourSource(tour) + + const saveName = async () => { + if (!canEdit) return + const name = nameDraft.trim() + if (!name || name === place.name || !onUpdatePlace) { setEditingName(false); return } + setSavingName(true) + try { + await onUpdatePlace(place.id, { name }) + setEditingName(false) + } catch (error: unknown) { + toast.error(translateApiError(t, error, 'common.error')) + } finally { + setSavingName(false) + } + } + + const uploadFiles = async (event: React.ChangeEvent) => { + 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') { setNameDraft(place.name); setEditingName(false) } + }} + className="border border-edge bg-surface-secondary text-content" + style={{ flex: 1, minWidth: 0, borderRadius: 8, padding: '6px 10px', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }} + /> + +
+ ) : ( +
+
+

{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 })}} + {tour.caution && {t('tours.caution.badge')}} + {hikeSourceBadgeLabel(tour, t)} +
+
+ ) + + const footer = <> + {canAssign && selectedDayId != null && (assignment ? onRemoveAssignment && ( + { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={} label={t('inspector.removeFromDay')} /> + ) : ( + onAssignToDay && { if (canAssign) onAssignToDay(place.id) }} icon={} label={t('inspector.addToDay')} primary /> + ))} + {navigationTargets.length > 0 && ( + <> + { + if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) + else setNavigationOpen(open => !open) + }} + icon={} + label={navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')} + /> + {navigationOpen && setNavigationOpen(false)} />} + + )} +
+ {canEdit && onDelete && { if (canEdit) onDelete() }} icon={} label={t('common.delete')} danger />} + + + return ( + + {source === 'gpx' && ( +
+ {t('tours.detail.gpxReadOnly')} + {/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */} +
+ )} +
+
{t('inspector.trackStats')}
+ {analysis && ( + <> +
+ } /> + {analysis.minEle != null && } />} + {analysis.maxEle != null && } />} + {analysis.gain != null && } />} + {analysis.loss != null && } />} +
+ + + )} +
+ +
+

{t('tours.detail.trackColor')}

+ {canEdit && onUpdatePlace ? { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> : } +
+ + {(place.description || place.notes || assignment?.notes) && ( +
+ {place.description &&
{place.description}
} + {place.notes &&
{place.notes}
} + {assignment?.notes &&
{t('places.assignmentNotes')}
{assignment.notes}
} +
+ )} + + {(placeFiles.length > 0 || (canEdit && onFileUpload)) && ( +
+
+ + {canEdit && onFileUpload && } +
+ {filesExpanded && placeFiles.length > 0 &&
{placeFiles.map(file => )}
} +
+ )} + + {navigationTargets.length > 0 &&
{t('tours.detail.navigationHint')}
} +
+ ) +} + +function TourMetric({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) { + return
{icon}{label}
{value}
+} + +function TourActionButton({ buttonRef, onClick, icon, label, primary = false, danger = false }: { buttonRef?: React.Ref; onClick: () => void; icon: React.ReactNode; label: string; primary?: boolean; danger?: boolean }) { + const className = primary ? 'bg-accent text-accent-text' : danger ? 'text-danger hover:bg-danger-subtle' : 'text-content-secondary hover:bg-surface-hover' + return +} diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx new file mode 100644 index 000000000..b07ceee79 --- /dev/null +++ b/client/src/components/Tours/TourListRow.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import type { TourListItem } from '@trek/shared' +import { AlertTriangle, ArrowDown, ArrowUp } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance, formatElevation } from '../../utils/units' +import { hikeSourceBadgeLabel } from './tourPresentation' + +interface TourListRowProps { + tour: TourListItem + selected?: boolean + disabled?: boolean + onSelect: (tour: TourListItem, opener: HTMLElement) => void + action?: ReactNode + children?: ReactNode +} + +/** Shared Tours list row used by both the ADD flow and the planner workspace. */ +export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(state => state.settings.distance_unit) + + return ( +
  • { if (!disabled) onSelect(tour, event.currentTarget) }} + onKeyDown={event => { + if (disabled) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelect(tour, event.currentTarget) + } + }} + onMouseEnter={event => { if (!selected) event.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={event => { if (!selected) event.currentTarget.style.background = 'transparent' }} + > +
    + {tour.name} +
    + + {hikeSourceBadgeLabel(tour, t)} + + T{tour.max_hiking_difficulty} + {tour.caution && ( + + {t('tours.caution.badge')} + + )} +
    +
    + + {formatDistance(tour.distance ?? 0, distanceUnit)} + + {tour.elevation_gain != null && ( + + + )} + {tour.elevation_loss != null && ( + + + )} +
    + {action &&
    {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 000000000..667750d2c --- /dev/null +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -0,0 +1,202 @@ +import { act, fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { useTripStore } from '../../store/tripStore' +import { toursApi } from '../../api/client' +import ToursSidebar from './ToursSidebar' + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })) + +vi.mock('../shared/Toast', () => ({ useToast: () => toast })) + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, + name: 'Selected ridge walk', + tour_type: 'hike', + distance: 4, + elevation_gain: 100, + elevation_loss: 80, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +beforeEach(() => { + resetAllStores() + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) +}) + +describe('ToursSidebar', () => { + it('uses the compact counted dropdown for All, Unplanned and Planned', () => { + render() + 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).toHaveStyle({ background: 'var(--border-faint)' }) + }) + + 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, badges, atomic metrics and full-width action in separate rows', (language, name) => { + seedStore(useSettingsStore, { settings: { language, distance_unit: 'metric' } }) + const onAssignToDay = vi.fn() + const { container } = render() + + const row = screen.getByRole('option', { name: new RegExp(name) }) + expect(row).toHaveAttribute('aria-selected', 'true') + expect(row.querySelector('span[title]')).toHaveAttribute('title', name) + const metrics = row.querySelector('[data-testid="tour-metrics"]')! + expect(metrics).toHaveTextContent('1.9 km') + expect(metrics).toHaveTextContent('13 m') + expect(metrics).toHaveTextContent('11 m') + for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap') + expect(metrics.children[0].getAttribute('aria-label')).toContain('1.9 km') + expect(metrics.children[1].getAttribute('aria-label')).toContain('13 m') + expect(metrics.children[2].getAttribute('aria-label')).toContain('11 m') + expect(screen.getByText('TREK')).toBeInTheDocument() + expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument() + const actionRow = row.querySelector('[data-testid="tour-action-row"]')! + expect(actionRow).toHaveClass('w-full') + expect(actionRow.querySelector('button')).toHaveClass('w-full') + expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })) + expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument() + }) + + it('leaves the post-write refresh to the shared assignment handler', async () => { + let resolveAssignment!: () => void + const assignment = new Promise(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('keeps the day picker open and does not refresh when assignment fails', async () => { + 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 waitFor(() => expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()) + expect(onToursChanged).not.toHaveBeenCalled() + }) + + it('quarantines returned Tour ids before loading the imported Places', async () => { + const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ + tours: [tour], caution: false, skipped: 0, + }) + const onToursChanged = vi.fn() + const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined) + const { container } = render() + + 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 000000000..776da3a14 --- /dev/null +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, FileDown, Mountain } from 'lucide-react' +import type { TourListItem } from '@trek/shared' +import type { Day } from '../../types' +import { toursApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import TourListRow from './TourListRow' +import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' +import { NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' + +const MAX_FILE_BYTES = 10 * 1024 * 1024 + +interface ToursSidebarProps extends TourPermissionProps { + tripId: number + days: Day[] + tours: TourListItem[] + loading?: boolean + selectedPlaceId?: number | null + onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise + 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 +} + +function dayLabel(day: Day, index: number, t: (key: string, params?: Record) => string): string { + if (day.title) return day.title + return t('dayplan.dayN', { n: index + 1 }) +} + +/** + * Tours mode of the right add-panel: a selection list of existing tours + * with GPX import and day assignment. + * + * Reuses the existing day-assignment mechanism as-is: `onAssignToDay` is the + * same `handleAssignToDay` PlacesSidebar calls, so a tour lands on a day + * exactly like a place does, and its route line renders on the day map + * without any Tours-specific rendering code. + */ +export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement { + const { t } = useTranslation() + const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission }) + const toast = useToast() + const loadTrip = useTripStore(s => s.loadTrip) + const [filter, setFilter] = useState<'all' | 'unplanned' | 'planned'>('all') + const [importing, setImporting] = useState(false) + const [pickerOpenFor, setPickerOpenFor] = useState(null) + 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 + setPickerOpenFor(null) + toast.success(t('tours.addedToDay', { n: index + 1 })) + } + + const counts = { + all: tours.length, + unplanned: tours.filter(tr => !tr.planned).length, + planned: tours.filter(tr => tr.planned).length, + } + const filtered = tours.filter(tr => { + if (filter === 'unplanned') return !tr.planned + if (filter === 'planned') return tr.planned + return true + }) + + useEffect(() => { + if (!filterOpen) return + const onDown = (event: MouseEvent) => { + if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false) + } + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.stopPropagation() + setFilterOpen(false) + filterButtonRef.current?.focus() + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey, true) + } + }, [filterOpen]) + + const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned') + const filterCount = counts[filter] + + return ( +
    +
    + {/* 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.title')}

    +

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

    +
    + ) : ( +
      + {filtered.map((tour) => { + const isSelected = tour.place_id === selectedPlaceId + return ( + onSelectTour?.(selectedTour, opener)} + action={( + + )} + > + {canAssign && pickerOpenFor === tour.place_id && ( +
      e.stopPropagation()}> + {days.map((day, i) => ( + + ))} +
      + )} +
      + ) + })} +
    + )} +
    +
    + ) +} 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 000000000..daad7d8cf --- /dev/null +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -0,0 +1,571 @@ +import { act, useState } from 'react' +import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { resetAllStores, seedStore } from '../../../../tests/helpers/store' +import { useSettingsStore } from '../../../store/settingsStore' +import { analyzeRouteGeometry } from '../../../utils/routeGeometry' +import type { TourPlannerController } from './useTourPlanner' +import type { Day } from '../../../types' +import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels' + +vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, + name: 'Saved ridge walk', + tour_type: 'hike', + distance: 4, + elevation_gain: 100, + elevation_loss: 80, + duration: 60, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +const days = [{ id: 7, title: 'Summit day', date: '2026-05-15' } as Day] +const railProps = { + days, + loading: false, + onAssignToDay: vi.fn(), + onViewGpxTour: vi.fn(), +} + +const routeAnalysis = analyzeRouteGeometry(JSON.stringify([[48, 11, 500], [48.036, 11, 550]])) + +function planner(overrides: Partial = {}): TourPlannerController { + 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('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('TOUR-PLANNER-RAIL-003: assigns the saved tour immediately when a day is picked', async () => { + const controller = planner({ saveOutcome: tour, editingPlaceId: tour.place_id }) + const onAssignToDay = vi.fn().mockResolvedValue(undefined) + render() + + expect(screen.getByRole('status')).toHaveTextContent('Tour saved') + fireEvent.click(screen.getByRole('button', { name: 'Plan another' })) + expect(controller.startNewTour).toHaveBeenCalledOnce() + expect(controller.discard).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' })) + const dayOption = screen.getByLabelText('Pick a day').querySelector('option[value="7"]') + expect(dayOption).toHaveTextContent(/Day 1/i) + expect(dayOption).toHaveTextContent(/May 15/i) + expect(dayOption).toHaveTextContent(/Fri/i) + fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } }) + await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7)) + }) + + it('keeps the saved Tour day picker open when assignment fails', async () => { + const controller = planner({ saveOutcome: tour, editingPlaceId: tour.place_id }) + const onAssignToDay = vi.fn().mockResolvedValue(false) + render() + fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' })) + fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } }) + + await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7)) + expect(screen.getByLabelText('Pick a day')).toBeInTheDocument() + }) + + it('TOUR-PLANNER-RAIL-004: protects a dirty draft before selecting a GPX Tour', () => { + const gpxTour = { ...tour, has_waypoints: false } + const controller = planner({ hasUnsavedChanges: true, mode: { type: 'new-draft' } }) + const onViewGpxTour = vi.fn() + render() + + 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-surface-tertiary', 'text-content-secondary', 'font-geist', 'tabular-nums') + expect(firstNumber).not.toHaveClass('text-white') + expect(firstNumber).toHaveStyle({ display: 'flex', width: '28px', minWidth: '28px', height: '28px', lineHeight: '1' }) + }) + + it('TOUR-PLANNER-RAIL-007: acknowledges alpine routing once per planner session', () => { + const setDifficulty = vi.fn() + const controller = planner({ mode: { type: 'new-draft' }, setMaxHikingDifficulty: setDifficulty }) + render() + + 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('Create a route with waypoints and save it to this trip.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Plan new tour' })) + expect(controller.startNewTour).toHaveBeenCalledOnce() + expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument() + expect(screen.queryByText('Maximum trail difficulty')).not.toBeInTheDocument() + + rerender() + 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 000000000..a23dc75ad --- /dev/null +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -0,0 +1,533 @@ +import { useRef, useState } from 'react' +import type { TourListItem } from '@trek/shared' +import { ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, ChevronDown, Clock3, Mountain, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react' +import type { Day } from '../../../types' +import { useTranslation } from '../../../i18n' +import { useSettingsStore } from '../../../store/settingsStore' +import { formatDate } from '../../../utils/formatters' +import { formatDistance, formatElevation } from '../../../utils/units' +import ConfirmDialog from '../../shared/ConfirmDialog' +import CustomSelect from '../../shared/CustomSelect' +import { DialogButton } from '../../shared/DialogShell' +import EmptyState from '../../shared/EmptyState' +import { useToast } from '../../shared/Toast' +import { BarButton } from '../../Planner/planParts' +import TourListRow from '../TourListRow' +import ElevationProfile from '../../shared/ElevationProfile' +import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation' +import type { RouteProfileFocus } from '../../../utils/routeGeometry' +import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner' +import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' + +const STATUS_KEY: Partial> = { + dirty: 'tours.planner.status.dirty', + routing: 'tours.planner.status.routing', + 'routing-failed': 'tours.planner.status.routingFailed', + 'enriching-elevation': 'tours.planner.status.elevation', + 'elevation-failed': 'tours.planner.status.elevationFailed', + ready: 'tours.planner.status.ready', + saving: 'tours.planner.status.saving', + saved: 'tours.planner.saved', +} + +function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) { + return t(`tours.planner.${role}`) +} + +function profileFocusLabel(focus: RouteProfileFocus, t: (key: string, params?: Record) => string, unit: 'metric' | 'imperial'): string { + return t('tours.planner.inspector.profileFocus', { + distance: formatDistance(focus.distanceMeters / 1000, unit), + elevation: focus.elevationMeters == null ? '—' : formatElevation(focus.elevationMeters, unit), + }) +} + +export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) { + const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) + const { t } = useTranslation() + const distanceUnit = useSettingsStore(state => state.settings.distance_unit) + const [confirmDiscard, setConfirmDiscard] = useState(false) + const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null) + const alpineAcknowledged = useRef(false) + const isSaving = planner.isSaving + const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status] + const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed' + const requestClose = () => { + if (isSaving) return + if (planner.hasUnsavedChanges) setConfirmDiscard(true) + else planner.returnToNeutral() + } + + if (planner.mode.type === 'neutral') { + return ( +
    + +

    {t('tours.planner.neutralBody')}

    +
    + } disabled={!canEdit} onClick={() => { if (canEdit) planner.startNewTour() }}> + {t('tours.planner.newTour')} + +
    +

    {t('tours.planner.neutralHint')}

    +
    + )} + /> + + ) + } + + if (planner.mode.type === 'view-gpx') { + const { tour } = planner.readOnlyGpxTour + const analysis = planner.readOnlyGpxAnalysis + return ( + <> +
    +
    + planner.returnToNeutral()} className="mt-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + +
    +

    {t('tours.planner.gpxTitle')}

    +

    {tour.name}

    +

    {hikeSourceBadgeLabel(tour, t)}

    +
    +
    +
    +

    {t('tours.detail.gpxReadOnly')}

    + {analysis ? ( +
    +

    {t('tours.planner.inspector.title')}

    +
    + + {tour.duration != null && } + {analysis.minEle != null && } + {analysis.maxEle != null && } + {analysis.gain != null && } + {analysis.loss != null && } +
    + {analysis.distanceIndexedProfileSamples.length >= 2 ? ( +
    +
    +

    {t('tours.planner.inspector.elevation')}

    + +
    + +
    + ) : ( +

    {t('tours.planner.inspector.elevationPlaceholder')}

    + )} +
    + ) : ( +

    {t('tours.planner.gpxNoGeometry')}

    + )} +
    +
    + + + ) + } + + const isSavedEdit = planner.mode.type === 'edit-saved' + + return ( + <> +
    +
    +
    +
    + requestClose()} disabled={isSaving} className="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + +
    +

    {t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')}

    +

    {t('tours.planner.mapHint')}

    +
    +
    +
    + { 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="mt-1 w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content outline-none focus:border-accent" + /> + + { + if (!canEdit || isSaving) return + const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6 + if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value) + else planner.setMaxHikingDifficulty(value) + }} + options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))} + size="sm" + style={{ marginTop: 4 }} + /> + {planner.maxHikingDifficulty === 3 &&

    {t('tours.planner.difficulty.t3Warning')}

    } +
    + +
    +

    {t('tours.planner.waypoints')}

    + {planner.waypoints.length === 0 ? ( +
    + +

    {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')} + /> +
    + + ) +} + +function TourPlannerMetric({ label, value }: { label: string; value: string }) { + return
    {label}
    {value}
    +} + +function ElevationProfileToggle({ id, expanded, onToggle, t }: { id: string; expanded: boolean; onToggle: () => void; t: (key: string) => string }) { + 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 +} + +function plannerDayLabel(day: Day, index: number, locale: string, t: (key: string, params?: Record) => string): string { + const dayNumber = t('dayplan.dayN', { n: index + 1 }) + const date = formatDate(day.date, locale) + return [dayNumber, date, day.title].filter(Boolean).join(' · ') +} + +export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) { + const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) + const { t, locale } = useTranslation() + const toast = useToast() + const distanceUnit = useSettingsStore(state => state.settings.distance_unit) + const [pendingTour, setPendingTour] = useState(null) + const [dayPickerOpen, setDayPickerOpen] = useState(false) + const [assigning, setAssigning] = useState(false) + const isSaving = planner.isSaving + const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null + const routeAnalysis = planner.routeAnalysis + + const openTour = async (tour: TourListItem) => { + const opened = await planner.openTour(tour) + if (!opened) toast.error(t('tours.planner.openFailed')) + } + + const selectTour = (tour: TourListItem) => { + if (tourSource(tour) === 'gpx') onViewGpxTour(tour) + else void openTour(tour) + } + + const requestOpenTour = (tour: TourListItem) => { + if (isSaving) return + if (planner.hasUnsavedChanges) { + setPendingTour(tour) + return + } + selectTour(tour) + } + + const assignSavedTour = async (dayId: number) => { + const savedTour = planner.saveOutcome + if (!canAssign || !savedTour) return + setAssigning(true) + try { + const assigned = await onAssignToDay(savedTour.place_id, dayId) + if (assigned === false) return + setDayPickerOpen(false) + } finally { + setAssigning(false) + } + } + + return ( + + ) +} 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 000000000..f37c5f2bb --- /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 000000000..393bc1f5e --- /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 000000000..f66a6a274 --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -0,0 +1,1038 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const { 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' + +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('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() + }) +}) diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts new file mode 100644 index 000000000..2dc405f1e --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -0,0 +1,622 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { TourCreateResponse, TourListItem, TourWaypointRole, TourMaxHikingDifficulty } from '@trek/shared' +import { toursApi } from '../../../api/client' +import { randomId } from '../../../utils/randomId' +import type { TourBaseLayer } from '../../Map/MapLayerSwitcher' +import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry' +import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting' +import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' + +export interface TourPlannerWaypoint { + id: string + lat: number + lng: number + role: TourWaypointRole +} + +export type TourPlannerStatus = + | 'empty' + | 'dirty' + | 'routing' + | 'routing-failed' + | 'enriching-elevation' + | 'elevation-failed' + | 'ready' + | 'saving' + | 'saved' + +interface DraftPayload { + version: 1 | 2 + name: string + waypoints: TourPlannerWaypoint[] + maxHikingDifficulty?: TourMaxHikingDifficulty + editingPlaceId?: number | null +} + +interface HistoryState { + past: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }> + future: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }> +} + +const ROUTE_DEBOUNCE_MS = 450 +const HISTORY_LIMIT = 30 + +function hasCompleteElevation(points: TourElevationPoint[] | null): points is [number, number, number][] { + return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2])) +} + +export function normalizePlannerWaypoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] { + return points.map((point, index) => ({ + ...point, + role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via', + })) +} + +export function tourWaypointKey(points: TourPlannerWaypoint[]): string { + return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}`).join('|') +} + +function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean { + return tourWaypointKey(a) === tourWaypointKey(b) +} + +function validDraft(value: unknown): value is DraftPayload { + if (!value || typeof value !== 'object') return false + const draft = value as Partial + 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 abortRef = useRef(null) + const generationRef = useRef(0) + const draftRevisionRef = useRef(0) + const saveRequestIdRef = useRef(0) + const saveInFlightRef = useRef(false) + const mountedRef = useRef(false) + const contextGenerationRef = useRef(0) + const detailRequestIdRef = useRef(0) + const detailAbortRef = useRef(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) + }, [invalidateDetailRequest, interruptRouting, storageKey]) + + const startNewTour = useCallback((confirmed = false) => { + if (!editPermissionRef.current || saveInFlightRef.current) return + const currentDraftIsEmpty = editingPlaceId === null && waypoints.length === 0 && name.trim().length === 0 + if (!confirmed && hasUnsavedChanges && !currentDraftIsEmpty) { + setNewTourConfirmationOpen(true) + return + } + returnToNeutral() + setHasUnsavedChanges(true) + }, [editingPlaceId, hasUnsavedChanges, name, returnToNeutral, waypoints.length]) + + const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), []) + const toggleElevationProfile = useCallback(() => { + if (elevationProfileExpanded) setRouteProfileFocus(null) + setElevationProfileExpanded(expanded => !expanded) + }, [elevationProfileExpanded]) + const discard = useCallback(() => returnToNeutral(), [returnToNeutral]) + + const viewGpxTour = useCallback((tour: TourListItem, routeGeometry: string | null) => { + if (saveInFlightRef.current) return + invalidateDetailRequest() + contextGenerationRef.current += 1 + returnToNeutral() + setReadOnlyGpxTour({ tour, routeGeometry }) + }, [invalidateDetailRequest, returnToNeutral]) + + const closeGpxTour = useCallback(() => { + invalidateDetailRequest() + contextGenerationRef.current += 1 + setRouteProfileFocus(null) + setReadOnlyGpxTour(null) + }, [invalidateDetailRequest]) + + const openTour = useCallback(async (tour: TourListItem) => { + if (!mountedRef.current || !activeRef.current || saveInFlightRef.current) return false + detailAbortRef.current?.abort() + const controller = new AbortController() + detailAbortRef.current = controller + const requestId = ++detailRequestIdRef.current + const submittedTripId = String(tripId) + const submittedPlaceId = tour.place_id + const submittedContextGeneration = contextGenerationRef.current + const submittedRevision = draftRevisionRef.current + const submittedEditingPlaceId = editingPlaceIdRef.current + const submittedGpxPlaceId = readOnlyGpxTourRef.current?.tour.place_id ?? null + detailPlaceIdRef.current = submittedPlaceId + const requestIsCurrent = () => mountedRef.current + && activeRef.current + && requestId === detailRequestIdRef.current + && detailPlaceIdRef.current === submittedPlaceId + && String(tripIdRef.current) === submittedTripId + && contextGenerationRef.current === submittedContextGeneration + && draftRevisionRef.current === submittedRevision + && editingPlaceIdRef.current === submittedEditingPlaceId + && (readOnlyGpxTourRef.current?.tour.place_id ?? null) === submittedGpxPlaceId + setRouteProfileFocus(null) + setOpeningTourId(tour.place_id) + setError(null) + try { + const result = await toursApi.detail(tripId, tour.place_id, controller.signal) + if (!requestIsCurrent() || saveInFlightRef.current) return false + if (result.waypoints.length < 2) { + setError('open') + return false + } + + interruptRouting() + const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({ + id: randomId(), + lat: point.lat, + lng: point.lng, + role: point.role, + }))) + localStorage.removeItem(storageKey) + contextGenerationRef.current += 1 + draftRevisionRef.current += 1 + setNameState(result.tour.name) + setWaypoints(nextWaypoints) + setMaxHikingDifficultyState(result.tour.max_hiking_difficulty ?? 2) + waypointsRef.current = nextWaypoints + difficultyRef.current = result.tour.max_hiking_difficulty ?? 2 + setSelectedWaypointId(nextWaypoints[0]?.id ?? null) + setHistory({ past: [], future: [] }) + setStatus('dirty') + setRoute(null) + setEnrichedGeometry(null) + setRouteForKey(null) + setDistanceMeters(null) + setDurationSeconds(null) + setHasUnsavedChanges(false) + setDraftRestored(false) + setEditingPlaceId(result.tour.place_id) + editingPlaceIdRef.current = result.tour.place_id + setSaveOutcome(null) + setReadOnlyGpxTour(null) + return true + } catch { + if (requestIsCurrent() && !saveInFlightRef.current) setError('open') + return false + } finally { + if (mountedRef.current && requestId === detailRequestIdRef.current) { + detailPlaceIdRef.current = null + if (detailAbortRef.current === controller) detailAbortRef.current = null + setOpeningTourId(null) + } + } + }, [interruptRouting, storageKey, tripId]) + + const retry = useCallback(() => { + if (waypointsRef.current.length < 2) return + interruptRouting() + setError(null) + setStatus('dirty') + setRetryToken(value => value + 1) + }, [interruptRouting]) + + const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]) + const routeAnalysis = useMemo( + () => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)), + [enrichedGeometry, route], + ) + const readOnlyGpxAnalysis = useMemo( + () => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry), + [readOnlyGpxTour?.routeGeometry], + ) + const mode = useMemo(() => selectTourPlannerMode({ readOnlyGpxTour, editingPlaceId, hasUnsavedChanges }), [editingPlaceId, hasUnsavedChanges, readOnlyGpxTour]) + const focusOwner = mode.type === 'view-gpx' || mode.type === 'edit-saved' ? `${mode.type}:${mode.placeId}` : mode.type + useEffect(() => { setRouteProfileFocus(null) }, [focusOwner, routeAnalysis, readOnlyGpxAnalysis, readOnlyGpxTour]) + + useEffect(() => { + if (waypoints.length < 2) { + interruptRouting() + setStatus(waypoints.length === 0 ? 'empty' : 'dirty') + setRoute(null) + setRouteForKey(null) + setEnrichedGeometry(null) + setDistanceMeters(null) + setDurationSeconds(null) + setRouteProfileFocus(null) + setError(null) + return + } + + const generation = ++generationRef.current + const controller = new AbortController() + abortRef.current?.abort() + abortRef.current = controller + const timer = window.setTimeout(async () => { + setStatus('routing') + const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty) + if (controller.signal.aborted || generation !== generationRef.current) return + if (!routed) { + setStatus('routing-failed') + setError('routing') + return + } + + setRoute(routed.coordinates) + setDistanceMeters(routed.distanceMeters) + setDurationSeconds(routed.durationSeconds) + setRouteForKey(waypointKey) + setEnrichedGeometry(null) + setStatus('enriching-elevation') + + const enriched = await enrichTourElevations(routed.coordinates, controller.signal) + if (controller.signal.aborted || generation !== generationRef.current) return + if (!enriched) { + setStatus('elevation-failed') + setError('elevation') + return + } + setEnrichedGeometry(enriched) + if (!hasCompleteElevation(enriched)) { + setStatus('elevation-failed') + setError('elevation') + return + } + setStatus('ready') + setError(null) + }, ROUTE_DEBOUNCE_MS) + + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints]) + + useEffect(() => { + if (!hasUnsavedChanges) return + const draft: DraftPayload = { version: 2, name, waypoints, maxHikingDifficulty, editingPlaceId } + localStorage.setItem(storageKey, JSON.stringify(draft)) + }, [editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, storageKey, waypoints]) + + useEffect(() => { + if (!hasUnsavedChanges) return + const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' } + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [hasUnsavedChanges]) + + const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready' + && routeForKey === waypointKey && hasCompleteElevation(enrichedGeometry) + + const save = useCallback(async () => { + if (!mountedRef.current || saveInFlightRef.current || !editPermissionRef.current || !canSave || !hasCompleteElevation(enrichedGeometry)) return null + invalidateDetailRequest() + saveInFlightRef.current = true + const requestId = ++saveRequestIdRef.current + const submittedRevision = draftRevisionRef.current + const submittedContextGeneration = contextGenerationRef.current + const submittedTripId = String(tripId) + const submittedPlaceId = editingPlaceId + const payload = { + name: name.trim(), + tour_type: 'hike', + route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]), + waypoints: waypoints.map((point, sequence) => ({ + lat: point.lat, + lng: point.lng, + role: point.role, + sequence, + })), + max_hiking_difficulty: maxHikingDifficulty, + duration_seconds: durationSeconds, + } as const + setStatus('saving') + setIsSaving(true) + setError(null) + const stillCurrent = () => mountedRef.current + && requestId === saveRequestIdRef.current + && String(tripIdRef.current) === submittedTripId + && editingPlaceIdRef.current === submittedPlaceId + && contextGenerationRef.current === submittedContextGeneration + && draftRevisionRef.current === submittedRevision + try { + const result = submittedPlaceId === null + ? await toursApi.create(tripId, payload) + : await toursApi.update(tripId, submittedPlaceId, payload) + if (stillCurrent()) { + localStorage.removeItem(storageKey) + setHasUnsavedChanges(false) + setStatus('saved') + setHistory({ past: [], future: [] }) + setEditingPlaceId(result.tour.place_id) + editingPlaceIdRef.current = result.tour.place_id + setSaveOutcome(result.tour) + } + if (mountedRef.current && String(tripIdRef.current) === submittedTripId) { + try { await onSaved?.(result) } catch { return result } + } + return result + } catch { + if (stillCurrent()) { + setStatus('ready') + setError('save') + } + return null + } finally { + if (mountedRef.current && requestId === saveRequestIdRef.current) { + saveInFlightRef.current = false + setIsSaving(false) + } + } + }, [canSave, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, storageKey, tripId, waypoints]) + + return { + canEdit, canAssign, + name, setName, maxHikingDifficulty, setMaxHikingDifficulty, + waypoints, selectedWaypointId, setSelectedWaypointId, + addWaypoint, removeWaypoint, moveWaypoint, + undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0, + status, error, retry, isSaving, + route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, + routeProfileFocus, setRouteProfileFocus, + mapBaseLayer, setMapBaseLayer, + mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour, + startNewTour, newTourConfirmationOpen, cancelNewTour, + elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, + editingPlaceId, openingTourId, openTour, + saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral, + } +} + +export type TourPlannerController = ReturnType diff --git a/client/src/components/Tours/tourPresentation.test.ts b/client/src/components/Tours/tourPresentation.test.ts new file mode 100644 index 000000000..31f7e209c --- /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 000000000..c26294ff8 --- /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 000000000..818516b3a --- /dev/null +++ b/client/src/components/Tours/toursI18n.test.tsx @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@trek/shared/i18n/en' +import de from '@trek/shared/i18n/de' +import fr from '@trek/shared/i18n/fr' +import type { TourListItem } from '@trek/shared' +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { useTranslation } from '../../i18n' +import { analyzeRouteGeometry } from '../../utils/routeGeometry' +import TourListRow from './TourListRow' +import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels' +import type { TourPlannerController } from './planner/useTourPlanner' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const tour: TourListItem = { + place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4, + elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: false, caution: false, +} + +function toursText(strings: typeof en, key: string): string { + const value = strings[key] + if (typeof value !== 'string') throw new Error(`Missing Tours text: ${key}`) + return value +} + +function planner(overrides: Partial): TourPlannerController { + 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.getByTitle(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument()) + expect(screen.getByTitle(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument() + rerender() + expect(screen.getByText(toursText(de, 'tours.planner.saved'))).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })) + expect(screen.getByLabelText(toursText(de, 'tours.addToDay.pickDay'))).toBeInTheDocument() + assertNoRawToursKeys() + }) + + it('German routing, elevation, save errors and alpine acknowledgement use translated copy', async () => { + seedStore(useSettingsStore, { settings: { language: 'de' } }) + const { rerender } = render() + 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 000000000..564f114aa --- /dev/null +++ b/client/src/components/Tours/toursPermissions.test.tsx @@ -0,0 +1,208 @@ +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') }) + +const state = vi.hoisted(() => ({ + trip: { id: 7, user_id: 1 } as { id: number; user_id: number } | null, + user: { id: 2, role: 'user' } as { id: number; role: string } | null, + importGpx: vi.fn(), + loadTrip: vi.fn(), + create: vi.fn(), + update: vi.fn(), + detail: vi.fn(), + route: vi.fn(), + elevation: vi.fn(), +})) + +vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip }) })) +vi.mock('../../store/authStore', () => ({ useAuthStore: (selector: (store: unknown) => unknown) => selector({ user: state.user }) })) +vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (selector: (store: unknown) => unknown) => selector({ settings: { distance_unit: 'metric' } }) })) +vi.mock('../../api/client', () => ({ toursApi: { importGpx: state.importGpx, create: state.create, update: state.update, detail: state.detail } })) +vi.mock('./planner/tourRouting', () => ({ routeWalkingTour: state.route, enrichTourElevations: state.elevation })) +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key, locale: 'en' }), translateApiError: () => 'error' })) +vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }) })) +vi.mock('../shared/ConfirmDialog', () => ({ default: () => null })) +vi.mock('../shared/EmptyState', () => ({ default: ({ action }: { action?: React.ReactNode }) =>
    {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: '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 save = screen.getByRole('button', { name: 'tours.planner.save' }) as HTMLButtonElement + expect(save.disabled).toBe(!canEdit) + fireEvent.click(save) + expect(planner.save).toHaveBeenCalledTimes(canEdit ? 1 : 0) + expect((screen.getByRole('button', { name: 'tours.planner.assignToDay' }) as HTMLButtonElement).disabled).toBe(!canAssign) + const picker = screen.queryByRole('combobox', { name: 'tours.addToDay: Ridge' }) + expect(Boolean(picker)).toBe(canAssign) + if (picker) fireEvent.change(picker, { target: { value: '3' } }) + expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0) + fireEvent.click(screen.getByRole('option', { name: /Ridge/ })) + expect(planner.openTour).toHaveBeenCalledWith(tour) + }) + it.each([[false, false], [true, false], [false, true], [true, true]])('planner edit=%s assign=%s gates direct create and retained save after revocation', async (canEdit, canAssign) => { + vi.useFakeTimers() + state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 }) + state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]]) + state.create.mockResolvedValue({ tour, waypoints: [] }) + const { result, rerender } = renderHook(({ edit }) => useTourPlanner({ tripId: 7, canEdit: edit, canAssign }), { initialProps: { edit: canEdit } }) + act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(49, 12) }) + await act(async () => { await vi.advanceTimersByTimeAsync(450) }) + expect(result.current.canSave).toBe(canEdit) + expect(result.current.waypoints.length).toBe(canEdit ? 2 : 0) + await act(async () => { await result.current.save() }) + expect(state.create).toHaveBeenCalledTimes(canEdit ? 1 : 0) + expect(state.update).not.toHaveBeenCalled() + if (canEdit) { + act(() => result.current.setName('Updated')) + const retainedSave = result.current.save + rerender({ edit: false }) + await act(async () => { await retainedSave(); await result.current.save() }) + expect(state.update).not.toHaveBeenCalled() + } + }) + it.each([[false, false], [true, false], [false, true], [true, true]])('sidebar edit=%s assign=%s keeps reads available and gates writes', async (canEdit, canAssign) => { + const assign = vi.fn() + const select = vi.fn() + const { container } = render() + 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 000000000..726ee18c8 --- /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/CustomSelect.consumers.test.tsx b/client/src/components/shared/CustomSelect.consumers.test.tsx new file mode 100644 index 000000000..3b12cce7e --- /dev/null +++ b/client/src/components/shared/CustomSelect.consumers.test.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { TourListItem } from '@trek/shared' +import { TranslationProvider } from '../../i18n/TranslationContext' +import { TourPlannerRail, TourPlannerToursRail } from '../Tours/planner/TourPlannerPanels' +import type { TourPlannerController } from '../Tours/planner/useTourPlanner' +import ToursSidebar from '../Tours/ToursSidebar' +import type { Day } from '../../types' +import CustomSelect from './CustomSelect' + +vi.mock('../../api/client', () => ({ toursApi: {} })) +vi.mock('../../store/settingsStore', () => ({ + useSettingsStore: (selector: (state: { settings: { language: string; distance_unit: string } }) => unknown) => + selector({ settings: { language: 'en', distance_unit: 'metric' } }), +})) +vi.mock('../../store/tripStore', () => ({ + useTripStore: (selector: (state: { loadTrip: () => Promise }) => unknown) => + selector({ loadTrip: vi.fn() }), +})) + +const tour: TourListItem = { + place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4, + elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: false, caution: false, +} +const days = [{ id: 7, trip_id: 1, day_number: 1, title: 'Summit day', date: '2026-05-15' } as Day] + +function planner(overrides: Partial = {}): TourPlannerController { + return { + mode: { type: 'new-draft' }, maxHikingDifficulty: 2, + setMaxHikingDifficulty: vi.fn(), name: '', setName: vi.fn(), + status: 'empty', waypoints: [], route: [], routeAnalysis: null, + distanceMeters: 0, durationSeconds: 0, hasUnsavedChanges: false, + elevationProfileExpanded: false, readOnlyGpxTour: null, + saveOutcome: null, returnToNeutral: vi.fn(), startNewTour: vi.fn(), + ...overrides, + } as TourPlannerController +} + +describe('CustomSelect consumer contracts', () => { + it('renders the real labelled T1-T6 selector and updates difficulty through the merged select', async () => { + const user = userEvent.setup() + const setDifficulty = vi.fn() + function DifficultyRail() { + const [difficulty, setDifficultyState] = useState(2) + return { setDifficulty(next); setDifficultyState(next) }, + })} canEdit /> + } + render() + const trigger = screen.getByRole('button', { name: 'Maximum trail difficulty' }) + expect(screen.getByLabelText('Maximum trail difficulty')).toBe(trigger) + expect(trigger).toHaveAttribute('id', 'tour-planner-difficulty') + expect(document.querySelectorAll('[id="tour-planner-difficulty"]')).toHaveLength(1) + await user.click(trigger) + const menu = within(document.getElementById(trigger.getAttribute('aria-controls')!)!) + for (const difficulty of [1, 2, 3, 4, 5, 6]) { + expect(menu.getByRole('button', { name: new RegExp(`^T${difficulty}`) })).toBeInTheDocument() + } + await user.click(menu.getByRole('button', { name: /^T3/ })) + expect(setDifficulty).toHaveBeenCalledWith(3) + expect(trigger).toHaveTextContent('T3') + expect(trigger).toHaveFocus() + await user.click(trigger) + await user.click(screen.getByRole('button', { name: /^T4/ })) + expect(screen.getByRole('heading', { name: 'Enable alpine routing?' })).toBeInTheDocument() + expect(setDifficulty).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Enable alpine routing' })) + expect(setDifficulty).toHaveBeenLastCalledWith(4) + }) + + it('keeps the labelled native post-save day picker resolving numeric day ids', async () => { + const user = userEvent.setup() + const onAssignToDay = vi.fn().mockResolvedValue(undefined) + render() + await user.click(screen.getByRole('button', { name: 'Assign to a day' })) + const picker = screen.getByRole('combobox', { name: 'Pick a day' }) + expect(picker).toHaveAttribute('id', 'tour-planner-assign-day') + await user.selectOptions(picker, '7') + expect(onAssignToDay).toHaveBeenCalledWith(42, 7) + }) + + it('keeps the real ADD Tours All/Unplanned/Planned filters working', async () => { + const user = userEvent.setup() + render() + expect(screen.getAllByRole('option')).toHaveLength(2) + const filter = screen.getByRole('button', { name: 'Show' }) + await user.click(filter) + await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ })) + expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull() + await user.click(filter) + await user.click(screen.getByRole('button', { name: /Planned\s*1/ })) + expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull() + await user.click(filter) + await user.click(screen.getByRole('button', { name: /All\s*2/ })) + expect(screen.getAllByRole('option')).toHaveLength(2) + }) + + it('preserves the Places filter select contract with labels, badges and compact sizing', async () => { + const user = userEvent.setup() + const setFilter = vi.fn() + function PlacesFilter() { + const [filter, updateFilter] = useState('all') + return { setFilter(next); updateFilter(next) }} + options={[ + { value: 'all', label: 'All', badge: '3' }, + { value: 'unplanned', label: 'Unplanned', badge: '2' }, + { value: 'planned', label: 'Planned', badge: '1' }, + ]} + /> + } + render() + await user.click(screen.getByRole('button', { name: /^All\s*3$/ })) + await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ })) + expect(setFilter).toHaveBeenCalledWith('unplanned') + expect(screen.getByRole('button', { name: /^Unplanned\s*2$/ })).toHaveFocus() + await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ })) + await user.click(screen.getByRole('button', { name: /^Planned\s*1$/ })) + expect(setFilter).toHaveBeenLastCalledWith('planned') + }) +}) \ No newline at end of file diff --git a/client/src/components/shared/CustomSelect.test.tsx b/client/src/components/shared/CustomSelect.test.tsx index da081bbc7..4e355f0d4 100644 --- a/client/src/components/shared/CustomSelect.test.tsx +++ b/client/src/components/shared/CustomSelect.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '../../../tests/helpers/render'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import CustomSelect from './CustomSelect'; @@ -193,4 +193,101 @@ describe('CustomSelect', () => { // Dropdown should not be in the DOM — options remain hidden expect(screen.queryByText('Apple')).toBeNull(); }); + + it('applies the external label id only to the trigger', () => { + render(<>); + expect(screen.getByRole('button', { name: 'Fruit' })).toHaveAttribute('id', 'fruit'); + expect(document.querySelectorAll('[id="fruit"]')).toHaveLength(1); + }); + + it.each([undefined, 'fruit'])('keeps the explicit accessible name with id %s', id => { + render(); + const trigger = screen.getByRole('button', { name: 'Choose fruit' }); + expect(trigger).toHaveAttribute('aria-label', 'Choose fruit'); + expect(Array.from(trigger.attributes).filter(attribute => attribute.name === 'aria-label')).toHaveLength(1); + if (id) expect(trigger).toHaveAttribute('id', id); + }); + + it('associates two independent portals without duplicate ids', async () => { + const user = userEvent.setup(); + const { container } = render(<> + + + ); + const first = screen.getByRole('button', { name: 'First fruit' }); + const second = screen.getByRole('button', { name: 'Second fruit' }); + await user.click(first); + const firstMenuId = first.getAttribute('aria-controls')!; + expect(container.contains(document.getElementById(firstMenuId))).toBe(false); + await user.click(second); + const secondMenuId = second.getAttribute('aria-controls')!; + expect(firstMenuId).not.toBe(secondMenuId); + expect(document.getElementById(secondMenuId)).toHaveAttribute('role', 'group'); + const ids = Array.from(document.querySelectorAll('[id]'), element => element.id); + expect(new Set(ids).size).toBe(ids.length); + await user.click(within(document.getElementById(secondMenuId)!).getByRole('button', { name: 'Cherry' })); + expect(onChange).toHaveBeenCalledWith('cherry'); + expect(second).toHaveFocus(); + expect(second).toHaveAttribute('aria-expanded', 'false'); + }); + + it.each(['{Enter}', ' '])('supports Tab, %s, arrows, selection and Escape', async openingKey => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Choose fruit' }); + await user.tab(); + expect(trigger).toHaveFocus(); + await user.keyboard(openingKey); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await user.keyboard('{ArrowDown}{ArrowDown}'); + expect(screen.getByRole('button', { name: 'Banana' })).toHaveFocus(); + await user.keyboard('{ArrowUp}{Enter}'); + expect(onChange).toHaveBeenCalledWith('apple'); + expect(trigger).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(screen.getByRole('button', { name: 'Cherry' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('group')).toBeNull(); + }); + + it('keeps searchable focus and supports arrow navigation from the search field', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Choose fruit' }); + await user.click(trigger); + expect(screen.getByPlaceholderText('...')).toHaveFocus(); + await user.type(screen.getByPlaceholderText('...'), 'ban'); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onChange).toHaveBeenCalledWith('banana'); + expect(trigger).toHaveFocus(); + }); + + it('keeps compact rendering, icon, badge and disabled keyboard behavior', async () => { + const user = userEvent.setup(); + render( }]} />); + const trigger = screen.getByRole('button', { name: 'Disabled fruit' }); + expect(trigger).toBeDisabled(); + expect(trigger.style.padding).toBe('8px 12px'); + expect(within(trigger).getByText('3')).toBeInTheDocument(); + expect(within(trigger).getByTestId('fruit-icon')).toBeInTheDocument(); + await user.tab(); + await user.keyboard('{Enter}{ArrowDown}'); + expect(screen.queryByRole('group')).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([ + ['{ArrowDown}', 'Apple'], + ['{ArrowUp}', 'Cherry'], + ])('keyboard opening a searchable menu with %s retains option focus', async (key, option) => { + const user = userEvent.setup(); + render(); + await user.tab(); + await user.keyboard(key); + expect(screen.getByRole('button', { name: option })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('button', { name: 'Choose fruit' })).toHaveFocus(); + }); }); diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx index 873565c1d..cb3a631f7 100644 --- a/client/src/components/shared/CustomSelect.tsx +++ b/client/src/components/shared/CustomSelect.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react' +import React, { useState, useRef, useEffect, useId } from 'react' import { createPortal } from 'react-dom' import { ChevronDown, Check } from 'lucide-react' import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition' @@ -16,6 +16,7 @@ interface SelectOption { } interface CustomSelectProps { + ariaLabel?: string value: string | number onChange: (value: string | number) => void options?: SelectOption[] @@ -70,6 +71,7 @@ function menuRoom(anchor: HTMLElement | null, left: number): number { } export default function CustomSelect({ + ariaLabel, value, onChange, options = [], @@ -84,8 +86,11 @@ export default function CustomSelect({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const ref = useRef(null) + const triggerRef = useRef(null) const dropRef = useRef(null) const searchRef = useRef(null) + const keyboardDirection = useRef<'first' | 'last' | null>(null) + const menuId = useId() // Follows the trigger while the sheet scrolls and while the on-screen keyboard // resizes the viewport, instead of freezing at the rect measured on open (#1999). @@ -93,12 +98,20 @@ export default function CustomSelect({ useEffect(() => { if (!open || !searchable || !searchRef.current) return - searchRef.current.focus() + if (!keyboardDirection.current) searchRef.current.focus() // Focusing raises the keyboard on a phone; scroll the trigger up so the list // it just opened is not left underneath it (#2000). scrollAnchorIntoView(ref.current) }, [open, searchable]) + useEffect(() => { + if (!open || !keyboardDirection.current) return + const buttons = dropRef.current?.querySelectorAll('button') + const direction = keyboardDirection.current + keyboardDirection.current = null + if (buttons?.length) buttons[direction === 'last' ? buttons.length - 1 : 0].focus() + }, [open]) + useEffect(() => { const handleClick = (e: MouseEvent) => { if (ref.current?.contains(e.target as Node)) return @@ -137,13 +150,46 @@ export default function CustomSelect({ const sm = size === 'sm' + const closeMenu = () => { + setOpen(false) + setSearch('') + triggerRef.current?.focus() + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (disabled) return + if (event.key === 'Escape' && open) { + event.preventDefault() + event.stopPropagation() + closeMenu() + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const backwards = event.key === 'ArrowUp' + if (!open) { + keyboardDirection.current = backwards ? 'last' : 'first' + setSearch('') + setOpen(true) + } else { + const buttons = Array.from(dropRef.current?.querySelectorAll('button') ?? []) + const current = buttons.findIndex(button => button === document.activeElement) + const next = current < 0 ? (backwards ? buttons.length - 1 : 0) : (current + (backwards ? -1 : 1) + buttons.length) % buttons.length + buttons[next]?.focus() + } + } + } + return (
    {/* Trigger */} } + 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 000000000..84d07335f --- /dev/null +++ b/client/src/components/shared/DetailShell.tsx @@ -0,0 +1,85 @@ +import type { CSSProperties, ReactNode } from 'react' +import { X } from 'lucide-react' + +interface DetailShellProps { + header: ReactNode + children: ReactNode + footer?: ReactNode + onClose: () => void + closeLabel: string + leftWidth?: number + rightWidth?: number + headerStyle?: CSSProperties + contentStyle?: CSSProperties + closeButtonClassName?: string + testId?: string +} + +/** Shared map-detail surface: panel-aware positioning, chrome and scrolling. */ +export default function DetailShell({ + header, + children, + footer, + onClose, + closeLabel, + leftWidth = 0, + rightWidth = 0, + headerStyle, + contentStyle, + closeButtonClassName = '', + testId, +}: DetailShellProps) { + return ( +
    +
    +
    + {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 000000000..3a6d425f0 --- /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 000000000..33e25ec9a --- /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 000000000..9fa8c60de --- /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 d2ed63272..701d72111 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 62e734fc6..b46c8e4ab 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 050cd3c0a..8803e895d 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 000000000..541713fa5 --- /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 e002dea33..a2efe0bec 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 000000000..113bf90d5 --- /dev/null +++ b/client/src/hooks/useTourPlaceIds.test.ts @@ -0,0 +1,241 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import { toursApi } from '../api/client' +import { useTourPlaceIds } from './useTourPlaceIds' + +vi.mock('../api/client', () => ({ + toursApi: { list: vi.fn() }, +})) + +const tour: TourListItem = { + place_id: 42, + name: 'Ridge walk', + tour_type: 'hike', + distance: 4, + elevation_gain: 100, + elevation_loss: 80, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +function deferred() { + 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('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 000000000..e8882dbea --- /dev/null +++ b/client/src/hooks/useTourPlaceIds.ts @@ -0,0 +1,104 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import type { TourListItem } from '@trek/shared' +import { toursApi } from '../api/client' + +export interface ToursInvalidation { + placeIds?: number[] + removedPlaceIds?: number[] +} + +/** + * Place ids that are tours (a `tours` facet row exists for them), used to keep + * them out of the Places pool while the Tours addon is on. + * The single discriminator stays the `tours` + * facet row \u2014 this hook just mirrors it into a client-side Set for the + * Places filter, it does not add anything to the `places` table. + * + * Only fetches while `enabled` is true: with the addon off there is nothing to + * exclude, and the Places pool must stay byte-for-byte identical to today. + */ +export function useTourPlaceIds(tripId: number, enabled: boolean) { + const [tourData, setTourData] = useState<{ tripId: number; tours: TourListItem[] } | null>(null) + const [requestState, setRequestState] = useState<{ + tripId: number + loading: boolean + error: boolean + } | null>(null) + const [pendingPlaceIds, setPendingPlaceIds] = useState<{ tripId: number; ids: Set } | null>(null) + const requestIdRef = useRef(0) + const mountedRef = useRef(false) + const activeRequestRef = useRef({ tripId, enabled }) + activeRequestRef.current = { tripId, enabled } + + const reload = useCallback(async () => { + if (!enabled || !mountedRef.current + || activeRequestRef.current.tripId !== tripId + || !activeRequestRef.current.enabled) return + const requestId = ++requestIdRef.current + setRequestState({ tripId, loading: true, error: false }) + try { + const response = await toursApi.list(tripId) + if (!mountedRef.current || requestId !== requestIdRef.current) return + setTourData({ tripId, tours: response.tours }) + setPendingPlaceIds(current => current?.tripId === tripId ? null : current) + setRequestState({ tripId, loading: false, error: false }) + } catch { + if (!mountedRef.current || requestId !== requestIdRef.current) return + // Data is keyed by trip, so this retains only a same-trip last-good list. + setRequestState({ tripId, loading: false, error: true }) + } + }, [tripId, enabled]) + + const invalidateTourPlaceIds = useCallback((change?: ToursInvalidation) => { + if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId + || !activeRequestRef.current.enabled) return + const added = change?.placeIds ?? [] + const removed = change?.removedPlaceIds ?? [] + if (added.length || removed.length) { + setPendingPlaceIds(current => { + const ids = new Set(current?.tripId === tripId ? current.ids : []) + for (const id of removed) ids.delete(id) + for (const id of added) ids.add(id) + return ids.size ? { tripId, ids } : null + }) + } + void reload() + }, [enabled, tripId, reload]) + + useEffect(() => { + mountedRef.current = true + void reload() + return () => { + mountedRef.current = false + requestIdRef.current += 1 + } + }, [reload]) + + const upsertTour = useCallback((tour: TourListItem) => { + if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId + || !activeRequestRef.current.enabled) return + requestIdRef.current += 1 + setTourData(current => ({ + tripId, + tours: [tour, ...(current?.tripId === tripId ? current.tours : []) + .filter(item => item.place_id !== tour.place_id)], + })) + setRequestState({ tripId, loading: false, error: false }) + }, [enabled, tripId]) + + const tourDataReady = !enabled || tourData?.tripId === tripId + const visibleTours = enabled && tourData?.tripId === tripId ? tourData.tours : [] + const pendingIds = enabled && pendingPlaceIds?.tripId === tripId ? pendingPlaceIds.ids : [] + const currentRequest = requestState?.tripId === tripId ? requestState : null + + return { + tours: visibleTours, + toursLoading: enabled && (currentRequest?.loading ?? false), + tourLoadError: enabled && (currentRequest?.error ?? false), + tourDataReady, + tourPlaceIds: new Set([...visibleTours.map(tour => tour.place_id), ...pendingIds]), + reloadTourPlaceIds: reload, + invalidateTourPlaceIds, + upsertTour, + } +} diff --git a/client/src/hooks/useTripWebSocket.test.ts b/client/src/hooks/useTripWebSocket.test.ts new file mode 100644 index 000000000..2247cd002 --- /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 5a226de79..0a1d82512 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/screens/settings/MSettingsGeneral.tsx b/client/src/mobile/screens/settings/MSettingsGeneral.tsx index 59a0011b9..5b9d5cad7 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 61cb96c17..bf902183a 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 6bd4a1d42..d69e8f9d6 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -21,7 +21,10 @@ import { useTranslation } from '../../../../i18n' import type { Place } from '../../../../types' import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet' import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet' +import MPlacesToursModeSwitch from './MPlacesToursModeSwitch' +import MToursSelectionList from './MToursSelectionList' import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel' +import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds' /** * Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter @@ -35,11 +38,19 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro * it. The header ellipsis opens the 'import' sheet (sheets/MImportSheet). */ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) { - const { t, places, categories, assignments, days, trip } = planner + const { t, places, categories, assignments, days, trip, tripId } = planner // The planner hook carries `t` but not the locale; day labels need both. const { locale } = useTranslation() const canEditPlaces = planner.can('place_edit', trip) const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) + const toursEnabled = useAddonStore(s => s.isEnabled('tours')) + // Places that are tours (a `tours` facet row exists) — kept out of the + // Places pool entirely while the addon is on. + const { tourPlaceIds } = useTourPlaceIds(tripId, toursEnabled) + // Top level of the places browser, mirroring the desktop right add-panel's + // Places <-> Tours switch placement. + const [toursMode, setToursMode] = useState(false) + const [toursFilter, setToursFilter] = useState<'all' | 'unplanned' | 'planned'>('all') const filter = useTripStore(s => s.placesFilter) const setFilter = useTripStore(s => s.setPlacesFilter) @@ -61,7 +72,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) if (shell.browseFromEdit) setFilter('unplanned') }, [shell.browseFromEdit, setFilter]) - const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places]) + const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled]) useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [filter, hasTracks, setFilter]) @@ -73,10 +84,10 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) [assignments, planner.tripAccommodations, planner.reservations], ) const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days]) - const filtered = useMemo( - () => filterPool(places, { filter, categoryFilters, search, plannedIds }), - [places, filter, categoryFilters, search, plannedIds], - ) + const filtered = useMemo(() => { + const pool = toursEnabled ? places.filter(p => !tourPlaceIds.has(p.id)) : places + return filterPool(pool, { filter, categoryFilters, search, plannedIds }) + }, [places, filter, categoryFilters, search, plannedIds, toursEnabled, tourPlaceIds]) // A bulk delete (or a remote edit) can remove selected places — drop the // stale ids so the toolbar count stays honest. @@ -120,10 +131,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) // Compare the ids, not just the counts: a place removed remotely while another // one is selected keeps the sizes equal without the sets matching. - const allSelected = filtered.length > 0 && filtered.every(p => selectedIds.has(p.id)) + const selectablePlaces = filtered.filter(place => !planner.isTourPlace(place.id)) + const allSelected = selectablePlaces.length > 0 && selectablePlaces.every(p => selectedIds.has(p.id)) const toggleAllVisible = () => { if (allSelected) setSelectedIds(new Set()) - else setSelectedIds(new Set(filtered.map(p => p.id))) + else setSelectedIds(new Set(selectablePlaces.map(p => p.id))) } const toggleCategory = (catId: string) => { @@ -142,17 +154,39 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) const openRow = (place: Place) => { if (selectMode) { + if (planner.isTourPlace(place.id)) { + planner.handlePlaceClick(place.id) + return + } toggleSelected(place.id) return } shell.openSheet('bract', { placeId: place.id, dayPicker: false }) } + const selectedTours = [...selectedIds].filter(planner.isTourPlace) + const hasUncategorized = places.some(p => p.category_id == null) return (
    + {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)}>
    @@ -349,7 +383,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 000000000..1f61a6cc6 --- /dev/null +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx @@ -0,0 +1,137 @@ +import { render, screen } from '../../../../../tests/helpers/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TourListItem } from '@trek/shared' +import en from '@trek/shared/i18n/en' +import de from '@trek/shared/i18n/de' +import { resetAllStores, seedStore } from '../../../../../tests/helpers/store' +import { useSettingsStore } from '../../../../store/settingsStore' +import type { TripPlanner, MTripShellApi } from '../MTripShell' +import MToursSelectionList from './MToursSelectionList' + +const tour: TourListItem = { + place_id: 42, + name: 'Camping tour with a deliberately long readable name', + tour_type: 'hike', + distance: 12.5, + elevation_gain: 420, + elevation_loss: 390, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + tour_group_id: null, + has_waypoints: true, + max_hiking_difficulty: 2, + planned: false, + caution: false, +} + +const reloadTourPlaceIds = vi.fn().mockResolvedValue(undefined) +const handlePlaceClick = vi.fn() +const openSheet = vi.fn() + +function planner(): TripPlanner { + return { + t: (key: string) => { + const value = en[key] + return typeof value === 'string' ? value : key + }, + tripId: 7, + tours: [tour], + selectedPlaceId: null, + selectedPlace: null, + selectedTour: null, + selectedDayId: null, + selectedAssignmentId: null, + assignments: {}, + days: [], + files: [], + canUploadFiles: false, + reloadTourPlaceIds, + handlePlaceClick, + } as unknown as TripPlanner +} + +function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi { + return { + sheet, + openSheet, + } as unknown as MTripShellApi +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) +}) + +describe('MToursSelectionList', () => { + it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => { + const germanPlanner = { ...planner(), t: (key: string) => { + const value = de[key] ?? en[key] + return typeof value === 'string' ? value : key + } } + const { rerender } = render() + + 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 metrics atomic and gives the mobile day action a separate full-width row', () => { + 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() + }) + + 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 000000000..c5d490cd7 --- /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, canEdit, canAssign: assignPermission }: MToursSelectionListProps) { + const { canAssign } = useTourPermissions({ tripId: planner.tripId, canEdit, canAssign: assignPermission }) + const { t } = planner + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const tours: TourListItem[] = planner.tours + + const filtered = tours.filter(tr => { + if (filter === 'unplanned') return !tr.planned + if (filter === 'planned') return tr.planned + return true + }) + + if (filtered.length === 0) { + return ( + <> +

    + {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 46cb596f0..0fc365be6 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-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 fcbb39dbf..87696d063 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 3d65cfc1e..768601bf0 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 c1cd48b99..f4d2317a6 100644 --- a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx @@ -26,6 +26,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps const canEditPlaces = planner.can('place_edit', planner.trip) const canEditDays = planner.can('day_edit', planner.trip) + const isTourPlace = (placeId: number) => planner.isTourPlace(placeId) const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) const openSavePicker = useSaveToCollectionStore(s => s.open) @@ -56,9 +57,9 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps openSavePicker(collectionTargetFromPlace(place)) } - const assignToDay = (dayId: number) => { - shell.closeSheet() - planner.handleAssignToDay(place.id, dayId) + const assignToDay = async (dayId: number) => { + const assigned = await planner.handleAssignToDay(place.id, dayId) + if (assigned !== false) shell.closeSheet() } const deletePlace = () => { @@ -84,13 +85,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps {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 +913,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} + onDelete={() => handleDeletePlace(selectedPlace.id)} + 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} + onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }} + /> +
    +
    , + document.body + )} + {mobileSidebarOpen && createPortal(
    setMobileSidebarOpen(null)}>
    e.stopPropagation()}> @@ -996,6 +1076,100 @@ 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} + 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)} + onViewGpxTour={tour => { + const place = places.find(item => item.id === tour.place_id) + tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null) + }} + /> + + )} +
    +
    +
    + )} + {activeTab === 'transports' && (
    @@ -1206,16 +1380,22 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onClose={() => setDeletePlaceId(null)} onConfirm={confirmDeletePlace} title={t('common.delete')} - message={deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')} + message={deletePlaceIsTour + ? [t('tours.delete.confirmBody'), deletePlaceNote].filter(Boolean).join(' ') + : deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')} + confirmLabel={deletePlaceIsTour ? t('tours.delete.confirmAction') : undefined} /> 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(), + mode: { type: 'neutral' } as unknown, + isSaving: false, + waypoints: [] as unknown, + routeProfileFocus: null as unknown, + readOnlyGpxTour: null as unknown, + readOnlyGpxAnalysis: null as unknown, +})) function stub(name: string, testId?: string) { return (props: Props) => { @@ -86,6 +98,37 @@ vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('book vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('airtrailImport') })) vi.mock('../components/Planner/ReservationsPanel', () => ({ default: stub('reservationsPanel', 'reservations-panel') })) vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') })) +vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') })) +vi.mock('../components/Tours/planner/useTourPlanner', () => ({ + useTourPlanner: () => ({ + route: null, + waypoints: plannerActions.waypoints, + routeProfileFocus: plannerActions.routeProfileFocus, + name: '', + startNewTour: vi.fn(), + newTourConfirmationOpen: false, + cancelNewTour: vi.fn(), + elevationProfileExpanded: true, + toggleElevationProfile: vi.fn(), + newDraftGeneration: 0, + mode: plannerActions.mode, + isSaving: plannerActions.isSaving, + selectedWaypointId: null, + editingPlaceId: null, + saveOutcome: null, + hasUnsavedChanges: false, + setSelectedWaypointId: vi.fn(), + mapBaseLayer: 'map', + setMapBaseLayer: vi.fn(), + openTour: plannerActions.openTour, + viewGpxTour: plannerActions.viewGpxTour, + addWaypoint: plannerActions.addWaypoint, + closeGpxTour: vi.fn(), + readOnlyGpxTour: plannerActions.readOnlyGpxTour, + readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis, + discard: vi.fn(), + }), +})) vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') })) vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') })) @@ -355,6 +398,16 @@ function baseState(): HookState { selectedPlace: null, dayOrderMap: {}, dayPlaces: [], + toursEnabled: false, + toursMode: true, + setToursMode: vi.fn(), + tours: [], + toursLoading: false, + tourDataReady: true, + tourPlaceIds: new Set(), + reloadTourPlaceIds: vi.fn(async () => undefined), + upsertTour: vi.fn(), + selectedTour: null, mapTileUrl: 'https://tiles/{z}/{x}/{y}.png', fontStyle: { fontFamily: 'var(--font-system)' }, splashDone: true, @@ -379,12 +432,238 @@ beforeEach(() => { resetAllStores() for (const key of Object.keys(captured)) delete captured[key] confirmDialogs.length = 0 + plannerActions.viewGpxTour.mockReset() + plannerActions.addWaypoint.mockReset() + plannerActions.openTour.mockReset() + plannerActions.mode = { type: 'neutral' } + plannerActions.isSaving = false + plannerActions.waypoints = [] + plannerActions.routeProfileFocus = null + plannerActions.readOnlyGpxTour = null + plannerActions.readOnlyGpxAnalysis = null hookState = baseState() seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) }) useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never) }) describe('TripPlannerPage — shell', () => { + it('UI-R2: Tours Planner rails use shared resize bounds, touch handles and keyboard nudges', () => { + const { container } = renderPage({ activeTab: 'tour-planner', toursEnabled: true, + enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true } }) + const handles = container.querySelectorAll('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('FE-PAGE-TPW-063: uses the last normal Plan viewport before falling back to trip Places', () => { + const first = buildPlace({ id: 81, lat: 47.1, lng: 11.2 }) + const second = buildPlace({ id: 82, lat: 47.3, lng: 11.6 }) + const view = renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true, places: [first, second] }) + + expect(props('map').focusPoints).toEqual([[47.1, 11.2], [47.3, 11.6]]) + expect(props('map').route).toBeNull() + expect(props('map').plannerWaypoints).toEqual([]) + expect(props('map').onMapClick).toBeUndefined() + plannerActions.mode = { type: 'new-draft' } + view.rerender() + 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 })) + 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('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 000000000..2aee4a945 --- /dev/null +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as ts from 'typescript' +import { test, vi } from 'vitest' + +vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in static tests') }) +vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in static tests') }) +vi.mock('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in static tests') }) +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in static tests') }) + +const pagePath = 'client/src/pages/TripPlannerPage.tsx' +const hookPath = 'client/src/pages/tripPlanner/useTripPlanner.ts' +const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8') +const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8') +const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) +const page = parse(pagePath, pageText) +const hook = parse(hookPath, hookText) + +function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] { + const matches: ts.Node[] = [] + function visit(node: ts.Node) { + if (predicate(node)) matches.push(node) + ts.forEachChild(node, visit) + } + visit(source) + return matches +} + +function initializer(name: string): ts.Expression { + const declaration = collect(hook, node => ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) && node.name.text === name)[0] + assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name) + return declaration.initializer +} + +function jsxTags(source: ts.SourceFile, name: string) { + return collect(source, node => (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) + && node.tagName.getText(source) === name) +} + +test('B4A Core testability debt: verify source without importing the page, hook, config, or database', () => { + for (const source of [page, hook]) { + const diagnostics = ts.transpileModule(source.text, { + fileName: source.fileName, + reportDiagnostics: true, + compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2022 }, + }).diagnostics ?? [] + assert.equal(diagnostics.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error).length, 0, source.fileName) + assert.doesNotMatch(source.text, /^(<<<<<<<|=======|>>>>>>>)/m) + } + assert.doesNotMatch(pageText, /\bnarrowPanels\b/) + const self = parse('b4a-wiring.test.ts', readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/b4a-wiring.test.ts'), 'utf8')) + const imports = collect(self, ts.isImportDeclaration).map(node => { + assert.ok(ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) + return node.moduleSpecifier.text + }) + assert.deepEqual(imports, ['node:assert/strict', 'node:fs', 'node:path', 'typescript', 'vitest']) + assert.equal(collect(self, node => ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword).length, 0) +}) + +test('B4A Tours tab is readable, desktop-only, and absent with addon OFF', () => { + const tabs = initializer('TRIP_TABS') + const branches = collect(tabs, node => ts.isConditionalExpression(node) + && node.whenTrue.getText(hook).includes("id: 'tour-planner'")) + assert.equal(branches.length, 1) + const branch = branches[0] + assert.ok(ts.isConditionalExpression(branch)) + assert.equal(branch.condition.getText(hook), 'enabledAddons.tours && !isMobile') + assert.equal(branch.whenFalse.getText(hook), '[]') + assert.match(branch.whenTrue.getText(hook), /desktopOnly: true/) + assert.doesNotMatch(tabs.getText(hook), /\bcan\b|canPlaceEdit|canDayEdit/) + assert.match(hookText, /!validTabIds\.includes\(activeTab\)[\s\S]*?setActiveTab\('plan'\)/) + assert.match(pageText, /activeTab === 'tour-planner' && enabledAddons\.tours && !isMobile/) +}) + +test('B4A selectedTour is derived only from explicit place selection and addon state', () => { + const selection = initializer('selectedTour') + assert.ok(ts.isConditionalExpression(selection)) + assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId') + assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/) + assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword) +}) + +test('B4A approved permissions reach every Tours surface and gate map edits', () => { + assert.match(pageText, /const canPlaceEdit = can\('place_edit', trip\)/) + assert.match(pageText, /const canDayEdit = can\('day_edit', trip\)/) + assert.match(pageText, /useTourPlanner\(\{\s*tripId,\s*canEdit: canPlaceEdit,\s*canAssign: canDayEdit,/) + for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) { + const tags = jsxTags(page, name) + assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1) + for (const tag of tags) { + assert.match(tag.getText(page), /canEdit=\{canPlaceEdit\}/) + assert.match(tag.getText(page), /canAssign=\{canDayEdit\}/) + } + } + assert.match(pageText, /onMapClick=\{canPlaceEdit && !tourPlanner\.isSaving && \(tourPlanner\.mode\.type === 'new-draft' \|\| tourPlanner\.mode\.type === 'edit-saved'\)/) +}) + +test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => { + const entry = collect(page, node => ts.isFunctionDeclaration(node) + && node.name?.text === 'TripPlannerPageDesktop')[0] + assert.ok(entry) + assert.equal(collect(entry, node => ts.isCallExpression(node) + && node.expression.getText(page) === 'useTourPlanner').length, 1) + assert.doesNotMatch(hookText, /\buseTourPlanner\b/) + for (const mode of ['view-gpx', 'new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`)) + assert.match(pageText, /readOnlyGpxAnalysis\?\.routeCoordinates/) + assert.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/) + assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) + assert.match(pageText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/) + assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/) +}) + +test('B4A retains non-Tours panels and protected page integrations', () => { + for (const name of ['HelpAnchor', 'RoadtripSidebar', 'RoadtripCorridorPanel', 'DayDetailPanel', 'PlaceInspector', 'BookingDetailPopup']) { + assert.ok(jsxTags(page, name).length > 0, name) + } + assert.ok(jsxTags(page, 'PanelResizeHandle').length >= 2) + for (const tab of ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab']) { + const panels = collect(page, node => ts.isJsxExpression(node) + && !!node.expression && ts.isBinaryExpression(node.expression) + && node.expression.left.getText(page) === `activeTab === '${tab}'`) + assert.equal(panels.length, 1, tab) + } + assert.match(hookText, /dayDetail && days\.some\(d => d\.id === dayDetail\.id\) \? dayDetail : null/) + assert.match(hookText, /!stop\.automaticNight && !stop\.bookend/) +}) \ No newline at end of file diff --git a/client/src/pages/tripPlanner/useDayClear.ts b/client/src/pages/tripPlanner/useDayClear.ts index af146b88b..25abc8fc1 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 cfa4f8a26..547bc4197 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 ──────────────────────────────────────────────────────────────────── @@ -1262,6 +1264,62 @@ describe('useTripPlanner — place CRUD', () => { expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 900, 2) }) + it('permanently deletes a Tour without Place restore and forgets only its stale assignment undo', async () => { + const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 }) + const unrelatedUndo = vi.fn() + const tourAssignmentUndo = vi.fn() + seedTrip({ places: [tourPlace] }) + actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] }) + + const { result } = await renderPlanner() + act(() => { + result.current.pushUndo('Unrelated place edit', unrelatedUndo) + result.current.pushUndo('Remove Tour from day', tourAssignmentUndo, [7], [1]) + result.current.handleDeletePlace(1) + }) + expect(result.current.deletePlaceIsTour).toBe(true) + + await act(async () => { await result.current.confirmDeletePlace() }) + + expect(actions.deletePlace).toHaveBeenCalledWith(42, 1) + expect(actions.addPlace).not.toHaveBeenCalled() + expect(result.current.lastActionLabel).toBe('Unrelated place edit') + expect(result.current.canUndo).toBe(true) + await act(async () => { await result.current.undo() }) + expect(unrelatedUndo).toHaveBeenCalledTimes(1) + expect(tourAssignmentUndo).not.toHaveBeenCalled() + }) + + it('uses the server facet result to suppress Undo for a dormant Tour when Tours is off', async () => { + const dormantPlace = buildPlace({ id: 1, name: 'Dormant route' }) + seedTrip({ places: [dormantPlace] }) + actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] }) + + const { result } = await renderPlanner() + act(() => { result.current.handleDeletePlace(1) }) + await act(async () => { await result.current.confirmDeletePlace() }) + + expect(result.current.enabledAddons.tours).toBe(false) + expect(actions.addPlace).not.toHaveBeenCalled() + expect(result.current.canUndo).toBe(false) + }) + + it('does not offer a partial bulk Undo when the deleted selection includes a Tour', async () => { + const ordinary = buildPlace({ id: 1, name: 'Ordinary place' }) + const tourPlace = buildPlace({ id: 2, name: 'Tour', tour_place_id: 2 }) + seedTrip({ places: [ordinary, tourPlace] }) + actions.deletePlacesMany.mockResolvedValue({ deleted: [1, 2], count: 2, tourPlaceIds: [2] }) + + const { result } = await renderPlanner() + act(() => { result.current.setDeletePlaceIds([1, 2]) }) + expect(result.current.deletePlacesIncludeTours).toBe(true) + await act(async () => { await result.current.confirmDeletePlaces() }) + + expect(actions.deletePlacesMany).toHaveBeenCalledWith(42, [1, 2]) + expect(actions.addPlace).not.toHaveBeenCalled() + expect(result.current.canUndo).toBe(false) + }) + it('FE-TP-HOOK-056: confirmDeletePlace is a no-op until a place is queued', async () => { seedTrip() @@ -1454,6 +1512,101 @@ describe('useTripPlanner — place CRUD', () => { }) describe('useTripPlanner — day plan CRUD', () => { + it('refreshes Tours only after a successful Tour assignment write', async () => { + const unplanned: TourListItem = { + place_id: 1, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100, + elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1, + tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false, + } + const planned = { ...unplanned, planned: true } + useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true }) + const listTours = vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [unplanned] }) + const place = buildPlace({ id: 1, tour_place_id: 1 }) + seedTrip({ places: [place] }) + let resolveAssignment!: (value: { id: number }) => void + const assignment = new Promise<{ id: number }>(resolve => { resolveAssignment = resolve }) + actions.assignPlaceToDay.mockReturnValue(assignment) + + const { result } = await renderPlanner() + await waitFor(() => expect(result.current.tours).toEqual([unplanned])) + listTours.mockClear().mockResolvedValue({ tours: [planned] }) + let assignPromise!: Promise + 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 }) @@ -1511,8 +1664,8 @@ describe('useTripPlanner — day plan CRUD', () => { expect(actions.assignPlaceToDay).toHaveBeenNthCalledWith(2, 42, 7, 1, 1) }) - it('FE-TP-HOOK-066: removing an assignment can be undone back to its old position', async () => { - const place = buildPlace({ id: 1, lat: 1, lng: 2 }) + it('FE-TP-HOOK-066: removing a Tour assignment can be undone without deleting the Tour', async () => { + const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1 }) seedTrip({ places: [place], assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })] }, @@ -1522,6 +1675,8 @@ describe('useTripPlanner — day plan CRUD', () => { await act(async () => { await result.current.handleRemoveAssignment(7, 10) }) expect(actions.removeAssignment).toHaveBeenCalledWith(42, 7, 10) + expect(actions.deletePlace).not.toHaveBeenCalled() + expect(actions.addPlace).not.toHaveBeenCalled() await act(async () => { await result.current.undo() }) expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 4) @@ -2494,6 +2649,24 @@ describe('useTripPlanner — misc state', () => { expect(result.current.deletePlaceId).toBeNull() }) + it('routes mobile Place-editor entry for a dormant Tour to read-only Tour detail', async () => { + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 390 }) + try { + const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 }) + seedTrip({ places: [tourPlace] }) + const { result } = await renderPlanner() + + act(() => result.current.openPlaceEditor(tourPlace)) + + expect(result.current.isMobile).toBe(true) + expect(result.current.selectedPlaceId).toBe(1) + expect(result.current.showPlaceForm).toBe(false) + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalWidth }) + } + }) + it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => { seedTrip() const { result } = await renderPlanner() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 3f2bcf401..387476dd1 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' @@ -129,7 +131,7 @@ export function useTripPlanner() { const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canUploadFiles = can('file_upload', trip) - const { pushUndo, undo, forgetDay, canUndo, lastActionLabel } = usePlannerHistory() + const { pushUndo, undo, forgetDay, forgetPlace, canUndo, lastActionLabel } = usePlannerHistory() // A step that could not be taken back says so instead of claiming it was. const handleUndo = useCallback(async () => { @@ -139,7 +141,7 @@ export function useTripPlanner() { else if (undone) toast.info(t('undo.done', { action: label ?? '' })) }, [undo, lastActionLabel, toast]) - const [enabledAddons, setEnabledAddons] = useState>({ 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. @@ -225,7 +227,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 => { @@ -242,6 +244,7 @@ export function useTripPlanner() { const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? [])) const TRIP_TABS = [ { id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map }, + ...(enabledAddons.tours && !isMobile ? [{ id: 'tour-planner', label: t(TRIP_TAB_LABEL_KEYS['tour-planner']), icon: Mountain, desktopOnly: true }] : []), { id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train }, { id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket }, // Phone only: the desktop reaches the drive through the mode switch beside the @@ -282,7 +285,7 @@ export function useTripPlanner() { setActiveTab('plan') sessionStorage.setItem(`trip-tab-${tripId}`, 'plan') } - }, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded]) + }, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile]) const handleTabChange = (rawTabId: string): void => { // A core tab a plugin replaced is gone from the bar, but a programmatic jump @@ -313,6 +316,21 @@ export function useTripPlanner() { startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax, } = useResizablePanels() const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection() + const toursEnabled = useAddonStore(state => state.isEnabled('tours')) + const [toursMode, setToursMode] = useState(false) + const previousToursModeRef = useRef(false) + const { tours, toursLoading, tourDataReady, tourPlaceIds, reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour } = useTourPlaceIds(tripId, toursEnabled) + + useEffect(() => { + if (!toursEnabled) { + setToursMode(false) + previousToursModeRef.current = false + return + } + const enteredToursMode = toursMode && !previousToursModeRef.current + previousToursModeRef.current = toursMode + if (enteredToursMode) setSelectedPlaceId(null) + }, [toursEnabled, toursMode, setSelectedPlaceId]) const [dayDetail, setShowDayDetail] = useState(null) // A day deleted while its panel is open, here or by a fellow traveller, takes // the panel along instead of leaving it on a day that is gone. @@ -575,6 +593,10 @@ export function useTripPlanner() { const mobilePlacesScrollTopRef = useRef(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. * @@ -705,7 +727,7 @@ export function useTripPlanner() { return () => window.removeEventListener('accommodations:refresh', onRefresh) }, [loadAccommodations]) - useTripWebSocket(tripId) + useTripWebSocket(tripId, invalidateTourPlaceIds) // Same filter the places sidebar renders — shared via the store so tab // switches can't desync the marker set from the filter UI (#1541). @@ -776,7 +798,7 @@ export function useTripPlanner() { // Road trip mode already draws the whole trip its own way, so the overview stands // down there rather than drawing a second set of lines over it. const overviewActive = overviewShown && !roadtripMode - const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive) + const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places) // Road trip mode reads the whole trip, not the selected day, so it owns its own legs. // Passing no days while the mode is off keeps it inert — no routing requests, no state. @@ -2343,6 +2365,10 @@ export function useTripPlanner() { // place's lone assignment to hydrate & persist its times; with 0 or 2+ // assignments the time is ambiguous and the modal hides the fields (#1247). const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => { + if (isMobile && isTourPlace(place.id)) { + handlePlaceClick(place.id, preferredAssignmentId) + return + } if (!can('place_edit', trip)) return if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') { const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id) @@ -2368,7 +2394,7 @@ export function useTripPlanner() { setPlaceFormDayId(null) setServiceStopForm(false) setShowPlaceForm(true) - }, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days]) + }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days]) /** * How long the drive stands here, for every stop alike. @@ -2395,11 +2421,16 @@ export function useTripPlanner() { as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index })) ) try { - await tripActions.deletePlace(tripId, deletePlaceId) + const deletion = await tripActions.deletePlace(tripId, deletePlaceId) + const deletedTour = Array.isArray(deletion?.tourPlaceIds) + ? deletion.tourPlaceIds.includes(deletePlaceId) + : isTourPlace(deletePlaceId) + void reloadTourPlaceIds() if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) updateRouteForDay(selectedDayId) toast.success(t('trip.toast.placeDeleted')) - if (capturedPlace) { + if (deletedTour) forgetPlace(deletePlaceId) + else if (capturedPlace) { pushUndo(t('undo.deletePlace'), async () => { const newPlace = await tripActions.addPlace(tripId, { name: capturedPlace.name, @@ -2420,7 +2451,7 @@ export function useTripPlanner() { }) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo]) + }, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace]) const confirmDeletePlaces = useCallback(async (ids?: number[]) => { const targetIds = ids ?? deletePlaceIds @@ -2431,12 +2462,17 @@ export function useTripPlanner() { as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index })) ) try { - await tripActions.deletePlacesMany(tripId, targetIds) + const deletion = await tripActions.deletePlacesMany(tripId, targetIds) + const deletedTourIds = Array.isArray(deletion?.tourPlaceIds) + ? deletion.tourPlaceIds.filter(id => targetIds.includes(id)) + : targetIds.filter(isTourPlace) + void reloadTourPlaceIds() if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null) if (!ids) setDeletePlaceIds(null) updateRouteForDay(selectedDayId) toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length })) - if (capturedPlaces.length > 0) { + if (deletedTourIds.length > 0) deletedTourIds.forEach(forgetPlace) + else if (capturedPlaces.length > 0) { pushUndo(t('undo.deletePlaces'), async () => { const live = new Set(useTripStore.getState().days.map(d => d.id)) for (const place of capturedPlaces) { @@ -2453,7 +2489,7 @@ export function useTripPlanner() { }) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo]) + }, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace]) const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => { if (!ids.length) return @@ -2483,26 +2519,36 @@ export function useTripPlanner() { const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { const target = dayId || selectedDayId - if (!target) { toast.error(t('trip.toast.selectDay')); return } + if (!target) { toast.error(t('trip.toast.selectDay')); return false } const place = places.find(p => p.id === placeId) // A place with a start of its own is drawn by it, so it is stored there too, the // way a stop moved over from another day is. Without one it goes where it was put. const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position const plan = viasAfterInsert(target, slot, place) + let assignment: Awaited> try { - const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot) - toast.success(t('trip.toast.assignedToDay')) - if (plan) await roadtripVias.reanchor(target, plan) - updateRouteForDay(target) - if (assignment?.id) { - const capturedAssignmentId = assignment.id - const capturedTarget = target - pushUndo(t('undo.assignPlace'), async () => { - await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId) - }, [capturedTarget]) - } - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert]) + assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + return false + } + if (isTourPlace(placeId)) await reloadTourPlaceIds() + toast.success(t('trip.toast.assignedToDay')) + if (assignment?.id) { + const capturedAssignmentId = assignment.id + const capturedTarget = target + pushUndo(t('undo.assignPlace'), async () => { + await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId) + if (isTourPlace(placeId)) await reloadTourPlaceIds() + }, [capturedTarget], [placeId]) + } + if (plan) { + try { await roadtripVias.reanchor(target, plan) } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } + updateRouteForDay(target) + return true + }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert]) /** * Moves a stop from the day list onto another day, at a row of that day or at its @@ -2525,6 +2571,7 @@ export function useTripPlanner() { const state = useTripStore.getState() const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId) const capturedPlaceId = capturedAssignment?.place?.id + const removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId) const capturedOrderIndex = capturedAssignment?.order_index ?? 0 // Worked out before the delete, while the day still has the stop the vias // were measured against. `after_order_index` is a POSITION, so taking a stop @@ -2540,6 +2587,7 @@ export function useTripPlanner() { : reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length) try { await tripActions.removeAssignment(tripId, dayId, assignmentId) + if (removedTour) await reloadTourPlaceIds() if (plan) await roadtripVias.reanchor(dayId, plan) updateRouteForDay(dayId) if (capturedPlaceId != null) { @@ -2547,15 +2595,18 @@ export function useTripPlanner() { const capturedPos = capturedOrderIndex pushUndo(t('undo.removeAssignment'), async () => { await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos) - }, [capturedDayId]) + if (removedTour) await reloadTourPlaceIds() + }, [capturedDayId], [capturedPlaceId]) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf]) + }, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace]) const handleReorder = useCallback((dayId: number, orderedIds: number[]) => { - const prevIds = (useTripStore.getState().assignments[String(dayId)] || []) + const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || [] + const prevIds = assignmentsBefore .slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))] // The rail counts anchors over routable stops only, so the plan is built in // that space. A drag here hands a whole new ordering rather than one move, // and any permutation is possible — so the anchors follow the stop they were @@ -2576,7 +2627,7 @@ export function useTripPlanner() { const capturedPrevIds = prevIds pushUndo(t('undo.reorder'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) - }, [capturedDayId]) + }, [capturedDayId], placeIdsBefore) }) .catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError'))) updateRouteForDay(dayId) @@ -2619,7 +2670,8 @@ export function useTripPlanner() { forgetDay(dayId) loadAccommodations() updateRouteForDay(useTripStore.getState().selectedDayId) - }, [loadAccommodations, updateRouteForDay, forgetDay]) + void reloadTourPlaceIds() + }, [loadAccommodations, updateRouteForDay, forgetDay, reloadTourPlaceIds]) const dayDelete = useDayDelete({ tripId, trip, days, places: allPlaces, reservations, accommodations: tripAccommodations, canEditDays: can('day_edit', trip), t, locale, toast, onDeleted: afterDayDeleted, @@ -2627,6 +2679,7 @@ export function useTripPlanner() { const dayClear = useDayClear({ tripId, days, canEditDays: can('day_edit', trip), t, locale, toast, roadtripVias, updateRouteForDay, pushUndo, + onToursChanged: reloadTourPlaceIds, }) const handleSaveReservation = async (data: Record & { title: string }) => { @@ -2890,6 +2943,9 @@ export function useTripPlanner() { } const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null + const selectedTour = toursEnabled && selectedPlaceId + ? tours.find(tour => tour.place_id === selectedPlaceId) ?? null + : null // The stops the inspector speaks for. A booked night at a day's edge stands on the // hotel's place without being a stop of the day, so it is left out: counted, the hotel's // own stop lost its stay and its day end to a second match. @@ -2959,6 +3015,8 @@ export function useTripPlanner() { leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels, startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax, selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment, + toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds, + reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour, selectedTour, showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed, stayPickerDayId, setStayPickerDayId, showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, @@ -2999,6 +3057,7 @@ export function useTripPlanner() { routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote, + isTourPlace, deletePlaceIsTour, deletePlacesIncludeTours, visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail, isMobile, isTouch, expandedDayIds, setExpandedDayIds, mapPlaces, diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts new file mode 100644 index 000000000..b83c84c35 --- /dev/null +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { TourListItem } from '@trek/shared' +import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' +import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner' +import { useTripPlanner } from './useTripPlanner' + +export function useTripPlannerPage() { + const planner = useTripPlanner() + const { + activeTab, + can, + enabledAddons, + isMobile, + places, + reloadTourPlaceIds, + setSelectedPlaceId, + t, + toast, + trip, + tripActions, + tripId, + upsertTour, + } = planner + + const canPlaceEdit = can('place_edit', trip) + const canDayEdit = can('day_edit', trip) + const tourPlanner = useTourPlanner({ + tripId, + canEdit: canPlaceEdit, + canAssign: canDayEdit, + active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile, + onSaved: async result => { + upsertTour(result.tour) + toast.success(t('tours.planner.saved')) + await tripActions.loadTrip(tripId) + void reloadTourPlaceIds() + }, + }) + + const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null) + const lastPlanMapViewportRef = useRef(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' || tourPlanner.mode.type === 'edit-saved') && tourPlanner.waypoints.length) { + return tourPlanner.waypoints.map(point => [point.lat, point.lng]) + } + return plannerFallbackFocusPoints + }, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints]) + + const route = useMemo<[number, number][][] | null>(() => { + if (tourPlanner.mode.type === 'view-gpx') { + const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates + return coordinates?.length ? [coordinates] : null + } + if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') { + return tourPlanner.route ? [tourPlanner.route] : null + } + return null + }, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route]) + + const { closeGpxTour } = tourPlanner + useEffect(() => { + if (activeTab !== 'tour-planner') closeGpxTour() + }, [activeTab, closeGpxTour]) + + const poi = usePoiExplore() + const poiViewportChangeRef = useRef(poi.onViewportChange) + poiViewportChangeRef.current = poi.onViewportChange + const captureViewport = useCallback((bounds: Bbox) => { + lastPlanMapViewportRef.current = bounds + poiViewportChangeRef.current(bounds) + }, []) + + const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => { + tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null + setSelectedPlaceId(tour?.place_id ?? null) + } + + return { + planner, + poi, + permissions: { canPlaceEdit, canDayEdit }, + tourPlanner, + tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour }, + tourMap: { route, focusPoints, captureViewport }, + } +} \ No newline at end of file diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 121ed1154..54c571d39 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.ts b/client/src/store/slices/placesSlice.ts index 578272678..bb6b94760 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 ee57591c1..082d63c90 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 000000000..a2346a5e0 --- /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 000000000..13c8066e8 --- /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 b705d0ff0..f373bda9b 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 6ea0ccbc6..9bd00202d 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 f44be1f3e..7c10f7b8a 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/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index c089a3bcb..a19bc80e8 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 3224b5d3f..20eea2e6d 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 49a192689..c1b47e909 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -35,6 +35,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] @@ -290,6 +291,23 @@ describe('MPlacesBrowser', () => { expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument() }) + it('routes a Tour row to read-only detail instead of mobile Place bulk management', () => { + const planner = makePlanner({ + places: [LOUVRE, TOUR], + isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id), + }) + renderBrowser(planner) + fireEvent.click(screen.getByRole('button', { name: 'common.select' })) + fireEvent.click(row('Louvre')) + fireEvent.click(row('Ridge walk')) + + expect(planner.handlePlaceClick).toHaveBeenCalledWith(TOUR.id) + expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'common.deselectAll' })).toBeInTheDocument() + }) + it('FE-MOB-PBROW-020b: a failed bulk delete keeps the selection for a retry', async () => { const planner = makePlanner() ;(planner.confirmDeletePlaces as ReturnType).mockRejectedValueOnce(new Error('nope')) diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx index d6205892d..a7d7d10c2 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 4d303407d..25adaed6d 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 } @@ -418,6 +419,44 @@ describe('MTripSheets', () => { expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1') }) + it.each([[false, false], [true, false], [false, true], [true, true]])( + 'keeps the mobile Tour host View/Attach-only with place_edit=%s day_edit=%s', (placeEdit, dayEdit) => { + const mobilePlace = { + id: 42, trip_id: 1, name: 'Mobile ridge', lat: 48, lng: 11, + route_geometry: '[[48,11,500],[48.01,11.01,510]]', route_color: '#ff0000', + } as never + const mobileTour = { + place_id: 42, name: 'Mobile ridge', tour_type: 'hike', distance: 1, + elevation_gain: 10, elevation_loss: 0, duration: null, difficulty: null, + wanderer_ref: null, match_confidence: 1, tour_group_id: null, + max_hiking_difficulty: 2, planned: false, caution: false, + } as TourListItem + const readOnlyFile = { + id: 19, trip_id: 1, place_id: 42, filename: 'ridge.gpx', original_name: 'ridge.gpx', + file_size: 32, mime_type: 'application/gpx+xml', url: '/api/files/19', created_at: '2026-01-01', + } + const can = vi.fn((permission: string) => permission === 'place_edit' ? placeEdit : permission === 'day_edit' && dayEdit) + renderHost({ + selectedPlace: mobilePlace, + selectedTour: mobileTour, + selectedDayId: 7, + days: [{ id: 7, trip_id: 1, day_number: 1, title: 'Day one' } as never], + files: [readOnlyFile as never], + can: can as TripPlanner['can'], + canUploadFiles: true, + }) + + expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit track colour' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '1 files' })) + expect(screen.getByText('ridge.gpx')).toBeInTheDocument() + expect(Boolean(screen.queryByRole('button', { name: /Add to day/i }))).toBe(dayEdit) + }, + ) + it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => { const { planner } = renderHost({ deletePlaceId: 101 }) expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete') @@ -428,6 +467,17 @@ describe('MTripSheets', () => { expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null) }) + it('shows permanent Tour deletion language in the mobile confirmation', () => { + const { planner } = renderHost({ deletePlaceId: 101, isTourPlace: vi.fn(() => true) }) + const confirm = screen.getByTestId('stub-confirm') + + expect(confirm).toHaveAttribute('data-confirm', 'tours.delete.confirmAction') + expect(confirm).toHaveTextContent('tours.delete.confirmBody') + expect(confirm).not.toHaveTextContent('Undo') + fireEvent.click(screen.getByText('confirm delete')) + expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1) + }) + it('FE-MOB-SHOST-029: a night booked at the place is said before the yes, and only then', () => { // The server takes the night down with the place, and the booking and the // expense with the night. The planner builds the sentence; the sheet has to diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts index ea2f4873c..ff8872b35 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/vite.config.js b/client/vite.config.js index a14484031..833c50c78 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 4337eec31..c1a12cc39 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/migrations.ts b/server/src/db/migrations.ts index 31f7dc6a8..8d8082da7 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -86,6 +86,115 @@ function createPlaceRegionsFollowPlaceTrigger(db: Database.Database): void { `); } +function schemaObjectSql(db: Database.Database, name: string): string | undefined { + return (db.prepare('SELECT sql FROM sqlite_master WHERE name = ?').get(name) as { sql: string } | undefined)?.sql; +} + +function hasSchemaColumn(db: Database.Database, table: string, column: string): boolean { + return !!db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column); +} + +function normalizedSchemaSql(sql: string): string { + return sql.replace(/IF NOT EXISTS/gi, '').replace(/\s+/g, '').replace(/;$/, ''); +} + +function matchesSchemaObject(db: Database.Database, name: string, sql: string | undefined): boolean { + const actual = schemaObjectSql(db, name); + return sql === undefined ? actual === undefined : actual !== undefined + && normalizedSchemaSql(actual) === normalizedSchemaSql(sql); +} + +function matchesToursSchema(db: Database.Database, stage: number): boolean { + const expected: Record = { + tour_types: `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 + )`, + tours: stage >= 2 ? `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 + ${stage === 4 ? ', max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)' : ''} + )` : undefined, + tour_waypoints: stage >= 3 ? `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) + )` : undefined, + idx_tours_tour_type: stage >= 2 ? 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)' : undefined, + idx_tour_waypoints_place: stage >= 3 + ? 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)' : undefined, + }; + return Object.entries(expected).every(([name, sql]) => matchesSchemaObject(db, name, sql)); +} + +function toursLineageRewind(db: Database.Database, version: number): number | undefined { + const legacy = version >= 216 && version <= 218; + const frozen = version >= 243 && version <= 246; + if (!legacy && !frozen) return undefined; + const tourTables = ['tour_types', 'tours', 'tour_waypoints'].map(name => !!schemaObjectSql(db, name)); + const upstreamMarkers = { + immich: hasSchemaColumn(db, 'users', 'immich_allow_insecure_tls'), + atlasTrigger: !!schemaObjectSql(db, 'trg_place_regions_follow_place'), + push: !!schemaObjectSql(db, 'push_subscriptions'), + routeExcluded: hasSchemaColumn(db, 'day_assignments', 'route_excluded'), + packedQuantity: hasSchemaColumn(db, 'packing_items', 'packed_quantity'), + bucketRegion: hasSchemaColumn(db, 'bucket_list', 'region_code'), + holidayFraction: hasSchemaColumn(db, 'vacay_company_holidays', 'fraction'), + entryDraft: hasSchemaColumn(db, 'journey_entries', 'is_draft'), + travelShare: hasSchemaColumn(db, 'share_tokens', 'share_travel_only'), + hiddenImages: hasSchemaColumn(db, 'share_tokens', 'share_hide_images'), + settlementNote: hasSchemaColumn(db, 'budget_settlements', 'note'), + googleUsage: !!schemaObjectSql(db, 'google_api_usage'), + templateWeight: hasSchemaColumn(db, 'packing_template_items', 'weight_grams'), + templateQuantity: hasSchemaColumn(db, 'packing_template_items', 'quantity'), + templateBag: hasSchemaColumn(db, 'packing_template_items', 'bag_name'), + journeyStatus: hasSchemaColumn(db, 'journeys', 'status_override'), + placeEmail: hasSchemaColumn(db, 'places', 'email'), + placeHours: hasSchemaColumn(db, 'places', 'opening_hours'), + photoLocation: hasSchemaColumn(db, 'journeys', 'photo_location'), + }; + const legacyMarkers = { + countries: !!schemaObjectSql(db, 'school_holiday_countries'), + regions: !!schemaObjectSql(db, 'school_holiday_regions'), + periods: !!schemaObjectSql(db, 'school_holiday_periods'), + endDay: hasSchemaColumn(db, 'day_assignments', 'end_day'), + boundaries: !!schemaObjectSql(db, 'roadtrip_day_boundaries'), + }; + const unsafe = (): never => { + throw new Error(`[migrations] Unsafe Tours migration lineage at schema version ${version}: ` + + JSON.stringify({ tourTables, upstreamMarkers, legacyMarkers }) + + '. Cursor and data were not changed; inspect this database before retrying.'); + }; + if (!tourTables.some(Boolean)) { + const laterUpstreamMarkers = Object.entries(upstreamMarkers) + .filter(([name]) => !['immich', 'atlasTrigger', 'push'].includes(name)); + if (frozen && laterUpstreamMarkers.some(([, present]) => present)) unsafe(); + if (frozen && (!upstreamMarkers.immich + || (version === 243 && (upstreamMarkers.atlasTrigger || upstreamMarkers.push)) + || (version === 244 && !upstreamMarkers.atlasTrigger && !upstreamMarkers.push) + || (version === 245 && !upstreamMarkers.push) + || (version === 246 && (!upstreamMarkers.atlasTrigger || !upstreamMarkers.push)))) unsafe(); + if (legacy && (Object.values(upstreamMarkers).some(Boolean) + || !legacyMarkers.countries || !legacyMarkers.regions || !legacyMarkers.periods + || (version === 216 && (legacyMarkers.endDay || legacyMarkers.boundaries)) + || (version === 217 && legacyMarkers.boundaries) + || (version >= 217 && !legacyMarkers.endDay) + || (version >= 218 && !legacyMarkers.boundaries))) unsafe(); + return undefined; + } + if (!matchesToursSchema(db, version - (legacy ? 215 : 242)) + || Object.values(upstreamMarkers).some(Boolean) + || (legacy && Object.values(legacyMarkers).some(Boolean)) + || (frozen && !Object.values(legacyMarkers).every(Boolean))) unsafe(); + return legacy ? 215 : 242; +} + function runMigrations(db: Database.Database): void { db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; @@ -5450,10 +5559,66 @@ function runMigrations(db: Database.Database): void { const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; if (!cols.some(c => c.name === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0'); }, + () => { + db.exec(` + 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 + ) + `); + }, + () => { + db.exec(` + 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 + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)'); + }, + () => { + db.exec(` + 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) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)'); + }, + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('tours')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'max_hiking_difficulty')) { + db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)'); + } + }, ]; - if (currentVersion < migrations.length) { - for (let i = currentVersion; i < migrations.length; i++) { + const rewindVersion = toursLineageRewind(db, currentVersion); + if (rewindVersion !== undefined && migrations.slice(rewindVersion).some(step => typeof step !== 'function')) { + throw new Error('[migrations] Unsafe Tours migration lineage: bridge replay contains a raw migration'); + } + const applyMigrations = (startVersion: number): void => { + for (let i = startVersion; i < migrations.length; i++) { console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); try { const migration = migrations[i]; @@ -5474,9 +5639,20 @@ function runMigrations(db: Database.Database): void { } } catch (err) { console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); + if (rewindVersion !== undefined) throw err; process.exit(1); } } + }; + if (rewindVersion !== undefined) { + db.transaction(() => { + db.prepare('UPDATE schema_version SET version = ?').run(rewindVersion); + applyMigrations(rewindVersion); + })(); + } else if (currentVersion < migrations.length) { + applyMigrations(currentVersion); + } + if (currentVersion < migrations.length) { console.log(`[DB] Migrations complete — schema version ${migrations.length}`); } } diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index a326d1328..e9a63d252 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -122,6 +122,7 @@ function seedAddons(db: Database.Database): void { { id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from a shared Naver Maps list', type: 'integration', icon: 'Link2', enabled: 1, sort_order: 13 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, { id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', 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', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 }, { id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 }, { id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 }, @@ -180,10 +181,24 @@ function seedAddons(db: Database.Database): void { } } +// MVP seeds/enables only `hike` — the other controlled tour_types (bike, run, ski, +// kayak, walk, food) ship in a later release and are intentionally NOT seeded yet. +function seedTourTypes(db: Database.Database): void { + try { + const insertType = db.prepare( + 'INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ); + insertType.run('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0); + } catch (err: unknown) { + console.error('Error seeding tour types:', err instanceof Error ? err.message : err); + } +} + function runSeeds(db: Database.Database): void { seedAdminAccount(db); seedCategories(db); seedAddons(db); + seedTourTypes(db); } export { runSeeds, seedAdminAccount }; diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index e71ac145a..21c872a89 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 d421e091e..50c7527e9 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -38,6 +38,7 @@ import { DayNotesModule } from './day-notes/day-notes.module'; import { AccommodationsModule } from './accommodations/accommodations.module'; import { AssignmentsModule } from './assignments/assignments.module'; import { PlacesModule } from './places/places.module'; +import { ToursModule } from './tours/tours.module'; import { TripsModule } from './trips/trips.module'; import { TodoModule } from './todo/todo.module'; import { CollabModule } from './collab/collab.module'; @@ -84,7 +85,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, ToursModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 06329b908..3cf70583a 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -100,9 +100,11 @@ export class AssignmentsService { 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 = ? `, assignmentId); @@ -136,9 +138,11 @@ export class AssignmentsService { 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.day_id = ? ORDER BY da.order_index ASC, da.created_at ASC diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index 7a08e73b0..0408f1a21 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -29,6 +29,8 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[] 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 6fe8eb545..ec0cb85da 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -109,9 +109,11 @@ export class DaysService { 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.day_id = ? ORDER BY da.order_index ASC, da.created_at ASC @@ -136,6 +138,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, @@ -195,9 +199,11 @@ export class DaysService { 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.day_id IN (${dayPlaceholders}) ORDER BY da.order_index ASC, da.created_at ASC diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 813c58f48..9bde9cc92 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -317,7 +317,7 @@ export class PlacesController { 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) this.places.onDeleted(id); // Read the linked expenses before the delete — afterwards the link is gone (#1298). const expenseIds = 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 { } this.places.onDeleted(Number(id)); const expenseIds = 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 5e75c0a06..eac34c2e5 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -280,7 +280,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(), @@ -458,7 +458,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 d75d2d3e4..6038fb876 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import type Database from 'better-sqlite3'; import { resolveCountryCodeSync } from '../atlas/atlas-geo'; import { XMLValidator } from 'fast-xml-parser'; import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared'; @@ -74,6 +75,14 @@ type Trip = TripAccess; type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null }; +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; @@ -185,9 +194,10 @@ export class PlacesService { filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, ) { 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 = ? @@ -507,37 +517,42 @@ 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 place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>( 'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId, ); const cancelled = noCancelledStays(); - 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. this.dbs.transaction(() => { + wasTour = !!this.dbs.get('SELECT 1 FROM tours WHERE place_id = ?', placeId); this.cancelStaysAt(tripId, placeId, cancelled); this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId); this.dbs.run('DELETE FROM places WHERE id = ?', placeId); }); await reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url); await reclaimPlaceImage(this.storage, place.image_url); - return { deleted: true, cancelled }; + return { deleted: true, deletedTourPlaceIds: wasTour ? [Number(placeId)] : [], 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 }; const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'); + const selectTourStmt = this.dbs.prepare('SELECT 1 FROM tours WHERE place_id = ?'); const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?'); const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?'); const deleted: number[] = []; + const deletedTourPlaceIds: number[] = []; const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; this.dbs.transaction(() => { for (const id of ids) { const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; if (!row) continue; + if (selectTourStmt.get(id)) deletedTourPlaceIds.push(id); this.cancelStaysAt(tripId, id, cancelled); deleteExpenseStmt.run(tripId, id); deleteStmt.run(id); @@ -550,7 +565,7 @@ export class PlacesService { await reclaimPhotoCache(this.photoCache, row.google_place_id, row.image_url); await reclaimPlaceImage(this.storage, row.image_url); } - return { deleted, cancelled }; + return { deleted, deletedTourPlaceIds, cancelled }; } /** @@ -759,11 +774,17 @@ export class PlacesService { } private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null { + const rows = this.prepareGpxRows(fileBuffer, opts); + if (!rows.length) return null; + return this.dbs.transaction(() => this.persistGpxRows(tripId, rows)); + } + + 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; }; @@ -782,8 +803,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) { @@ -828,8 +848,18 @@ export class PlacesService { } } - if (waypoints.length === 0) return null; + return waypoints; + } + importPreparedGpx(tripId: string, rows: PreparedGpxPlace[]): GpxImportResult { + if (!this.dbs.connection.inTransaction) throw new Error('Prepared GPX import requires an active transaction'); + const result = this.persistGpxRows(tripId, rows); + const tracks = result.places.filter(place => place.route_geometry && !place.route_color); + this.applyImportedTrackColors(this.dbs.connection, tripId, tracks); + return result; + } + + private persistGpxRows(tripId: string, waypoints: PreparedGpxPlace[]): GpxImportResult { const dedup = this.buildDedupSet(tripId); const insertStmt = this.dbs.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) @@ -837,18 +867,16 @@ export class PlacesService { `); const created: PlaceWithTags[] = []; let skipped = 0; - this.dbs.transaction(() => { - for (const wp of waypoints) { - if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { - skipped++; - continue; - } - const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); - const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; - created.push(place); - trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); + for (const wp of waypoints) { + if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { + skipped++; + continue; } - }); + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); + const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + created.push(place); + trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); + } return { places: created, count: created.length, skipped }; } @@ -1003,20 +1031,25 @@ export class PlacesService { // Read and write in one transaction so two concurrent imports cannot both // read the same set of free colours. this.dbs.transaction((conn) => { - const taken = new Set( - (conn - .prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL') - .all(tripId) as { c: string }[]).map((r) => r.c), - ); - const free = TRACK_COLORS.filter((c) => !taken.has(c)); - const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?'); - tracks.forEach((track, i) => { - // Free ones first, then wrap through the whole palette — never reuse a - // free colour twice within the same import. - const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length]; - stmt.run(color, track.id); - track.route_color = color; - }); + this.applyImportedTrackColors(conn, tripId, tracks); + }); + } + + private applyImportedTrackColors(conn: Database.Database, tripId: string, tracks: ImportedPlace[]): void { + if (tracks.length === 0) return; + const taken = new Set( + (conn + .prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL') + .all(tripId) as { c: string }[]).map((r) => r.c), + ); + const free = TRACK_COLORS.filter((c) => !taken.has(c)); + const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?'); + tracks.forEach((track, i) => { + // Free ones first, then wrap through the whole palette — never reuse a + // free colour twice within the same import. + const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length]; + stmt.run(color, track.id); + track.route_color = color; }); } 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 000000000..e5f994b38 --- /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.controller.ts b/server/src/nest/tours/tours.controller.ts new file mode 100644 index 000000000..b2f8ac7aa --- /dev/null +++ b/server/src/nest/tours/tours.controller.ts @@ -0,0 +1,76 @@ +import { + Body, Controller, Get, Headers, HttpException, Param, Post, Put, UploadedFile, UseGuards, UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +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'; + +const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }; + +/** + * /api/trips/:tripId/tours: the Tours facet, read and tours-mode GPX import. + * TripAccessGuard resolves + * :tripId (404 for no access); writes require 'place_edit'. Assignments stay + * on the existing day_edit endpoints. + */ +@Controller('api/trips/:tripId/tours') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class ToursController { + constructor(private readonly tours: ToursService) {} + + @Get() + list(@Param('tripId') tripId: string) { + return { tours: this.tours.listTours(tripId) }; + } + + @Get(':placeId') + detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) { + return this.tours.getTour(tripId, placeId); + } + + @RequirePermission('place_edit') + @Post() + create( + @Param('tripId') tripId: string, + @Body() body: TourCreateDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.tours.createTour(tripId, body, socketId); + } + + @RequirePermission('place_edit') + @Put(':placeId') + update( + @Param('tripId') tripId: string, + @Param('placeId') placeId: string, + @Body() body: TourCreateDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.tours.updateTour(tripId, placeId, body, socketId); + } + + @RequirePermission('place_edit') + @Post('import/gpx') + @UseInterceptors(FileInterceptor('file', UPLOAD)) + importGpx( + @Param('tripId') tripId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Headers('x-socket-id') socketId?: string, + ) { + if (!file) { + throw new HttpException({ error: 'No file uploaded' }, 400); + } + const result = 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.helpers.ts b/server/src/nest/tours/tours.helpers.ts new file mode 100644 index 000000000..55291722e --- /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 000000000..c5067088f --- /dev/null +++ b/server/src/nest/tours/tours.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { ToursController } from './tours.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. + */ +@Module({ + imports: [PlacesModule, PermissionsModule, AuthModule, AddonsModule], + controllers: [ToursController], + 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 000000000..f2fa36c11 --- /dev/null +++ b/server/src/nest/tours/tours.service.ts @@ -0,0 +1,280 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { TourCreateRequest, TourCreateResponse, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; +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; +} + +type TourJoinRow = { + 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 | null; planned: number; has_waypoints: 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 dbs: DatabaseService, + private readonly places: PlacesService, + ) {} + + private toItem(r: TourJoinRow): 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), + }; + } + + /** All tours (the facet + owning place) for a trip, newest first. */ + listTours(tripId: string): TourListItem[] { + const rows = this.dbs.all(` + SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain, + t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence, + t.tour_group_id, t.max_hiking_difficulty, + EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned, + EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) 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 + `, tripId); + return rows.map(r => this.toItem(r)); + } + + /** One saved tour plus the persisted routing controls needed by the editor. */ + getTour(tripId: string, placeId: string): TourDetailResponse { + const row = this.dbs.get(` + SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain, + t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence, + t.tour_group_id, t.max_hiking_difficulty, + EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned, + EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) AS has_waypoints + FROM tours t + JOIN places p ON p.id = t.place_id + WHERE p.trip_id = ? AND p.id = ? + `, tripId, placeId); + if (!row) throw new NotFoundException('Tour not found'); + + let waypoints = this.dbs.all(` + SELECT lat, lng, role, sequence + FROM tour_waypoints + WHERE place_id = ? + ORDER BY sequence + `, placeId); + // 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 = this.dbs.get<{ route_geometry: string | null }>( + 'SELECT route_geometry FROM places WHERE id = ? AND trip_id = ?', placeId, tripId, + ); + 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. */ + createTour(tripId: string, input: TourCreateRequest, socketId?: string): TourCreateResponse { + const geometry = input.route_geometry as GeometryPoint[]; + const metrics = computeTourMetrics(geometry); + const start = input.route_geometry[0]; + let placeId = 0; + + this.dbs.transaction(() => { + const place = this.dbs.run(` + INSERT INTO places (trip_id, name, lat, lng, transport_mode, route_geometry) + VALUES (?, ?, ?, ?, 'walking', ?) + `, tripId, input.name, start[0], start[1], JSON.stringify(input.route_geometry)); + placeId = Number(place.lastInsertRowid); + + this.dbs.run(` + INSERT INTO tours ( + place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence, + max_hiking_difficulty + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?) + `, placeId, input.tour_type, metrics.distanceKm, metrics.elevationGainM, + metrics.elevationLossM, input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), + input.max_hiking_difficulty); + + const insertWaypoint = this.dbs.prepare(` + INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) + VALUES (?, ?, ?, ?, ?) + `); + for (const waypoint of input.waypoints) { + insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence); + } + }); + + const tour = this.listTours(tripId).find(item => item.place_id === placeId); + const place = this.dbs.getPlaceWithTags(placeId); + if (!tour || !place) throw new Error('Created tour could not be loaded'); + const waypoints = this.dbs.all(` + SELECT lat, lng, role, sequence + FROM tour_waypoints + WHERE place_id = ? + ORDER BY sequence + `, 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, waypoints }; + } + + /** Replace a saved tour's derived route and routing controls as one write. */ + updateTour(tripId: string, placeId: string, input: TourCreateRequest, socketId?: string): TourDetailResponse { + // The trip predicate is the cross-trip boundary. Do this before any write so + // an id from another accessible trip cannot be moved into the current one. + this.getTour(tripId, placeId); + const geometry = input.route_geometry as GeometryPoint[]; + const metrics = computeTourMetrics(geometry); + const start = input.route_geometry[0]; + + this.dbs.transaction(() => { + this.dbs.run(` + UPDATE places + SET name = ?, lat = ?, lng = ?, transport_mode = 'walking', route_geometry = ? + WHERE id = ? AND trip_id = ? + `, input.name, start[0], start[1], JSON.stringify(input.route_geometry), placeId, tripId); + this.dbs.run(` + UPDATE tours + SET tour_type = ?, distance = ?, elevation_gain = ?, elevation_loss = ?, + duration = ?, match_confidence = 1, max_hiking_difficulty = ? + WHERE place_id = ? + `, input.tour_type, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM, + input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), input.max_hiking_difficulty, placeId); + this.dbs.run('DELETE FROM tour_waypoints WHERE place_id = ?', placeId); + + const insertWaypoint = this.dbs.prepare(` + INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) + VALUES (?, ?, ?, ?, ?) + `); + for (const waypoint of input.waypoints) { + insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence); + } + }); + + const result = this.getTour(tripId, placeId); + const place = this.dbs.getPlaceWithTags(Number(placeId)); + if (!place) throw new Error('Updated tour could not be loaded'); + this.broadcastToursChanged(tripId, [Number(placeId)], 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. + */ + importGpxAsTour(tripId: string, fileBuffer: Buffer, defaultName?: string, socketId?: string): ImportGpxAsTourResult | null { + const rows = this.places.prepareGpxRows(fileBuffer, { + importWaypoints: false, importRoutes: true, importTracks: true, defaultName, + }); + if (rows.length === 0) return null; + + const insertTour = this.dbs.prepare(` + INSERT INTO tours (place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence, max_hiking_difficulty) + VALUES (?, 'hike', ?, ?, ?, NULL, ?, 2) + `); + + const tours: TourListItem[] = []; + const result = this.dbs.transaction(() => { + const result = this.places.importPreparedGpx(tripId, rows); + for (const place of result.places) { + const points = parseRouteGeometry(place.route_geometry); + const metrics = computeTourMetrics(points); + const matchConfidence = metrics.hasElevation ? 1 : 0.3; + insertTour.run(place.id, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM, matchConfidence); + 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 result; + }); + + 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/types.ts b/server/src/types.ts index 432b59b47..74119858b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -364,6 +364,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 876bf2e65..ada4ee0cb 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -13,6 +13,23 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; +vi.mock('../../src/config', async () => { + const { readEnv } = await import('../../src/app-config'); + const env = readEnv(); + return { + ENCRYPTION_KEY: 'assignments-e2e-inert-encryption-key', + JWT_SECRET: 'assignments-e2e-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, + }; +}); + const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); @@ -30,7 +47,8 @@ const { db } = vi.hoisted(() => { description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT, transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, - stop_type TEXT, fill_percent INTEGER);`); + stop_type TEXT, fill_percent INTEGER, route_geometry TEXT);`); + tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);'); tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT, assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT, @@ -132,6 +150,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) VALUES (4)').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 }); diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index be00957c8..a2db899ca 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -15,6 +15,23 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; +vi.mock('../../src/config', async () => { + const { readEnv } = await import('../../src/app-config'); + const env = readEnv(); + return { + ENCRYPTION_KEY: 'days-e2e-inert-encryption-key', + JWT_SECRET: 'days-e2e-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, + }; +}); + const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); @@ -34,7 +51,9 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, transport_mode TEXT, - google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, stop_type TEXT, fill_percent INTEGER);`); + google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, stop_type TEXT, + fill_percent INTEGER, route_geometry TEXT);`); + tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);'); tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, assignment_time TEXT, assignment_end_time TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); @@ -120,6 +139,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); }); @@ -138,6 +158,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) VALUES (4)').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 7dfad93d9..0e38c66af 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -30,6 +30,8 @@ const { db } = vi.hoisted(() => { google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, transport_mode TEXT, route_geometry TEXT, route_color TEXT, stop_type TEXT, fill_percent INTEGER, email TEXT, opening_hours TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // PlacesService projects only tours.place_id and checks for a facet before deletion. + tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);'); tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`); tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); @@ -123,7 +125,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { }); beforeEach(() => { - db.exec('DELETE FROM trips; DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;'); + db.exec('DELETE FROM trips; DELETE FROM tours; DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;'); canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); }); @@ -144,6 +146,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) VALUES (2)').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(); @@ -171,7 +185,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'A'), (2, 5, 'B'), (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 }]); @@ -297,7 +311,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)); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 0aceefd03..53c83103a 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -81,6 +81,8 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // The trip bundle's PlacesService projection joins the Tours facet by place_id. + tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);'); // PlacesService.list joins categories and batch-loads tags/ratings. tmp.exec('CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);'); tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at DATETIME);'); diff --git a/server/tests/helpers/migration-prefix-db.ts b/server/tests/helpers/migration-prefix-db.ts new file mode 100644 index 000000000..41a32bfb1 --- /dev/null +++ b/server/tests/helpers/migration-prefix-db.ts @@ -0,0 +1,68 @@ +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; +import Database from 'better-sqlite3'; +import ts from 'typescript'; +import { readEnv } from '../../src/app-config'; +import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto'; +import { createTables } from '../../src/db/schema'; +import { seedDocumentProviders } from '../../src/db/document-provider-seed'; +import { reseatBookedNights } from '../../src/db/reseat-booked-nights'; + +const migrationPath = resolve(__dirname, '../../src/db/migrations.ts'); +const migrationSource = readFileSync(migrationPath, 'utf8'); +const localRequire = createRequire(migrationPath); + +function migrationArray(source: string): { file: ts.SourceFile; array: ts.ArrayLiteralExpression } { + const file = ts.createSourceFile('migrations.ts', source, ts.ScriptTarget.Latest, true); + let array: ts.ArrayLiteralExpression | undefined; + function visit(node: ts.Node): void { + if (ts.isVariableDeclaration(node) && node.name.getText(file) === 'migrations' + && node.initializer && ts.isArrayLiteralExpression(node.initializer)) { + array = node.initializer; + } + ts.forEachChild(node, visit); + } + visit(file); + if (!array) throw new Error('Migration array not found'); + return { file, array }; +} + +export function createMigrationPrefixDatabase(version: number): Database.Database { + const { file, array } = migrationArray(migrationSource); + if (!Number.isInteger(version) || version < 0 || version > array.elements.length) { + throw new RangeError(`Unsupported migration prefix version: ${version}`); + } + const truncated = migrationSource.slice(0, array.getStart(file)) + + `[${array.elements.slice(0, version).map(element => element.getFullText(file)).join(',')}]` + + migrationSource.slice(array.end); + const compiled = ts.transpileModule(truncated, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, + }).outputText; + const dependencies: Record = { + '@trek/shared': { ROADTRIP_PREFERENCE_KEYS }, + '../app-config': { readEnv }, + '../nest/common/crypto/apiKeyCrypto': { encrypt_api_key }, + './document-provider-seed': { seedDocumentProviders }, + './reseat-booked-nights': { reseatBookedNights }, + }; + const loaded = { exports: {} as { runMigrations?: (db: Database.Database) => void } }; + new Function('require', 'module', 'exports', compiled)( + (specifier: string) => dependencies[specifier] ?? localRequire(specifier), loaded, loaded.exports, + ); + + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + try { + createTables(db); + if (!loaded.exports.runMigrations) throw new Error('runMigrations export not found'); + loaded.exports.runMigrations(db); + const row = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; + if (row?.version !== version) throw new Error(`Expected schema version ${version}, got ${row?.version ?? 'none'}`); + return db; + } catch (error) { + db.close(); + throw error; + } +} \ No newline at end of file diff --git a/server/tests/unit/db/migration-hygiene.test.ts b/server/tests/unit/db/migration-hygiene.test.ts index 9031db506..ea020e325 100644 --- a/server/tests/unit/db/migration-hygiene.test.ts +++ b/server/tests/unit/db/migration-hygiene.test.ts @@ -15,10 +15,21 @@ * ALLOWED_DESTRUCTIVE below with the reason. Anything not on that list is * treated as a regression. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { createTestDb } from '../../helpers/test-db'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'migration-hygiene-test-key', + JWT_SECRET: 'migration-hygiene-test-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => { + throw new Error('Migration hygiene tests must not import the global database'); +}); const MIGRATIONS_PATH = resolve(__dirname, '../../../src/db/migrations.ts'); const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8'); @@ -186,13 +197,13 @@ describe('migration hygiene — no silently swallowed errors', () => { describe('migration hygiene — full chain smoke', () => { it('migrates a fresh in-memory database from zero to the latest version', () => { - // createTestDb() runs createTables() + the entire runMigrations() chain. - // This proves the logging edits in the previously-empty catch blocks do - // not change control flow / break the migration runner. - const db = createTestDb(); + const db = new Database(':memory:'); try { + db.pragma('foreign_keys = ON'); + createTables(db); + runMigrations(db); const row = db.prepare('SELECT version FROM schema_version').get() as { version: number }; - expect(row.version).toBeGreaterThan(0); + expect(row.version).toBe(262); } finally { db.close(); } diff --git a/server/tests/unit/db/migration-version-atomicity.test.ts b/server/tests/unit/db/migration-version-atomicity.test.ts index 919cce579..537e4db0b 100644 --- a/server/tests/unit/db/migration-version-atomicity.test.ts +++ b/server/tests/unit/db/migration-version-atomicity.test.ts @@ -7,6 +7,16 @@ */ import { describe, it, expect, vi } from 'vitest'; import Database from 'better-sqlite3'; + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'migration-atomicity-test-key', + JWT_SECRET: 'migration-atomicity-test-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => { + throw new Error('Migration version atomicity tests must not import the global database'); +}); + import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; diff --git a/server/tests/unit/db/place-regions-follow-place.test.ts b/server/tests/unit/db/place-regions-follow-place.test.ts index c3ea6cf57..6d0e9912f 100644 --- a/server/tests/unit/db/place-regions-follow-place.test.ts +++ b/server/tests/unit/db/place-regions-follow-place.test.ts @@ -1,5 +1,5 @@ import { runMigrations } from '../../../src/db/migrations'; -import { createTestDb } from '../../helpers/test-db'; +import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -26,8 +26,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { | { country_code: string; region_code: string } | undefined; - beforeEach(() => { - db = createTestDb(); + function seedPlaces(): void { db.exec("INSERT INTO users (username, email, password_hash) VALUES ('traveller', 'traveller@example.test', 'x')"); const userId = (db.prepare('SELECT id FROM users').get() as { id: number }).id; const tripId = db.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(userId) @@ -42,6 +41,17 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { ); insertRegion.run(placeId); insertRegion.run(otherPlaceId); + } + + function replaceWithPrefix(version: number): void { + db.close(); + db = createMigrationPrefixDatabase(version); + seedPlaces(); + } + + beforeEach(() => { + db = createMigrationPrefixDatabase(PLACE_REGIONS_TRIGGER_AGAIN_VERSION); + seedPlaces(); }); afterEach(() => db?.close()); @@ -108,12 +118,6 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { it('ships at its own schema version and replays without a second trigger', () => { expect(triggers()).toHaveLength(1); - db.exec('DROP TRIGGER trg_place_regions_follow_place'); - db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_AGAIN_VERSION); - runMigrations(db); - expect(triggers()).toHaveLength(0); - - db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1); runMigrations(db); runMigrations(db); expect(triggers()).toHaveLength(1); @@ -123,6 +127,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { }); it('keeps the slot it has on main, with Web Push after it and the trigger once more after that', () => { + replaceWithPrefix(PLACE_REGIONS_TRIGGER_VERSION - 1); const sqlByVersion = new Map(); let running = 0; const log = vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { @@ -135,7 +140,6 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { return realExec(sql); }); try { - db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1); runMigrations(db); } finally { exec.mockRestore(); @@ -154,8 +158,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { }); it('gives a 4.3.3 install, already at 244 with the trigger, the Web Push table', () => { - db.exec('DROP TABLE push_subscriptions'); - db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION); + replaceWithPrefix(PLACE_REGIONS_TRIGGER_VERSION); expect(pushTable()).toEqual([]); runMigrations(db); @@ -166,6 +169,7 @@ describe('place_regions follows the place it was resolved from (#2527)', () => { }); it('gives an instance that ran Web Push at 244 the trigger and keeps its subscriptions', () => { + replaceWithPrefix(WEB_PUSH_VERSION); db.exec('DROP TRIGGER trg_place_regions_follow_place'); db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION); db.prepare( diff --git a/server/tests/unit/db/roadtrip-preferences-migration.test.ts b/server/tests/unit/db/roadtrip-preferences-migration.test.ts index cd9a040a0..18e445be6 100644 --- a/server/tests/unit/db/roadtrip-preferences-migration.test.ts +++ b/server/tests/unit/db/roadtrip-preferences-migration.test.ts @@ -1,5 +1,5 @@ import { runMigrations } from '../../../src/db/migrations'; -import { createTables } from '../../../src/db/schema'; +import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db'; import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; @@ -13,10 +13,8 @@ const PREFERENCES_VERSION = 219; describe('trip driving preferences migration', () => { it('inherits owner values once, tolerates nulls and preserves trip overrides on replay', () => { - const db = new Database(':memory:'); + const db = createMigrationPrefixDatabase(PREFERENCES_VERSION - 1); try { - createTables(db); - runMigrations(db); db.exec( "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')", ); @@ -24,16 +22,34 @@ describe('trip driving preferences migration', () => { db.exec( "INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"'), (1, 'roadtrip_day_end', NULL), (1, 'routing_base_url', '\"https://private.test\"')", ); - db.prepare('UPDATE schema_version SET version = ?').run(PREFERENCES_VERSION - 1); runMigrations(db); expect(db.prepare('SELECT key, value FROM roadtrip_preferences WHERE trip_id = 10 ORDER BY key').all()).toEqual([ { key: 'roadtrip_day_start', value: '"08:00"' }, { key: 'roadtrip_range_km', value: '120' }, ]); expect(db.prepare('SELECT value FROM roadtrip_preferences WHERE trip_id = 11').get()).toEqual({ value: '300' }); - db.exec("UPDATE roadtrip_preferences SET value = '200' WHERE trip_id = 10 AND key = 'roadtrip_range_km'"); + } finally { + db.close(); + } + }); + + it('preserves trip overrides when migration 219 is replayed after a partial cursor update', () => { + const db = createMigrationPrefixDatabase(PREFERENCES_VERSION); + try { + db.exec( + "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')", + ); + db.exec("INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'First'), (11, 2, 'Second')"); + db.exec( + "INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"')", + ); + db.exec( + "INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (10, 'roadtrip_day_start', '\"08:00\"'), (10, 'roadtrip_range_km', '200'), (11, 'roadtrip_range_km', '300')", + ); db.prepare('UPDATE schema_version SET version = ?').run(PREFERENCES_VERSION - 1); + runMigrations(db); + expect( db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 10 AND key = 'roadtrip_range_km'").get(), ).toEqual({ value: '200' }); diff --git a/server/tests/unit/db/tours-migration-lineage.test.ts b/server/tests/unit/db/tours-migration-lineage.test.ts new file mode 100644 index 000000000..b303892e4 --- /dev/null +++ b/server/tests/unit/db/tours-migration-lineage.test.ts @@ -0,0 +1,387 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { createMigrationPrefixDatabase } from '../../helpers/migration-prefix-db'; + +vi.mock('../../../src/config', () => ({ + ENCRYPTION_KEY: 'r3-test-only-key', + JWT_SECRET: 'r3-test-only-secret', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => { + throw new Error('Tours migration lineage tests must not import the global database'); +}); + +const repositoryRoot = resolve(__dirname, '../../../..'); +const databases: Database.Database[] = []; +const disposableDirectories: string[] = []; + +function migrationPrefixDatabase(version: number): Database.Database { + const db = createMigrationPrefixDatabase(version); + databases.push(db); + expect(cursor(db)).toBe(version); + return db; +} + +function addToursSchemaStage(db: Database.Database, stage: number): void { + db.exec(` + 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 + ) + `); + if (stage >= 2) { + db.exec(` + 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 + ) + `); + db.exec('CREATE INDEX idx_tours_tour_type ON tours(tour_type)'); + } + if (stage >= 3) { + db.exec(` + 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) + ) + `); + db.exec('CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)'); + } + if (stage >= 4) { + db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)'); + } +} + +function createUpstream242Fixture(): Database.Database { + return migrationPrefixDatabase(242); +} + +function createUpstream258Fixture(): Database.Database { + return migrationPrefixDatabase(258); +} + +function createLegacyToursFixture(version: number): Database.Database { + if (version < 216 || version > 218) throw new Error(`Unsupported legacy Tours fixture version: ${version}`); + const db = migrationPrefixDatabase(215); + addToursSchemaStage(db, version - 215); + db.prepare('UPDATE schema_version SET version = ?').run(version); + return db; +} + +function createLegacyTours218Fixture(): Database.Database { + return createLegacyToursFixture(218); +} + +function createFrozenToursFixture(version: number): Database.Database { + if (version < 243 || version > 246) throw new Error(`Unsupported frozen Tours fixture version: ${version}`); + const db = createUpstream242Fixture(); + addToursSchemaStage(db, version - 242); + db.prepare('UPDATE schema_version SET version = ?').run(version); + return db; +} + +function createFrozenTours246Fixture(): Database.Database { + return createFrozenToursFixture(246); +} + +function createAmbiguousToursFixture(mutation: string, lineage: 'legacy' | 'frozen' = 'frozen'): Database.Database { + const db = lineage === 'legacy' ? createLegacyTours218Fixture() : createFrozenTours246Fixture(); + seedTour(db); + db.exec(mutation); + return db; +} + +function emptyDb(): Database.Database { + const db = new Database(':memory:'); + databases.push(db); + db.pragma('foreign_keys = ON'); + createTables(db); + return db; +} + +function cursor(db: Database.Database): number { + return (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version; +} + +function snapshot(db: Database.Database): Record { + const tables = new Set((db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]) + .map(row => row.name)); + return Object.fromEntries(['places', 'tours', 'tour_waypoints', 'day_assignments'].filter(table => tables.has(table)).map(table => [ + table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(), + ])); +} + +function schemaSnapshot(db: Database.Database): unknown[] { + return db.prepare(` + SELECT type, name, tbl_name, sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name + `).all(); +} + +function expectRowsPreserved(actual: unknown[], before: unknown[]): void { + expect(actual).toHaveLength(before.length); + for (const [index, previous] of before.entries()) { + const values = previous as Record; + const row = actual[index] as Record; + expect(Object.fromEntries(Object.keys(values).map(key => [key, row[key]]))).toEqual(values); + } +} + +function seedTour(db: Database.Database): void { + db.exec(` + INSERT INTO users (id, username, email, password_hash) VALUES (71, 'lineage-test', 'lineage@example.invalid', 'not-a-password'); + INSERT INTO trips (id, user_id, title) VALUES (81, 71, 'Synthetic Tours'); + INSERT INTO days (id, trip_id, day_number) VALUES (91, 81, 1); + INSERT INTO places (id, trip_id, name, route_geometry) VALUES + (101, 81, 'Historical tour', '[[48,11,600],[48.01,11.02,650],[48.02,11.04,630]]'); + INSERT INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'tours.types.hike', 'Mountain', '#008800'); + INSERT INTO tours (place_id, tour_type, difficulty) VALUES (101, 'hike', 'T3'); + INSERT INTO tour_waypoints (id, place_id, lat, lng, role, sequence) VALUES + (201, 101, 48, 11, 'start', 0), (203, 101, 48.01, 11.02, 'via', 1), + (207, 101, 48.02, 11.04, 'end', 2); + INSERT INTO day_assignments (id, day_id, place_id, order_index) VALUES (301, 91, 101, 0); + `); +} + +function expectFinalSchema(db: Database.Database): void { + expect(cursor(db)).toBe(262); + expect(db.pragma('foreign_key_check')).toEqual([]); + const markers: Record = { + users: ['immich_allow_insecure_tls'], day_assignments: ['route_excluded'], + packing_items: ['packed_quantity'], bucket_list: ['region_code'], vacay_company_holidays: ['fraction'], + journey_entries: ['is_draft'], share_tokens: ['share_travel_only', 'share_hide_images'], + budget_settlements: ['note'], packing_template_items: ['weight_grams', 'quantity', 'bag_name'], + journeys: ['status_override', 'photo_location'], places: ['email', 'opening_hours'], + tours: ['max_hiking_difficulty'], + }; + for (const [table, columns] of Object.entries(markers)) { + const actual = (db.pragma(`table_info(${table})`) as { name: string }[]).map(column => column.name); + expect(actual).toEqual(expect.arrayContaining(columns)); + } + for (const name of ['tour_types', 'tours', 'tour_waypoints', 'push_subscriptions', 'google_api_usage', + 'trg_place_regions_follow_place', 'idx_push_subscriptions_user', 'idx_tours_tour_type', 'idx_tour_waypoints_place']) { + expect(db.prepare('SELECT 1 FROM sqlite_master WHERE name = ?').get(name)).toBeDefined(); + } + runMigrations(db); + expect(cursor(db)).toBe(262); + expect(db.pragma('foreign_key_check')).toEqual([]); +} + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`Migration exit ${code}`); }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const db of databases.splice(0)) if (db.open) db.close(); + for (const directory of disposableDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe('Tours migration sequence', () => { + it('replays the current upstream 258 prefix with the current Tours tail', () => { + const db = createUpstream258Fixture(); + expect(cursor(db)).toBe(258); + expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'tours'").get()).toBeUndefined(); + runMigrations(db); + expectFinalSchema(db); + }); +}); + +describe('Tours migration lineage matrix', () => { + it('migrates a fresh disposable database from zero and safely reruns', () => { + const db = emptyDb(); + runMigrations(db); + expectFinalSchema(db); + }); + + it('accepts the exact public upstream schema at historical version 218', () => { + const db = migrationPrefixDatabase(218); + expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'tours'").get()).toBeUndefined(); + expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'roadtrip_day_boundaries'").get()).toBeDefined(); + expect(db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'school_holiday_periods'").get()).toBeDefined(); + expect(db.pragma('table_info(day_assignments)')).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'end_day' }), + ])); + + runMigrations(db); + expectFinalSchema(db); + }); + + it.each([242, 243, 244, 245, 246, 258])('migrates pure upstream %i without a rewind', (version) => { + const db = migrationPrefixDatabase(version); + const writes: number[] = []; + const prepare = db.prepare.bind(db); + vi.spyOn(db, 'prepare').mockImplementation(((sql: string) => { + const statement = prepare(sql); + if (sql.includes('UPDATE schema_version SET version')) { + const run = statement.run.bind(statement); + statement.run = ((value: number) => { writes.push(value); return run(value); }) as typeof statement.run; + } + return statement; + }) as typeof db.prepare); + runMigrations(db); + expect(writes.every(value => value > version)).toBe(true); + expectFinalSchema(db); + }); + + it('accepts upstream Web Push at historical slot 244 and restores the Atlas trigger at 246', () => { + const db = migrationPrefixDatabase(245); + db.exec('DROP TRIGGER trg_place_regions_follow_place; UPDATE schema_version SET version = 244'); + runMigrations(db); + expectFinalSchema(db); + }); + + it('rejects an upstream cursor carrying impossible later schema markers without rewriting it', () => { + const db = migrationPrefixDatabase(243); + db.exec('ALTER TABLE day_assignments ADD COLUMN route_excluded INTEGER NOT NULL DEFAULT 0'); + const beforeSchema = schemaSnapshot(db); + const beforeRows = snapshot(db); + expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/); + expect(cursor(db)).toBe(243); + expect(schemaSnapshot(db)).toEqual(beforeSchema); + expect(snapshot(db)).toEqual(beforeRows); + }); + + it.each([216, 217, 218])('recognizes the exact legacy Tours %i schema', (version) => { + const db = createLegacyToursFixture(version); + const before = version === 218 ? (seedTour(db), snapshot(db)) : undefined; + runMigrations(db); + expectFinalSchema(db); + if (before) { + const after = snapshot(db); + for (const table of ['places', 'tour_waypoints']) expectRowsPreserved(after[table], before[table]); + expect(after.tours).toEqual(before.tours.map(row => ({ ...row as object, max_hiking_difficulty: 2 }))); + expect((after.tours[0] as { place_id: number; tour_type: string }).place_id).toBe(101); + expect((after.tours[0] as { tour_type: string }).tour_type).toBe('hike'); + expect(after.tour_waypoints.map(row => (row as { id: number }).id)).toEqual([201, 203, 207]); + expect(after.day_assignments).toEqual(before.day_assignments.map(row => ({ + ...row as object, end_day: 0, route_excluded: 0, accommodation_id: null, + }))); + } + }); + + it.each([243, 244, 245, 246])('recognizes the exact frozen Tours %i schema', (version) => { + const db = createFrozenToursFixture(version); + if (version === 246) { + seedTour(db); + db.prepare('UPDATE tours SET max_hiking_difficulty = 5').run(); + } + const before = version === 246 ? snapshot(db) : undefined; + runMigrations(db); + expectFinalSchema(db); + if (before) { + const after = snapshot(db); + for (const table of ['places', 'tours', 'tour_waypoints']) expectRowsPreserved(after[table], before[table]); + expect((after.tours[0] as { place_id: number; tour_type: string }).place_id).toBe(101); + expect((after.tours[0] as { tour_type: string }).tour_type).toBe('hike'); + expect((after.tours[0] as { max_hiking_difficulty: number }).max_hiking_difficulty).toBe(5); + expect(after.tour_waypoints.map(row => (row as { id: number }).id)).toEqual([201, 203, 207]); + expect(after.day_assignments).toEqual(before.day_assignments.map(row => ({ ...row as object, route_excluded: 0 }))); + expect(() => db.prepare('UPDATE tours SET max_hiking_difficulty = 7').run()).toThrow(); + db.prepare('DELETE FROM places WHERE id = 101').run(); + expect(db.prepare('SELECT * FROM tours').all()).toEqual([]); + expect(db.prepare('SELECT * FROM tour_waypoints').all()).toEqual([]); + } + }); + + it.each([ + "ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0", + 'CREATE TABLE push_subscriptions (id INTEGER PRIMARY KEY)', + 'ALTER TABLE tours ADD COLUMN unknown_manual_column TEXT', + 'DROP INDEX idx_tour_waypoints_place', + 'UPDATE schema_version SET version = 244', + ])('rejects mixed or contradictory frozen schemas without changing cursor or data: %s', (mutation) => { + const db = createAmbiguousToursFixture(mutation, 'frozen'); + const before = snapshot(db); + const beforeSchema = schemaSnapshot(db); + const version = cursor(db); + expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/); + expect(cursor(db)).toBe(version); + expect(snapshot(db)).toEqual(before); + expect(schemaSnapshot(db)).toEqual(beforeSchema); + expect(db.pragma('foreign_key_check')).toEqual([]); + }); + + it.each(['legacy', 'frozen'] as const)('rolls a recognized %s bridge and its cursor back if replay fails, then restarts safely', (lineage) => { + const version = lineage === 'legacy' ? 218 : 246; + const db = lineage === 'legacy' ? createLegacyTours218Fixture() : createFrozenTours246Fixture(); + seedTour(db); + const before = snapshot(db); + const beforeSchema = schemaSnapshot(db); + const exec = db.exec.bind(db); + vi.spyOn(db, 'exec').mockImplementation((sql) => { + if (sql.includes('CREATE TABLE IF NOT EXISTS push_subscriptions')) throw new Error('Injected migration failure'); + return exec(sql); + }); + expect(() => runMigrations(db)).toThrow(); + expect(cursor(db)).toBe(version); + expect(snapshot(db)).toEqual(before); + expect(schemaSnapshot(db)).toEqual(beforeSchema); + expect(process.exit).not.toHaveBeenCalled(); + expect(db.prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'").get()).toBeUndefined(); + vi.mocked(db.exec).mockRestore(); + runMigrations(db); + expectFinalSchema(db); + }); + + it.each([ + 'CREATE TABLE school_holiday_countries (code TEXT PRIMARY KEY, name TEXT NOT NULL)', + 'ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE tour_types ADD COLUMN unknown_manual_column TEXT', + 'UPDATE schema_version SET version = 216', + ])('rejects ambiguous legacy Tours schemas without rewinding: %s', (mutation) => { + const db = createAmbiguousToursFixture(mutation, 'legacy'); + const before = snapshot(db); + const beforeSchema = schemaSnapshot(db); + const version = cursor(db); + expect(() => runMigrations(db)).toThrow(/Unsafe Tours migration lineage/); + expect(cursor(db)).toBe(version); + expect(snapshot(db)).toEqual(before); + expect(schemaSnapshot(db)).toEqual(beforeSchema); + }); + + it('upgrades a copied synthetic frozen-246 database and safely reopens it without touching its source', async () => { + const source = createFrozenTours246Fixture(); + seedTour(source); + source.prepare('UPDATE tours SET max_hiking_difficulty = 5').run(); + const before = snapshot(source); + const parent = resolve(repositoryRoot, 'server/data/tmp'); + mkdirSync(parent, { recursive: true }); + const directory = mkdtempSync(resolve(parent, 'b1-tours-lineage-')); + disposableDirectories.push(directory); + const filename = resolve(directory, 'synthetic-frozen-copy.sqlite'); + await source.backup(filename); + const copy = new Database(filename); + databases.push(copy); + copy.pragma('foreign_keys = ON'); + runMigrations(copy); + expectFinalSchema(copy); + const after = snapshot(copy); + for (const table of ['places', 'tours', 'tour_waypoints', 'day_assignments']) { + expectRowsPreserved(after[table], before[table]); + } + copy.close(); + const reopened = new Database(filename); + databases.push(reopened); + reopened.pragma('foreign_keys = ON'); + runMigrations(reopened); + expectFinalSchema(reopened); + expect(snapshot(reopened)).toEqual(after); + expect(cursor(source)).toBe(246); + expect(snapshot(source)).toEqual(before); + }); +}); \ No newline at end of file diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 04fb5797f..481453f26 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/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 7d245672b..f8e5785bc 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -104,6 +104,25 @@ describe('verifyTripAccess', () => { // ── getAssignmentsForDay ────────────────────────────────────────────────────── describe('getAssignmentsForDay', () => { + it('projects only facet-backed Tour geometry in both day read paths', () => { + 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); + testDb.prepare("INSERT INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'hike', 'hike', '#000000')").run(); + testDb.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tour.id); + createDayAssignment(testDb, day.id, tour.id, { order_index: 0 }); + createDayAssignment(testDb, day.id, track.id, { order_index: 1 }); + + for (const assignments of [svc.getAssignmentsForDay(day.id), 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', () => { 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 65cc26ada..316570a6f 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'; @@ -43,6 +62,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. @@ -256,14 +279,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); }); @@ -477,8 +507,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'); }); it('DELETE /:id announces the booking and the expense a cancelled night took down', async () => { @@ -565,7 +599,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 8768268cf..72a813f24 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -145,6 +145,20 @@ describe('list', () => { expect(places).toHaveLength(2); }); + it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', () => { + 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 OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run(); + testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id); + + const byId = new Map((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', () => { const { user } = createUser(testDb); const t1 = createTrip(testDb, user.id); @@ -480,6 +494,43 @@ 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; + testDb.prepare(`INSERT OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run(); + 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); @@ -556,6 +607,25 @@ 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; + testDb.prepare(`INSERT OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'test', 'Mountain', '#000000')`).run(); + 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 000000000..6730c0f57 --- /dev/null +++ b/server/tests/unit/nest/tours.controller.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import 'reflect-metadata'; + +vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); }); +vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); }); +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); }); +vi.mock('../../../src/nest/database/database.service', () => ({ + DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }), +})); +vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} })); +vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} })); +vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} })); +vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import type { TourCreateRequest } from '@trek/shared'; +import { ToursController } from '../../../src/nest/tours/tours.controller'; +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 { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; + +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, +}; + +describe('ToursController planner routes', () => { + it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, ToursController) as unknown[]; + expect(guards).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]); + expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' }); + }); + + it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { createTour: vi.fn().mockReturnValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(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', () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { getTour: vi.fn().mockReturnValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(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', () => { + const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; + const service = { updateTour: vi.fn().mockReturnValue(result) } as unknown as ToursService; + const controller = new ToursController(service); + + expect(controller.update('7', '1', request, 'socket-2')).toBe(result); + expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2'); + }); +}); 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 000000000..ad586a271 --- /dev/null +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { Logger } from '@nestjs/common'; + +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: vi.fn(), +})); +vi.mock('../../../src/db/database', () => ({ + db: { + prepare: () => { throw new Error('Legacy database access forbidden'); }, + exec: () => { throw new Error('Legacy database access forbidden'); }, + }, +})); +vi.mock('../../../src/nest/database/database.service', () => ({ DatabaseService: class {} })); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { PlacesService } from '../../../src/nest/places/places.service'; +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { gpxParser } from '../../../src/nest/places/places.helpers'; + +const mixedGpx = Buffer.from(` + Ignored POI + Route description + RidgeTrack description + 100 + 150 + 120 +`); + +function makeHarness() { + const raw = new Database(':memory:'); + raw.pragma('foreign_keys = ON'); + raw.exec(` + CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id TEXT, name TEXT, description TEXT, + lat REAL, lng REAL, transport_mode TEXT, route_geometry TEXT, route_color TEXT, + google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT); + CREATE TABLE tours (place_id INTEGER PRIMARY KEY REFERENCES places(id), tour_type TEXT, + distance REAL, elevation_gain REAL, elevation_loss REAL, duration REAL, + match_confidence REAL, max_hiking_difficulty INTEGER); + CREATE TABLE tour_waypoints (id INTEGER PRIMARY KEY, tour_place_id INTEGER REFERENCES tours(place_id)); + `); + const db = { + connection: raw, + prepare: (sql: string) => raw.prepare(sql), + all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params), + getPlaceWithTags: (id: number) => ({ ...raw.prepare('SELECT * FROM places WHERE id = ?').get(id) as object, tags: [] }), + transaction: vi.fn((write: (conn: Database.Database) => Result): Result => { + expect(raw.inTransaction).toBe(false); + return raw.transaction(() => write(raw))(); + }), + }; + const broadcast = vi.fn(); + const places = new PlacesService(db as never, {} as never, { broadcast } as never, + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never); + return { raw, db, places, broadcast, tours: new ToursService(db as never, places) }; +} + +describe('Tours GPX atomic persistence and postcommit publication', () => { + let setup: ReturnType; + beforeEach(() => { setup = makeHarness(); }); + afterEach(() => { setup.raw.close(); vi.restoreAllMocks(); }); + + function expectEmpty() { + for (const table of ['places', 'tours', 'tour_waypoints']) { + expect(setup.raw.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()).toEqual({ count: 0 }); + } + expect(setup.broadcast).not.toHaveBeenCalled(); + } + + it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', () => { + setup.broadcast.mockImplementation((tripId, event, payload, socketId) => { + expect(setup.raw.inTransaction).toBe(false); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 }); + expect(tripId).toBe('7'); + if (event === 'tours:changed') { + expect(payload).toEqual({ placeIds: [1, 2] }); + expect(socketId).toBe('socket'); + } else { + expect(event).toBe('place:created'); + expect(socketId).toBe('socket'); + expect(payload.place.route_color).toBeTruthy(); + } + }); + const result = setup.tours.importGpxAsTour('7', mixedGpx, 'walk.gpx', 'socket')!; + expect(setup.db.transaction).toHaveBeenCalledOnce(); + 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 }); + expect(result.tours[1]).toMatchObject({ name: 'Ridge', elevation_gain: 50, elevation_loss: 30, caution: false }); + const rows = setup.raw.prepare('SELECT * FROM places ORDER BY id').all() as { trip_id: string; description: string; route_geometry: string; route_color: string }[]; + expect(rows.map(row => row.trip_id)).toEqual(['7', '7']); + 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(setup.raw.prepare('SELECT COUNT(*) AS count FROM tour_waypoints').get()).toEqual({ count: 0 }); + expect(setup.broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']); + expect(setup.broadcast.mock.calls.slice(1).map(call => call[2].place.id)).toEqual(result.tours.map(tour => tour.place_id)); + }); + + it.each([1, 2])('rolls back every carrier and facet when facet %i fails', (ordinal) => { + setup.raw.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours + WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1} + BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`); + expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('facet failure'); + expectEmpty(); + }); + + it('rolls back earlier carriers when the later Place insert fails', () => { + setup.raw.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge' + BEGIN SELECT RAISE(ABORT, 'place failure'); END;`); + expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('place failure'); + expectEmpty(); + }); + + it('rolls back carriers and earlier colors when later coloring fails', () => { + setup.raw.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge' + BEGIN SELECT RAISE(ABORT, 'color failure'); END;`); + expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('color failure'); + expectEmpty(); + }); + + it.each(['', '', '', ''])('does not write or publish unusable input %s', (xml) => { + expect(setup.tours.importGpxAsTour('7', Buffer.from(xml))).toBeNull(); + expect(setup.db.transaction).not.toHaveBeenCalled(); + expectEmpty(); + }); + + it('propagates parser failures before entering the transaction', () => { + vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => { throw new Error('parser failure'); }); + expect(() => setup.tours.importGpxAsTour('7', mixedGpx)).toThrow('parser failure'); + expect(setup.db.transaction).not.toHaveBeenCalled(); + expectEmpty(); + }); + + it('keeps a committed response and attempts remaining events if publication throws', () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + setup.broadcast.mockImplementationOnce(() => { throw new Error('transport unavailable'); }); + expect(setup.tours.importGpxAsTour('7', mixedGpx)?.tours).toHaveLength(2); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 }); + expect(setup.broadcast).toHaveBeenCalledTimes(3); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('keeps committed rows and success response if the Tours invalidation event fails', () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + setup.broadcast.mockImplementation((_tripId, event) => { + if (event === 'tours:changed') throw new Error('invalidation unavailable'); + }); + + const result = setup.tours.importGpxAsTour('7', mixedGpx); + + expect(result?.tours).toHaveLength(2); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 2 }); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 }); + expect(setup.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', () => { + expect(setup.tours.importGpxAsTour('7', mixedGpx)?.tours).toHaveLength(2); + const rowsBefore = setup.raw.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all(); + const facetsBefore = setup.raw.prepare('SELECT place_id FROM tours ORDER BY place_id').all(); + setup.broadcast.mockClear(); + + expect(setup.tours.importGpxAsTour('7', mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 }); + expect(setup.raw.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore); + expect(setup.raw.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore); + expect(setup.broadcast).not.toHaveBeenCalled(); + }) + + it('imports only the new tracks and publishes only committed rows in a mixed result', () => { + const existing = Buffer.from(`Ridge + 100150 + `) + expect(setup.tours.importGpxAsTour('7', existing)?.tours).toHaveLength(1); + setup.broadcast.mockClear(); + + const result = setup.tours.importGpxAsTour('7', mixedGpx, 'walk.gpx', 'socket'); + + expect(result?.tours).toHaveLength(1); + expect(result?.tours[0].name).toBe('walk'); + expect(result?.skipped).toBe(1); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 2 }); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 2 }); + expect(setup.broadcast.mock.calls.map(call => [call[1], call[2]])).toEqual([ + ['tours:changed', { placeIds: [2] }], + ['place:created', expect.objectContaining({ place: expect.objectContaining({ id: 2, name: 'walk' }) })], + ]); + }) + + it('preserves single-track response and trip scoping', () => { + const single = Buffer.from(''); + const result = setup.tours.importGpxAsTour('8', single, 'single.gpx')!; + expect(result.tours).toHaveLength(1); + expect(result.tours[0].name).toBe('single'); + expect(result.caution).toBe(true); + expect(setup.broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created']); + }); + + it('rejects caller-owned persistence outside a transaction before writing', () => { + expect(() => setup.places.importPreparedGpx('7', setup.places.prepareGpxRows(mixedGpx))).toThrow('active transaction'); + expectEmpty(); + }); +}); \ No newline at end of file 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 000000000..e11a566dd --- /dev/null +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -0,0 +1,238 @@ +import 'reflect-metadata'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { HttpException, type ExecutionContext } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { TourCreateRequest } from '@trek/shared'; + +vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); }); +vi.mock('../../../src/nest/database/database.service', () => ({ + DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in this suite'); }), +})); +vi.mock('../../../src/db/database', () => ({ + db: { prepare: vi.fn(() => { throw new Error('Legacy database access is forbidden in this suite'); }) }, +})); +vi.mock('better-sqlite3', () => ({ + default: vi.fn(() => { throw new Error('SQLite construction is forbidden in this suite'); }), +})); +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 { DatabaseService } from '../../../src/nest/database/database.service'; +import { db as legacyDatabase } from '../../../src/db/database'; +import SqliteDatabase from 'better-sqlite3'; +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'; + +const matrix = [[false, false], [true, false], [false, true], [true, true]] as const; + +afterAll(() => { + expect(DatabaseService).not.toHaveBeenCalled(); + expect(legacyDatabase.prepare).not.toHaveBeenCalled(); + expect(SqliteDatabase).not.toHaveBeenCalled(); +}); + +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' } }; + const trip = { id: 7, user_id: 1 }; + const access = vi.fn((id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined); + const permission = vi.fn((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 guard = new TripAccessGuard({ canAccessTrip: access } as never, { checkPermission: permission } as never, new Reflector()); + return { request, access, permission, context, guard }; +} + +async function rejected(action: () => unknown, status: number) { + await expect(Promise.resolve().then(action)).rejects.toMatchObject({ status }); +} + +function placesFixture() { + const places = { + get: vi.fn(() => ({ id: 42, trip_id: 7 })), + onDeleted: vi.fn(), + linkedExpenseIds: vi.fn(() => []), + remove: vi.fn(async () => ({ deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } })), + broadcast: vi.fn(), + }; + return { places, controller: new PlacesController(places as never, {} as never, {} as never) }; +} + +function assignmentsFixture() { + const assignment = { id: 51, place_id: 42, day_id: 11 }; + const assignments = { + dayExists: vi.fn(() => true), + placeExists: vi.fn(() => true), + assignmentExistsInDay: vi.fn(() => true), + createAssignment: vi.fn(() => assignment), + deleteAssignment: vi.fn(), + broadcast: vi.fn(), + reconcile: vi.fn(), + }; + return { assignment, assignments, controller: new DayAssignmentsController(assignments as never) }; +} + +describe('Tours deletion and assignment authorization (mock-only)', () => { + 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 = () => { + auth.guard.canActivate(auth.context); + return controller.remove(auth.request.user as never, '7', '42', 'socket'); + }; + if (canEdit) { + await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [] }); + 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 = () => { + auth.guard.canActivate(auth.context); + return handler === 'create' + ? controller.create(auth.request.user as never, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket') + : controller.remove(auth.request.user as never, '7', '11', '51', 'socket'); + }; + if (canAssign) { + expect(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 }, + ], +}; + +function tourDatabaseFixture(ownerTripId: string) { + 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 waypointRun = vi.fn(); + const db = { + get: vi.fn((sql: string, tripId: string, placeId: string) => { + expect(sql.replace(/\s+/g, ' ')).toContain('WHERE p.trip_id = ? AND p.id = ?'); + return tripId === ownerTripId && placeId === '42' ? row : undefined; + }), + all: vi.fn(() => input.waypoints), + run: vi.fn(), + prepare: vi.fn(() => ({ run: waypointRun })), + transaction: vi.fn((write: () => void) => write()), + getPlaceWithTags: vi.fn(() => ({ id: 42, trip_id: Number(ownerTripId) })), + }; + const places = { broadcast: vi.fn() }; + return { db, waypointRun, places, service: new ToursService(db as never, places as never) }; +} + +describe('Real ToursService update trip boundary (mock database module)', () => { + it('rejects a placeId belonging to another trip before any transaction or write', () => { + const { db, waypointRun, places, service } = tourDatabaseFixture('8'); + expect(service.getTour('8', '42').tour.place_id).toBe(42); + db.get.mockClear(); + db.all.mockClear(); + try { + service.updateTour('7', '42', input, 'socket'); + expect.fail('Expected cross-trip rejection'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(404); + expect((error as Error).message).toBe('Tour not found'); + } + expect(db.get).toHaveBeenCalledExactlyOnceWith(expect.any(String), '7', '42'); + for (const method of [db.all, db.transaction, db.run, db.prepare, waypointRun, db.getPlaceWithTags, places.broadcast]) { + expect(method).not.toHaveBeenCalled(); + } + expect(DatabaseService).not.toHaveBeenCalled(); + }); + + it('allows a same-trip update through the fake transaction and prepare interface', () => { + const { db, waypointRun, places, service } = tourDatabaseFixture('7'); + expect(service.updateTour('7', '42', input, 'socket').tour.place_id).toBe(42); + expect(db.get).toHaveBeenCalledTimes(2); + expect(db.transaction).toHaveBeenCalledOnce(); + expect(db.run).toHaveBeenCalledTimes(3); + expect(db.run).toHaveBeenNthCalledWith(1, expect.stringMatching(/WHERE id = \? AND trip_id = \?/), input.name, 48, 11, JSON.stringify(input.route_geometry), '42', '7'); + expect(db.prepare).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('INSERT INTO tour_waypoints')); + expect(waypointRun).toHaveBeenCalledTimes(2); + for (const waypoint of input.waypoints) { + expect(waypointRun).toHaveBeenCalledWith('42', waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence); + } + expect(db.getPlaceWithTags).toHaveBeenCalledWith(42); + expect(places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket'); + expect(DatabaseService).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file 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 000000000..1f0aa51b6 --- /dev/null +++ b/server/tests/unit/nest/tours.permissions.test.ts @@ -0,0 +1,108 @@ +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'; + +vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); }); +vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); }); +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); }); +vi.mock('../../../src/nest/database/database.service', () => ({ + DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }), +})); +vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} })); +vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: 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 { TripAccessGuard, TRIP_PERMISSION_KEY, TRIP_REQUEST_KEY } from '../../../src/nest/permissions/trip-access.guard'; +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'; + +function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') { + const request = { params: { tripId }, user: { id: 2, role: 'user' } }; + const trip = { id: 7, user_id: 1 }; + const access = vi.fn((id: number, userId: number) => id === 7 && userId === 2 ? trip : undefined); + const permission = vi.fn((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 guard = new TripAccessGuard({ canAccessTrip: access } as never, { checkPermission: permission } as never, new Reflector()); + return { request, trip, access, permission, context, guard }; +} + +function expectStatus(action: () => unknown, status: number) { + try { + action(); + expect.fail('Expected guard rejection'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(status); + } +} + +describe('Tours permission contract (mock-only, no DB)', () => { + it('retains addon/JWT/trip guard order and exact write metadata', () => { + expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]); + for (const handler of ['create', 'update', 'importGpx'] 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([[false, false], [true, false], [false, true], [true, true]])('edit=%s assign=%s permits reads and only place-edit writes', (canEdit, canAssign) => { + for (const handler of ['list', 'detail'] as const) { + const test = fixture(handler, canEdit, canAssign); + expect(test.guard.canActivate(test.context)).toBe(true); + expect(test.permission).not.toHaveBeenCalled(); + expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(test.trip); + } + for (const handler of ['create', 'update', 'importGpx'] as const) { + const test = fixture(handler, canEdit, canAssign); + if (canEdit) expect(test.guard.canActivate(test.context)).toBe(true); + else expectStatus(() => test.guard.canActivate(test.context), 403); + expect(test.permission).toHaveBeenCalledWith('place_edit', 'user', 1, 2, true); + } + }); + + it.each(['list', 'detail', 'create', 'update', 'importGpx'] as const)('%s rejects inaccessible route trips before any write permission check', handler => { + const test = fixture(handler, true, true, '8'); + expectStatus(() => test.guard.canActivate(test.context), 404); + expect(test.access).toHaveBeenCalledWith(8, 2); + expect(test.permission).not.toHaveBeenCalled(); + }); + + it('preserves addon and JWT refusal without reaching trip resolution', () => { + const test = fixture('create', true, true); + const addon = new AddonGuard({ isAddonEnabled: vi.fn(() => false) } as never, new Reflector()); + expectStatus(() => addon.canActivate(test.context), 404); + vi.mocked(extractToken).mockReturnValue(null); + const jwt = new JwtAuthGuard(); + expectStatus(() => jwt.canActivate(test.context), 401); + vi.mocked(extractToken).mockReturnValue('token'); + vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); + expectStatus(() => jwt.canActivate(test.context), 401); + expect(test.access).not.toHaveBeenCalled(); + }); + + it('delegates import with the route trip and preserves missing/empty-file prechecks', () => { + const imported = { tours: [{ place_id: 42 }] }; + const importGpxAsTour = vi.fn(() => imported); + const controller = new ToursController({ importGpxAsTour } as never); + const buffer = Buffer.from('gpx'); + expect(controller.importGpx('7', { buffer, originalname: 'ridge.gpx' } as Express.Multer.File, 'socket')).toBe(imported); + expect(importGpxAsTour).toHaveBeenCalledWith('7', buffer, 'ridge.gpx', 'socket'); + const allSkipped = { tours: [], caution: false, skipped: 2 }; + importGpxAsTour.mockReturnValueOnce(allSkipped as never); + expect(controller.importGpx('7', { buffer, originalname: 'duplicates.gpx' } as Express.Multer.File)).toBe(allSkipped); + expectStatus(() => controller.importGpx('7', undefined), 400); + importGpxAsTour.mockReturnValueOnce(null as never); + expectStatus(() => controller.importGpx('7', { buffer, originalname: 'empty.gpx' } as Express.Multer.File), 400); + }); +}); \ No newline at end of file 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 000000000..2ea2b682c --- /dev/null +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config', () => { throw new Error('Config initialization is forbidden in mock-only tests'); }); +vi.mock('../../../src/db/database', () => { throw new Error('Legacy database imports are forbidden in mock-only tests'); }); +vi.mock('../../../src/nest/database/database.service', () => ({ + DatabaseService: vi.fn(() => { throw new Error('Database construction is forbidden in mock-only tests'); }), +})); +vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in mock-only tests'); }); +vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} })); + +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared'; + +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, + }; + let inTransaction = false; + const waypointRun = vi.fn(() => { expect(inTransaction).toBe(true); }); + const db = { + get: vi.fn((): unknown => row), + all: vi.fn((sql: string): unknown[] => sql.includes('JOIN places') ? [row] : request.waypoints), + run: vi.fn<(sql: string, ...params: unknown[]) => { lastInsertRowid: number }>(() => { + expect(inTransaction).toBe(true); + return { lastInsertRowid: 42 }; + }), + prepare: vi.fn(() => { + expect(inTransaction).toBe(true); + return { run: waypointRun }; + }), + transaction: vi.fn((write: () => void) => { + inTransaction = true; + try { write(); } finally { inTransaction = false; } + }), + getPlaceWithTags: vi.fn(() => ({ id: 42, trip_id: 7 })), + }; + const places = { broadcast: vi.fn() }; + return { db, waypointRun, places, service: new ToursService(db as never, places as never) }; +} + +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, +}; + +describe('ToursService planner contracts (mock-only; SQLite rollback semantics require separate integration coverage)', () => { + let setup: ReturnType; + beforeEach(() => { setup = makeService(); }); + + it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one injected transaction', () => { + const result = setup.service.createTour('7', request, 'socket-1'); + + expect(setup.db.transaction).toHaveBeenCalledOnce(); + expect(setup.db.run).toHaveBeenCalledTimes(2); + expect(setup.db.run).toHaveBeenNthCalledWith(1, expect.stringContaining('INSERT INTO places'), '7', request.name, 48, 11, JSON.stringify(request.route_geometry)); + expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO tours'), 42, 'hike', expect.any(Number), 50, 20, 60, 2); + expect(setup.db.run.mock.calls[1][3]).toBeGreaterThan(0); + expect(setup.waypointRun).toHaveBeenCalledTimes(2); + for (const waypoint of request.waypoints) { + expect(setup.waypointRun).toHaveBeenCalledWith(42, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence); + } + 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: { id: 42, trip_id: 7 } }, 'socket-1'); + }); + + it('TOURS-SVC-002: propagates injected waypoint failure without publishing or reading an uncommitted create', () => { + const failure = new Error('Injected waypoint failure'); + setup.waypointRun.mockImplementationOnce(() => { throw failure; }); + expect(() => setup.service.createTour('7', request)).toThrow(failure); + expect(setup.db.transaction).toHaveBeenCalledOnce(); + expect(setup.db.all).not.toHaveBeenCalled(); + expect(setup.db.getPlaceWithTags).not.toHaveBeenCalled(); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-003: validates and rounds fractional routed duration before the create write', () => { + const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 }); + const result = setup.service.createTour('7', validated); + expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO tours'), 42, 'hike', expect.any(Number), 50, 20, 60, 2); + 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', () => { + expect(setup.service.getTour('7', '42').waypoints).toEqual(request.waypoints); + expect(setup.db.get).toHaveBeenCalledWith(expect.stringMatching(/WHERE p.trip_id = \? AND p.id = \?/), '7', '42'); + expect(setup.db.all).toHaveBeenCalledWith(expect.stringContaining('ORDER BY sequence'), '42'); + setup.db.get.mockReturnValueOnce(undefined); + setup.db.all.mockClear(); + expect(() => setup.service.getTour('8', '42')).toThrow('Tour not found'); + expect(setup.db.all).not.toHaveBeenCalled(); + setup.db.get.mockReturnValueOnce({ place_id: 42, has_waypoints: 0 }).mockReturnValueOnce({ route_geometry: JSON.stringify(request.route_geometry) }); + setup.db.all.mockReturnValueOnce([]); + const legacyGpx = setup.service.getTour('7', '42'); + 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 }, + ]); + for (const method of [setup.db.transaction, setup.db.run, setup.db.prepare, setup.waypointRun, setup.places.broadcast]) { + expect(method).not.toHaveBeenCalled(); + } + }); + + it('TOURS-SVC-005: updates route data and replaces controls through the injected transaction', () => { + 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, + }; + + setup.service.updateTour('7', '42', update, 'socket-2'); + expect(setup.db.transaction).toHaveBeenCalledOnce(); + expect(setup.db.run).toHaveBeenCalledTimes(3); + expect(setup.db.run).toHaveBeenNthCalledWith(1, expect.stringMatching(/WHERE id = \? AND trip_id = \?/), update.name, 49, 12, JSON.stringify(update.route_geometry), '42', '7'); + expect(setup.db.run).toHaveBeenNthCalledWith(2, expect.stringContaining('UPDATE tours'), 'hike', expect.any(Number), 60, 0, 45, 2, '42'); + expect(setup.db.run).toHaveBeenNthCalledWith(3, 'DELETE FROM tour_waypoints WHERE place_id = ?', '42'); + expect(setup.waypointRun).toHaveBeenCalledTimes(3); + for (const waypoint of update.waypoints) { + expect(setup.waypointRun).toHaveBeenCalledWith('42', waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence); + } + expect(setup.places.broadcast).toHaveBeenCalledWith( + '7', 'place:updated', expect.objectContaining({ place: expect.anything() }), 'socket-2', + ); + }); + + it('TOURS-SVC-006: propagates injected replacement failure without publishing or reloading', () => { + const failure = new Error('Injected replacement failure'); + setup.waypointRun.mockImplementationOnce(() => { throw failure; }); + expect(() => setup.service.updateTour('7', '42', request)).toThrow(failure); + expect(setup.db.transaction).toHaveBeenCalledOnce(); + expect(setup.db.get).toHaveBeenCalledTimes(1); + expect(setup.db.getPlaceWithTags).not.toHaveBeenCalled(); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file 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 000000000..662d95dfd --- /dev/null +++ b/server/tests/unit/nest/tours.service.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { Logger } 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), + }), +})); + +import { ToursService } from '../../../src/nest/tours/tours.service'; +import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared'; + +function makeService() { + const raw = new Database(':memory:'); + raw.pragma('foreign_keys = ON'); + raw.exec(` + CREATE TABLE places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + transport_mode TEXT, + route_geometry TEXT + ); + CREATE TABLE day_assignments (place_id INTEGER); + CREATE TABLE tour_types (key TEXT PRIMARY KEY); + INSERT INTO tour_types (key) VALUES ('hike'); + 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, max_hiking_difficulty INTEGER NOT NULL DEFAULT 2, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + 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, lng REAL NOT NULL, + role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), + sequence INTEGER NOT NULL, + UNIQUE(place_id, sequence) + ); + `); + const db = { + get: (sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params as never[]) as T | undefined, + all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params as never[]) as T[], + run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]), + prepare: (sql: string) => raw.prepare(sql), + transaction: (fn: () => T) => raw.transaction(fn)(), + getPlaceWithTags: (id: number) => raw.prepare('SELECT * FROM places WHERE id = ?').get(id), + }; + const places = { broadcast: vi.fn() }; + return { raw, places, service: new ToursService(db as never, places as never) }; +} + +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, +}; + +describe('ToursService planner creation', () => { + let setup: ReturnType; + beforeEach(() => { setup = makeService(); }); + + it('does not access the legacy global database initializer', () => { + expect(legacyDatabaseAccess).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', () => { + const writeStates: boolean[] = []; + setup.places.broadcast.mockImplementation(() => { writeStates.push(setup.raw.inTransaction); }); + const result = setup.service.createTour('7', request, 'socket-1'); + + const place = setup.raw.prepare('SELECT * FROM places').get() as Record; + const tour = setup.raw.prepare('SELECT * FROM tours').get() as Record; + const points = setup.raw.prepare('SELECT role, sequence FROM tour_waypoints ORDER BY sequence').all(); + + expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry); + 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 }); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [1] }, 'socket-1'); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: expect.anything() }, 'socket-1'); + expect(setup.places.broadcast).toHaveBeenCalledTimes(2); + expect(writeStates).toEqual([false, false]); + }); + + it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', () => { + const invalid = { + ...request, + waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }], + } as TourCreateRequest; + + expect(() => setup.service.createTour('7', invalid)).toThrow(); + expect(setup.raw.prepare('SELECT count(*) AS n FROM places').get()).toEqual({ n: 0 }); + expect(setup.raw.prepare('SELECT count(*) AS n FROM tours').get()).toEqual({ n: 0 }); + expect(setup.places.broadcast).not.toHaveBeenCalled(); + }); + + it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', () => { + const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 }); + + const result = setup.service.createTour('7', validated); + + expect(setup.raw.prepare('SELECT count(*) AS n FROM places').get()).toEqual({ n: 1 }); + expect(setup.raw.prepare('SELECT count(*) AS n FROM tours').get()).toEqual({ n: 1 }); + expect(setup.raw.prepare('SELECT count(*) AS n FROM tour_waypoints').get()).toEqual({ n: 2 }); + expect(setup.raw.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 }); + expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false }); + }); + + it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', () => { + const created = setup.service.createTour('7', request); + + expect(setup.service.getTour('7', String(created.tour.place_id))).toEqual(created); + expect(() => setup.service.getTour('8', String(created.tour.place_id))).toThrow('Tour not found'); + + setup.raw.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id); + const legacyGpx = setup.service.getTour('7', String(created.tour.place_id)); + 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 }, + ]); + }); + + it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', () => { + const created = setup.service.createTour('7', request); + setup.places.broadcast.mockClear(); + const writeStates: boolean[] = []; + setup.places.broadcast.mockImplementation(() => { writeStates.push(setup.raw.inTransaction); }); + 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, + }; + + const result = setup.service.updateTour('7', 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(setup.raw.prepare('SELECT name, lat, lng FROM places').get()).toEqual({ + name: 'Updated ridge walk', lat: 49, lng: 12, + }); + expect(setup.raw.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 45 }); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [created.tour.place_id] }, 'socket-2'); + expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, + '7', 'place:updated', expect.objectContaining({ place: expect.anything() }), 'socket-2', + ); + expect(writeStates).toEqual([false, false]); + }); + + it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', () => { + const created = setup.service.createTour('7', request); + const invalid = { + ...request, + name: 'Must roll back', + waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }], + } as TourCreateRequest; + + expect(() => setup.service.updateTour('7', String(created.tour.place_id), invalid)).toThrow(); + expect(setup.raw.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' }); + expect(setup.service.getTour('7', String(created.tour.place_id)).waypoints).toEqual(request.waypoints); + }); + + it('keeps a committed create successful when Tours invalidation publication fails', () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + setup.places.broadcast.mockImplementation((_tripId, event) => { + if (event === 'tours:changed') throw new Error('transport unavailable'); + }); + + const result = setup.service.createTour('7', request); + + expect(result.tour.name).toBe('Ridge walk'); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM places').get()).toEqual({ count: 1 }); + expect(setup.raw.prepare('SELECT COUNT(*) AS count FROM tours').get()).toEqual({ count: 1 }); + expect(setup.places.broadcast).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledOnce(); + }); +}); diff --git a/server/tests/unit/nest/wiring.test.ts b/server/tests/unit/nest/wiring.test.ts index 8cb519459..75e974820 100644 --- a/server/tests/unit/nest/wiring.test.ts +++ b/server/tests/unit/nest/wiring.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { db } from '../../../src/db/database'; @@ -7,11 +7,40 @@ import { FeaturesController } from '../../../src/nest/health/features.controller import { DatabaseService } from '../../../src/nest/database/database.service'; 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 pinned upstream module union and Tours exactly once', () => { + const imports = Reflect.getMetadata('imports', AppModule) as Array; + const names = imports.map(entry => (typeof entry === 'function' ? entry : entry.module).name); + expect(names).toHaveLength(68); + 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 () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(DatabaseService) diff --git a/shared/src/assignment/assignment.schema.spec.ts b/shared/src/assignment/assignment.schema.spec.ts index 31be55e01..5159a00b9 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 32287a337..352204652 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 d7fad9f62..fd08a5f2d 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 000000000..2768d7634 --- /dev/null +++ b/shared/src/i18n/ar/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': 'تم استيراد {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.neutralHint': 'بعد البدء، انقر على الخريطة لإضافة أول نقطة مرور.', + '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 d7fad9f62..73301a146 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 000000000..ca3753120 --- /dev/null +++ b/shared/src/i18n/az/tours.ts @@ -0,0 +1,134 @@ +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': 'Yol nöqtələri ilə marşrut yaradın və bu səyahətdə saxlayın.', + 'tours.planner.neutralHint': 'Başladıqdan sonra ilk yol nöqtəsini əlavə etmək üçü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': 'Başlanğıc, aralıq və son nöqtələri ə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 0afbf275b..7417e463d 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 000000000..e5ad0cf54 --- /dev/null +++ b/shared/src/i18n/br/tours.ts @@ -0,0 +1,136 @@ +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': 'Crie uma rota com pontos de passagem e salve-a nesta viagem.', + 'tours.planner.neutralHint': 'Após começar, clique no mapa para adicionar o primeiro ponto de passagem.', + '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 partida, pontos de passagem e chegada.', + '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 99ad8c034..7ff67afe0 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 000000000..b33d3d5ea --- /dev/null +++ b/shared/src/i18n/ca/tours.ts @@ -0,0 +1,135 @@ +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': 'Crea una ruta amb punts de pas i desa-la en aquest viatge.', + 'tours.planner.neutralHint': 'Un cop hagis començat, fes clic al mapa per afegir el primer punt de pas.', + '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 l'inici, els punts de pas i el final.", + '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 d7fad9f62..fd08a5f2d 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 000000000..362208a2d --- /dev/null +++ b/shared/src/i18n/cs/tours.ts @@ -0,0 +1,132 @@ +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': 'Vytvořte trasu s průjezdními body a uložte ji k této cestě.', + 'tours.planner.neutralHint': 'Po zahájení klikněte na mapu a přidejte první bod.', + '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 na mapu přidejte start, průjezdní body a cíl.', + '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 d7fad9f62..fd08a5f2d 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 000000000..58399c46d --- /dev/null +++ b/shared/src/i18n/de/tours.ts @@ -0,0 +1,137 @@ +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': 'Erstelle eine Route mit Wegpunkten und speichere sie für diese Reise.', + 'tours.planner.neutralHint': 'Klicke nach dem Start auf die Karte, um den ersten Wegpunkt 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 Start-, Zwischen- und Endpunkt 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 d7fad9f62..fd08a5f2d 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 000000000..dd22c8315 --- /dev/null +++ b/shared/src/i18n/en/tours.ts @@ -0,0 +1,133 @@ +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': 'Create a route with waypoints and save it to this trip.', + 'tours.planner.neutralHint': 'After starting, click the map to add your first waypoint.', + '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 start, via, and end 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 e4f631e73..87546f168 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 000000000..edb9c6b0e --- /dev/null +++ b/shared/src/i18n/es/tours.ts @@ -0,0 +1,134 @@ +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': 'Crea una ruta con puntos de paso y guárdala en este viaje.', + 'tours.planner.neutralHint': 'Tras empezar, haz clic en el mapa para añadir el primer punto de paso.', + '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 el inicio, los puntos de paso y el final.', + '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 d7fad9f62..73301a146 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 000000000..01173a4a2 --- /dev/null +++ b/shared/src/i18n/et/tours.ts @@ -0,0 +1,134 @@ +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': 'Loo teekonnapunktidega marsruut ja salvesta see sellele reisile.', + 'tours.planner.neutralHint': 'Pärast alustamist klõpsa kaardil esimese teekonnapunkti lisamiseks.', + '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 algus-, vahe- ja lõpp-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 d7fad9f62..fd08a5f2d 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 000000000..9211c3a34 --- /dev/null +++ b/shared/src/i18n/fr/tours.ts @@ -0,0 +1,140 @@ +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': 'Créez un itinéraire avec des points de passage et enregistrez-le dans ce voyage.', + 'tours.planner.neutralHint': + 'Après avoir commencé, cliquez sur la carte pour ajouter votre premier point de passage.', + '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 le départ, les points de passage et l'arrivée.", + '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 d7fad9f62..fd08a5f2d 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 000000000..31f1f5f72 --- /dev/null +++ b/shared/src/i18n/gr/tours.ts @@ -0,0 +1,140 @@ +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.neutralHint': 'Αφού ξεκινήσετε, κάντε κλικ στον χάρτη για να προσθέσετε το πρώτο σημείο διέλευσης.', + '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 0afbf275b..7417e463d 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 000000000..53a3aee8c --- /dev/null +++ b/shared/src/i18n/hu/tours.ts @@ -0,0 +1,136 @@ +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': 'Hozz létre útvonalat útvonalpontokkal, és mentsd ehhez az utazáshoz.', + 'tours.planner.neutralHint': 'Kezdés után kattints a térképre az első útvonalpont hozzáadásához.', + '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 a kezdőpont, köztes pontok és végpont 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 d7fad9f62..fd08a5f2d 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 000000000..d4e1861c9 --- /dev/null +++ b/shared/src/i18n/id/tours.ts @@ -0,0 +1,135 @@ +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': 'Buat rute dengan titik-titik lintasan dan simpan ke perjalanan ini.', + 'tours.planner.neutralHint': 'Setelah memulai, klik peta untuk menambahkan titik lintasan pertama.', + '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 awal, titik antara, dan titik akhir.', + '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 d7fad9f62..fd08a5f2d 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 000000000..e8a1a6092 --- /dev/null +++ b/shared/src/i18n/it/tours.ts @@ -0,0 +1,136 @@ +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': 'Crea un percorso con punti di passaggio e salvalo in questo viaggio.', + 'tours.planner.neutralHint': 'Dopo aver iniziato, fai clic sulla mappa per aggiungere il primo punto di passaggio.', + '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 partenza, punti di passaggio e arrivo.', + '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 d7fad9f62..fd08a5f2d 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 000000000..e5ecbb748 --- /dev/null +++ b/shared/src/i18n/ja/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': + '選択内容にはツアーが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.neutralHint': '開始後、地図をクリックして最初の地点を追加してください。', + '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 d7fad9f62..fd08a5f2d 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 000000000..1fb3d3743 --- /dev/null +++ b/shared/src/i18n/ko/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': '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.neutralHint': '시작한 뒤 지도를 클릭해 첫 지점을 추가하세요.', + '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 d7fad9f62..fd08a5f2d 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 000000000..6adea895f --- /dev/null +++ b/shared/src/i18n/nl/tours.ts @@ -0,0 +1,135 @@ +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': 'Maak een route met routepunten en sla deze op bij deze reis.', + 'tours.planner.neutralHint': 'Klik na het starten op de kaart om je eerste routepunt toe te voegen.', + '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 start-, tussen- en eindpunten 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 d7fad9f62..fd08a5f2d 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 000000000..7122f657a --- /dev/null +++ b/shared/src/i18n/pl/tours.ts @@ -0,0 +1,134 @@ +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': 'Utwórz trasę z punktami pośrednimi i zapisz ją w tej podróży.', + 'tours.planner.neutralHint': 'Po rozpoczęciu kliknij mapę, aby dodać pierwszy punkt.', + '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ć początek, punkty pośrednie i koniec trasy.', + '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 d7fad9f62..fd08a5f2d 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 000000000..85954f784 --- /dev/null +++ b/shared/src/i18n/ru/tours.ts @@ -0,0 +1,135 @@ +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.neutralHint': 'После начала нажмите на карту, чтобы добавить первую путевую точку.', + '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 d7fad9f62..73301a146 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 000000000..758158733 --- /dev/null +++ b/shared/src/i18n/sk/tours.ts @@ -0,0 +1,132 @@ +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': 'Vytvorte trasu s bodmi a uložte ju do tejto cesty.', + 'tours.planner.neutralHint': 'Po spustení kliknite na mapu a pridajte prvý bod trasy.', + '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 na mapu pridajte začiatok, priebežné body a koniec.', + '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 d7fad9f62..fd08a5f2d 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 000000000..09e70deba --- /dev/null +++ b/shared/src/i18n/sv/tours.ts @@ -0,0 +1,133 @@ +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': 'Skapa en rutt med ruttpunkter och spara den till den här resan.', + 'tours.planner.neutralHint': 'När du har börjat klickar du på kartan för att lägga till din första ruttpunkt.', + '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 start-, mellan- och slutpunkter.', + '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 d7fad9f62..73301a146 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 000000000..c02ed12ef --- /dev/null +++ b/shared/src/i18n/th/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': 'นำเข้าทัวร์ {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.neutralHint': 'เมื่อเริ่มแล้ว คลิกแผนที่เพื่อเพิ่มจุดแวะแรก', + '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 000000000..730a3bf37 --- /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 119 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(119); + }); + + 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(119); + expect(new Set(domainKeys).size).toBe(119); + 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 d7fad9f62..fd08a5f2d 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 000000000..da6289148 --- /dev/null +++ b/shared/src/i18n/tr/tours.ts @@ -0,0 +1,134 @@ +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': 'Rota noktalarıyla bir rota oluşturun ve bu seyahate kaydedin.', + 'tours.planner.neutralHint': 'Başladıktan sonra ilk rota noktasını eklemek 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': 'Başlangıç, ara ve bitiş noktalarını 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 d7fad9f62..fd08a5f2d 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 000000000..7384b8510 --- /dev/null +++ b/shared/src/i18n/uk/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.neutralHint': 'Після початку натисніть на карту, щоб додати першу маршрутну точку.', + '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 d7fad9f62..fd08a5f2d 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 000000000..8f06ac2c3 --- /dev/null +++ b/shared/src/i18n/vi/tours.ts @@ -0,0 +1,135 @@ +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': 'Tạo tuyến đường qua các điểm và lưu vào chuyến đi này.', + 'tours.planner.neutralHint': 'Sau khi bắt đầu, nhấp vào bản đồ để thêm điểm đầu tiên.', + '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 xuất phát, điểm đi qua và điểm kết thúc.', + '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 b51d0de98..17117e857 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 000000000..b80f78d68 --- /dev/null +++ b/shared/src/i18n/zh-TW/tours.ts @@ -0,0 +1,127 @@ +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.neutralHint': '開始後,點選地圖加入第一個路線點。', + '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 d7fad9f62..fd08a5f2d 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 000000000..48b4c8aac --- /dev/null +++ b/shared/src/i18n/zh/tours.ts @@ -0,0 +1,127 @@ +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.neutralHint': '开始后,点击地图添加第一个路线点。', + '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 038d1707f..67b06aaae 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 de4e2e596..01128ba13 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 d485b9632..9f90f8ad6 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 6ebd3a250..20ad12b46 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 000000000..d9fd86fe6 --- /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 000000000..6f41b641c --- /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;