Tours: provenance contract, source check, one assignment path and the map overview

- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
This commit is contained in:
Maurice
2026-10-15 21:51:22 +02:00
parent 018d2d1425
commit 3d9095ccde
77 changed files with 2845 additions and 118 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1150,
"src/pages/TripPlannerPage.tsx": 1147,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+1 -1
View File
@@ -62,7 +62,7 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 10,
"components/Map/MapView.tsx": 9,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
+4
View File
@@ -1,5 +1,6 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
@@ -39,6 +40,9 @@ export const wandererApi = {
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
+26 -2
View File
@@ -60,7 +60,7 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { resolveTrackColor, hasManualTrackColor, TRACK_CASING_COLOR } from './trackColors'
import DawarichTrailLayer from './DawarichTrailLayer'
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'
@@ -1051,7 +1051,7 @@ export const MapView = memo(function MapView({
key={`gpx-${place.id}-casing`}
positions={coords}
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
pathOptions={{ color: '#ffffff', weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
))}
@@ -1083,6 +1083,29 @@ export const MapView = memo(function MapView({
</>
), [gpxTracks, hasCasingPane])
// PR1-08: the track of the picked tour stands out, wider and with a light rim, so the line and
// the list's row read as one. Drawn on its own, so a selection never redraws every track.
const selectedTrack = useMemo(
() => (selectedPlaceId == null ? null : gpxTracks.find(track => track.place.id === selectedPlaceId) ?? null),
[gpxTracks, selectedPlaceId],
)
const selectedTrackLine = selectedTrack && (
<>
<Polyline
key={`gpx-selected-${selectedTrack.place.id}-casing`}
positions={selectedTrack.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${selectedTrack.place.id}`}
positions={selectedTrack.coords}
pathOptions={{ color: selectedTrack.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
)
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
@@ -1313,6 +1336,7 @@ export const MapView = memo(function MapView({
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
{selectedTrackLine}
{routeProfileFocus && (
<CircleMarker
+31 -1
View File
@@ -41,7 +41,7 @@ import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { resolveTrackColor, hasManualTrackColor, TRACK_CASING_COLOR } from './trackColors'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
@@ -67,6 +67,8 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
const GPX_SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing'
const GPX_SELECTED_LAYER_ID = 'trip-gpx-selected'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
@@ -1369,6 +1371,24 @@ export function MapViewGL({
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// PR1-08: the picked tour's track on top, wider with a light rim, as the Leaflet map draws
// it. The filter follows the selection; until something is picked it matches no track.
map.addLayer({
id: GPX_SELECTED_CASING_LAYER_ID,
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'place_id'], -1],
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: GPX_SELECTED_LAYER_ID,
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'place_id'], -1],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#3b82f6'], 'line-width': 5.5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// Invisible fat line that catches the click — 3.5px is not a target,
// and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
@@ -2093,6 +2113,16 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [places, mapReady])
// The picked tour's track stands out (PR1-08); -1 matches no track.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || typeof map.getLayer !== 'function') return
const filter = ['==', ['get', 'place_id'], selectedPlaceId ?? -1] as mapboxgl.FilterSpecification
for (const id of [GPX_SELECTED_CASING_LAYER_ID, GPX_SELECTED_LAYER_ID]) {
if (map.getLayer(id)) map.setFilter(id, filter)
}
}, [selectedPlaceId, mapReady])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
+3
View File
@@ -1,6 +1,9 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared'
import type { Place } from '../../types'
/** The light rim under a track that carries a picked colour, and under the track picked on the map. */
export const TRACK_CASING_COLOR = '#ffffff'
/**
* The colour a track's polyline is drawn in (#776): a manually picked colour
* wins, then the category colour, then the blue every imported track used to
@@ -46,6 +46,7 @@ import { Tooltip } from '../shared/Tooltip';
import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { tourDaysOf } from './tourDays';
import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts';
import {
@@ -55,6 +56,7 @@ import {
tourSource,
tourWebsitePresentation,
} from './tourPresentation';
import { useTourDayAssignment } from './useTourDayAssignment';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
interface TourDetailDialogProps extends TourPermissionProps {
@@ -68,6 +70,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
onClose: () => void;
onUpdatePlace?: (placeId: number, data: Partial<Place>) => Promise<void> | void;
onFileUpload?: (formData: FormData) => Promise<unknown>;
/** A wanderer snapshot's source check brought the changed trail in as a new tour (MF-03). */
onSourceReimported?: (tour: TourListItem) => Promise<void> | void;
onAssignToDay?: (placeId: number, dayId?: number) => void;
onRemoveAssignment?: (dayId: number, assignmentId: number) => void;
onDelete?: () => void;
@@ -298,6 +302,7 @@ export default function TourDetailDialog({
onClose,
onUpdatePlace,
onFileUpload,
onSourceReimported,
onAssignToDay,
onRemoveAssignment,
onDelete,
@@ -315,6 +320,7 @@ export default function TourDetailDialog({
canAssign: assignPermission,
});
const canEdit = hasEditPermission && !readOnly;
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay: (id, dayId) => onAssignToDay?.(id, dayId) });
const { t } = useTranslation();
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const [colorOpen, setColorOpen] = useState(false);
@@ -453,9 +459,8 @@ export default function TourDetailDialog({
: onAssignToDay && (
<DialogButton
variant="primary"
onClick={() => {
if (canAssign) onAssignToDay(place.id);
}}
onClick={() => void dayAssignment.assign(place.id, selectedDayId)}
disabled={!dayAssignment.isAvailable(place.id, selectedDayId)}
icon={<Plus size={14} strokeWidth={2} />}
>
{t('inspector.addToDay')}
@@ -582,6 +587,7 @@ export default function TourDetailDialog({
</DialogSection>
)}
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
</DetailShell>
);
+37 -2
View File
@@ -1,4 +1,5 @@
import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
@@ -6,6 +7,7 @@ import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
import { useTourDays } from './tourDays'
import { Tooltip } from '../shared/Tooltip'
const NO_DAYS: Day[] = []
@@ -20,6 +22,30 @@ interface TourListRowProps {
onSelect: (tour: TourListItem, opener: HTMLElement) => void
action?: ReactNode
children?: ReactNode
/** The colour the tour's line has on the map, shown beside it so the two read as one. */
trackColor?: string
/** PR1-07: the person's switch for this tour's line on the trip map. */
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
}
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['mapToggle']> }) {
const label = toggle.hidden ? toggle.showLabel : toggle.hideLabel
return (
<Tooltip label={label}>
<button
type="button"
aria-label={label}
aria-pressed={toggle.hidden}
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden
? 'bg-surface-tertiary text-content-muted ring-edge-faint hover:text-content'
: 'bg-surface-card text-content-muted ring-edge-faint hover:text-content'}`}
>
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
</button>
</Tooltip>
)
}
/**
@@ -31,7 +57,7 @@ interface TourListRowProps {
export default function TourListRow({ tour,
days = NO_DAYS,
selected = false, disabled = false,
draggableToDay = false, onSelect, action, children }: TourListRowProps) {
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
const nameId = useId()
const tourDays = useTourDays(tour.place_id, days)
const draggable = draggableToDay && !disabled
@@ -44,6 +70,7 @@ export default function TourListRow({ tour,
aria-selected={selected}
aria-disabled={disabled || undefined}
aria-labelledby={nameId}
data-place-id={tour.place_id}
draggable={draggable || undefined}
onDragStart={draggable ? (event) => startPlaceDrag(event.dataTransfer, tour.place_id) : undefined}
onDragEnd={draggable ? endPlaceDrag : undefined}
@@ -58,6 +85,11 @@ export default function TourListRow({ tour,
}}
>
<div className="flex items-center gap-2.5">
{trackColor && (
// The line's colour on the map, as a plan row shows its category.
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
)}
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${mapToggle?.hidden ? 'opacity-55' : ''}`}>
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
@@ -81,11 +113,14 @@ export default function TourListRow({ tour,
)}
</div>
</div>
{action && (
</div>
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
{(action || (mapToggle && !mapToggle.hidden)) && (
// Out on hover, focus or selection, as a plan row's controls are: the name and badges have the room otherwise.
<div data-testid="tour-action-row" role="presentation"
onClick={event => event.stopPropagation()}
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
{mapToggle && !mapToggle.hidden && <MapToggleButton toggle={mapToggle} />}
{action}
</div>
)}
+44 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, FileDown } from 'lucide-react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, EyeOff, FileDown } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { useTranslation } from '../../i18n'
@@ -11,6 +11,10 @@ import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import { filterTours, type TourFilter } from './tourPresentation'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
import { useTripStore } from '../../store/tripStore'
import { resolveTrackColor } from '../Map/trackColors'
interface ToursSidebarProps extends TourPermissionProps {
tripId: number
@@ -18,7 +22,7 @@ interface ToursSidebarProps extends TourPermissionProps {
tours: TourListItem[]
loading?: boolean
selectedPlaceId?: number | null
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onAssignToDay: AssignTourToDay
pushUndo?: (label: string, undo: () => void | Promise<void>) => void
/** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */
onToursChanged?: (placeIds?: number[]) => void | Promise<void>
@@ -45,9 +49,22 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const handleAssign = (tour: TourListItem, day: Day) => {
if (canAssign) void onAssignToDay(tour.place_id, day.id)
}
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
const visibility = useTourMapVisibility(tripId, tourIds)
// The colour each line has on the map, so a row and its line read as one (PR1-08).
const places = useTripStore(state => state.places)
const trackColors = useMemo(
() => new Map(places.filter(place => tourIds.has(place.id)).map(place => [place.id, resolveTrackColor(place)])),
[places, tourIds],
)
const listRef = useRef<HTMLUListElement>(null)
// A tour picked on the map shows in the list too, scrolled into view.
useEffect(() => {
if (selectedPlaceId == null) return
const row = listRef.current?.querySelector<HTMLElement>(`[data-place-id="${selectedPlaceId}"]`)
row?.scrollIntoView?.({ block: 'nearest' })
}, [selectedPlaceId])
const counts = {
all: tours.length,
@@ -118,6 +135,18 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</div>
)}
</div>
{visibility.hiddenCount > 0 && (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate text-content-secondary" style={fs(12, 'body')}>{t('tours.visibility.hidden')}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{visibility.hiddenCount}</span>
<button type="button" onClick={visibility.showAll}
className="flex-none rounded-full px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.visibility.showAll')}
</button>
</div>
)}
</div>
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
@@ -125,7 +154,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<ul role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map(tour => (
<TourListRow
key={tour.place_id}
@@ -133,8 +162,15 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
days={days}
draggableToDay={canAssign && days.length > 0}
selected={tour.place_id === selectedPlaceId}
trackColor={trackColors.get(tour.place_id)}
mapToggle={{
hidden: visibility.isHidden(tour.place_id),
onToggle: () => visibility.toggle(tour.place_id),
showLabel: t('tours.visibility.show'),
hideLabel: t('tours.visibility.hide'),
}}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day) => handleAssign(tour, day)} />}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} assignment={dayAssignment} />}
/>
))}
</ul>
@@ -27,6 +27,7 @@ import TourConversionSection from '../conversion/TourConversionSection'
import type { GpxConversion } from '../conversion/useGpxConversion'
import type { RecordedTrack } from '../useRecordedTrack'
import type { TourGpxImport } from '../useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignment'
import WandererMark from '../wanderer/WandererMark'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
@@ -510,7 +511,7 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
tours: TourListItem[]
days: Day[]
loading: boolean
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onAssignToDay: AssignTourToDay
onViewGpxTour: (tour: TourListItem) => void
onDeleteTour?: (placeId: number) => void
/** The workspace's GPX import; its button sits beside "New tour". */
@@ -524,6 +525,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const toast = useToast()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const isSaving = planner.isSaving
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
@@ -645,7 +647,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
days={days}
placeId={tour.place_id}
label={`${t('tours.addToDay')}: ${tour.name}`}
onPick={day => { if (canAssign) void onAssignToDay(tour.place_id, day.id) }}
assignment={dayAssignment}
/>
)}
{canEdit && onDeleteTour && (
@@ -0,0 +1,287 @@
import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../tourParts';
import WandererMark from '../wanderer/WandererMark';
import { type SourceCheck, useSourceCheck } from './useSourceCheck';
import { useTourProvenance } from './useTourProvenance';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** A stored time as the reader's date; the time of day says nothing here. */
function dayOf(value: string | null, locale: string): string | null {
return value ? formatDate(value.slice(0, 10), locale) : null;
}
function hostOf(instance: string): string {
try {
return new URL(instance).host;
} catch {
return instance;
}
}
/** One line of the card: a round icon, what it is, and the value beside it. */
function ProvenanceRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 items-center gap-2.5 px-3 py-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
{icon}
</span>
<span className="w-[92px] flex-none truncate text-content-faint" style={fs(11)}>
{label}
</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 text-content" style={fs(12.5, 'body')}>
{children}
</div>
</div>
);
}
function SourceValue({ provenance, t }: { provenance: TourProvenance; t: Translate }) {
const { source } = provenance;
if (source.kind === 'gpx') {
return (
<>
<span className="font-semibold">{t('tours.badge.gpx')}</span>
{source.fileName && (
<span className="min-w-0 truncate font-geist text-content-muted" style={fs(11.5)}>
{source.fileName}
</span>
)}
</>
);
}
if (source.kind === 'wanderer') {
return (
<>
<span className="font-semibold">{t('tours.badge.wanderer')}</span>
<span className="min-w-0 truncate font-geist text-content-muted" style={fs(11.5)}>
{hostOf(source.instance)}
</span>
{source.externalUrl && (
<Tooltip label={t('tours.provenance.openSource')}>
<a
href={source.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={t('tours.provenance.openSource')}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ExternalLink size={12} strokeWidth={2.2} />
</a>
</Tooltip>
)}
</>
);
}
return <span className="font-semibold">{t('tours.badge.planned')}</span>;
}
/** How the tour stands here against what came in: planned over, edited, or as it was. */
function LocalState({ provenance, t }: { provenance: TourProvenance; t: Translate }) {
const pills: ReactNode[] = [];
if (provenance.plannedOver) {
pills.push(
<SoftPill key="planned" tone="info">
{t('tours.provenance.plannedOver')}
</SoftPill>
);
}
if (provenance.editedLocally === true) {
pills.push(
<SoftPill key="edited" tone="warning">
{t('tours.provenance.editedLocally')}
</SoftPill>
);
} else if (provenance.editedLocally === false && !provenance.plannedOver) {
pills.push(
<SoftPill key="same" tone="success">
{t('tours.provenance.asImported')}
</SoftPill>
);
}
return pills.length ? <>{pills}</> : null;
}
const STATE_TONE: Record<TourSourceCheckResponse['state'], 'success' | 'warning' | 'neutral'> = {
unchanged: 'success',
changed: 'warning',
unavailable: 'neutral',
not_connected: 'neutral',
other_instance: 'neutral',
unknown: 'neutral',
};
/** What the check found, the parts that differ, and the way to take the change in. */
function CheckResult({ check, locale, t }: { check: SourceCheck; locale: string; t: Translate }) {
const { result } = check;
if (!result) return null;
const body =
result.state === 'unchanged' || result.state === 'changed' ? null : t(`tours.provenance.body.${result.state}`);
const nowUpdated = dayOf(result.externalUpdated, locale);
return (
<div role="status" className="flex flex-col gap-2 border-t border-edge-faint px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<SoftPill tone={STATE_TONE[result.state]}>{t(`tours.provenance.state.${result.state}`)}</SoftPill>
{result.changes.map((part) => (
<SoftPill key={part}>{t(`tours.provenance.change.${part}`)}</SoftPill>
))}
</div>
{body && (
<p className="m-0 leading-snug text-content-muted" style={fs(12, 'body')}>
{body}
</p>
)}
{nowUpdated && (
<p className="m-0 leading-snug text-content-faint" style={fs(11)}>
{t('tours.provenance.nowUpdated', { date: nowUpdated })}
</p>
)}
{check.canReimport && (
<div>
<CardAction
icon={
check.reimporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} strokeWidth={2.2} />
)
}
onClick={check.askReimport}
disabled={check.reimporting}
>
{t('tours.provenance.reimport')}
</CardAction>
</div>
)}
</div>
);
}
interface TourProvenanceSectionProps {
provenance: TourProvenance;
/** Given for a wanderer snapshot the user may change: the check of its source and the reimport. */
check?: SourceCheck;
}
/**
* P2C-02 in the tour's detail: where it came from (planned here, a GPX file,
* a wanderer trail with its link), when, and how it stands here against what
* came in. For a wanderer snapshot, MF-03: the check of its source, on purpose
* and read only, and from a change the confirmed reimport as a new snapshot.
*/
export default function TourProvenanceSection({ provenance, check }: TourProvenanceSectionProps) {
const { t, locale } = useTranslation();
const { source } = provenance;
const imported = source.kind !== 'trek';
const importedAt = dayOf((source.kind === 'wanderer' ? source.importedAt : null) ?? provenance.createdAt, locale);
const externalUpdated = source.kind === 'wanderer' ? dayOf(source.externalUpdated, locale) : null;
const local = <LocalState provenance={provenance} t={t} />;
const sourceIcon =
source.kind === 'gpx' ? (
<FileDown size={13} strokeWidth={2} />
) : source.kind === 'wanderer' ? (
<WandererMark size={13} />
) : (
<PenLine size={13} strokeWidth={2} />
);
return (
<DialogSection
className="flex-none"
label={t('tours.provenance.label')}
action={
check && (
<Tooltip label={t('tours.provenance.checkHint')}>
<span className="inline-flex">
<CardAction
icon={
check.checking ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} strokeWidth={2.2} />
)
}
onClick={() => void check.check()}
disabled={check.checking}
>
{t('tours.provenance.check')}
</CardAction>
</span>
</Tooltip>
)
}
>
<div className={`${BOX} divide-y divide-edge-faint`}>
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
<SourceValue provenance={provenance} t={t} />
</ProvenanceRow>
{importedAt && (
<ProvenanceRow
icon={<Download size={13} strokeWidth={2} />}
label={t(imported ? 'tours.provenance.importedAt' : 'tours.provenance.createdAt')}
>
<span>{importedAt}</span>
</ProvenanceRow>
)}
{externalUpdated && (
<ProvenanceRow icon={<RefreshCw size={13} strokeWidth={2} />} label={t('tours.provenance.externalUpdated')}>
<span>{externalUpdated}</span>
<InfoDot
label={t('tours.provenance.externalUpdated')}
description={t('tours.provenance.externalUpdatedHint')}
/>
</ProvenanceRow>
)}
{imported && (provenance.plannedOver || provenance.editedLocally !== null) && (
<ProvenanceRow icon={<PenLine size={13} strokeWidth={2} />} label={t('tours.provenance.localState')}>
{local}
</ProvenanceRow>
)}
{check && <CheckResult check={check} locale={locale} t={t} />}
</div>
{check && (
<ConfirmDialog
isOpen={check.confirming}
onClose={check.cancelReimport}
onConfirm={() => void check.reimport()}
title={t('tours.provenance.reimportTitle')}
message={t('tours.provenance.reimportBody')}
confirmLabel={t('tours.provenance.reimport')}
danger={false}
/>
)}
</DialogSection>
);
}
/**
* The section as the tour's detail places it: reads the provenance with the
* detail, and offers the source check only on a wanderer snapshot the user
* may change, where a reimport has somewhere to go.
*/
export function TourProvenanceBlock({
tripId,
tour,
canCheck,
onReimported,
}: {
tripId: number;
tour: TourListItem;
canCheck: boolean;
onReimported?: (tour: TourListItem) => Promise<void> | void;
}) {
const provenance = useTourProvenance(tripId, tour);
const check = useSourceCheck({ tripId, placeId: tour.place_id, onReimported });
if (!provenance) return null;
const checkable = canCheck && provenance.origin === 'wanderer';
return <TourProvenanceSection provenance={provenance} check={checkable ? check : undefined} />;
}
@@ -0,0 +1,80 @@
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo';
import { useToast } from '../../shared/Toast';
import { wandererErrorText } from '../wanderer/wandererErrors';
interface SourceCheckOptions {
tripId: number;
placeId: number;
/** The new snapshot a confirmed reimport brought in, for the lists and the map to take it in. */
onReimported?: (tour: TourListItem) => Promise<void> | void;
}
/**
* MF-03: asking the source of a wanderer snapshot, on purpose, whether the
* trail changed since it came in. The check only reads; nothing in the trip
* changes until the user confirms a reimport, which goes the way every
* wanderer import goes and comes in as a new snapshot beside this one.
*/
export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOptions) {
const { t } = useTranslation();
const toast = useToast();
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<TourSourceCheckResponse | null>(null);
const [confirming, setConfirming] = useState(false);
const [reimporting, setReimporting] = useState(false);
// Another tour shown in the same dialog starts unchecked.
useEffect(() => {
setResult(null);
setConfirming(false);
}, [placeId]);
const check = useCallback(async () => {
if (checking) return;
setChecking(true);
try {
setResult(await wandererRepo.checkSource(tripId, placeId));
} catch (error: unknown) {
setResult(null);
toast.error(wandererErrorText(t, wandererErrorCode(error)));
} finally {
setChecking(false);
}
}, [checking, placeId, t, toast, tripId]);
const reimport = useCallback(async () => {
const payload = result?.reimport;
if (!payload || reimporting) return;
setReimporting(true);
try {
const { tour } = await wandererRepo.importTrail(tripId, payload);
setConfirming(false);
setResult(null);
await onReimported?.(tour);
toast.success(t('tours.provenance.reimported'));
} catch (error: unknown) {
setConfirming(false);
toast.error(wandererErrorText(t, wandererErrorCode(error)));
} finally {
setReimporting(false);
}
}, [onReimported, reimporting, result, t, toast, tripId]);
return {
checking,
result,
check,
/** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */
canReimport: !!result?.reimport && !!onReimported,
confirming,
askReimport: useCallback(() => setConfirming(true), []),
cancelReimport: useCallback(() => setConfirming(false), []),
reimporting,
reimport,
};
}
export type SourceCheck = ReturnType<typeof useSourceCheck>;
@@ -0,0 +1,39 @@
import type { TourListItem, TourProvenance } from '@trek/shared';
import { useEffect, useState } from 'react';
import { tourRepo } from '../../../repo/tourRepo';
/**
* Where a tour came from (P2C-02), read with its detail: online from the
* server, offline from the copy the last detail left. Asked again whenever a
* field it depends on changes (the name and line for the local edit state, the
* kept track, the import it was planned from). Null while it loads and when no
* copy is to be had; the caller then simply shows no provenance.
*/
export function useTourProvenance(tripId: number, tour: TourListItem): TourProvenance | null {
const [state, setState] = useState<{ key: string; provenance: TourProvenance | null } | null>(null);
const key = [
tripId,
tour.place_id,
tour.name,
tour.distance,
tour.has_recorded_track,
tour.derived_from_place_id,
tour.origin,
].join('|');
useEffect(() => {
const controller = new AbortController();
tourRepo.detail(tripId, tour.place_id, controller.signal).then(
(detail) => {
if (!controller.signal.aborted) setState({ key, provenance: detail.provenance ?? null });
},
() => {
if (!controller.signal.aborted) setState({ key, provenance: null });
}
);
return () => controller.abort();
// `key` also carries the fields the detail is asked again for.
}, [key, tripId, tour.place_id]);
return state?.key === key ? state.provenance : null;
}
+10 -10
View File
@@ -7,12 +7,12 @@ import { useTranslation } from '../../i18n'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { DialogButton, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill, WhiteBadge } from '../Planner/planParts'
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
import { type TourDay, dayMenuLabel } from './tourDays'
import { tourMetricFacts } from './tourFacts'
import type { TourDayAssignment } from './useTourDayAssignment'
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
@@ -155,25 +155,25 @@ export function TourDayPills({
/**
* Putting a tour on a day: a round "+" on a row, or a footer button with a
* chevron, both opening the trip's days as a menu.
* chevron, both opening the trip's days as a menu. Which days it offers and
* the write itself come from the shared assignment rules (PR1-18): a day that
* has the tour, or is getting it right now, is greyed out.
*/
export function TourDayMenu({ days, placeId, onPick, disabled = false, label, variant = 'round', primary = false }: {
export function TourDayMenu({ days, placeId, assignment, label, variant = 'round', primary = false }: {
days: Day[]
placeId?: number
onPick: (day: Day, index: number) => void
disabled?: boolean
placeId: number
assignment: TourDayAssignment
label: string
variant?: 'round' | 'button'
primary?: boolean
}) {
const { t, locale } = useTranslation()
const menu = useContextMenu()
const assignments = useTripStore(state => state.assignments ?? {})
const assigned = (dayId: number) => placeId != null && (assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === placeId)
const disabled = !assignment.canAssign
const items = days.map((day, index) => ({
label: dayMenuLabel(day, index, t, locale),
disabled: assigned(day.id),
onClick: () => onPick(day, index),
disabled: !assignment.isAvailable(placeId, day.id),
onClick: () => void assignment.assign(placeId, day.id),
}))
const hasAvailableDay = items.some(item => !item.disabled)
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -1,8 +1,8 @@
import type { TourListItem } from '@trek/shared'
import type { TourListItem, TourOrigin } from '@trek/shared'
import { tourWebsiteSchema } from '@trek/shared'
import { formatDurationShort } from '@trek/shared/roadtrip'
export type TourSource = 'trek' | 'gpx' | 'wanderer'
export type TourSource = TourOrigin
/** All / Unplanned / Planned, the same three states as the Places pool. */
export type TourFilter = 'all' | 'unplanned' | 'planned'
@@ -64,13 +64,15 @@ type Translate = (key: string, params?: Record<string, unknown>) => string
* A wanderer reference is authoritative. Planner-authored TREK tours persist
* routing controls, while GPX imports intentionally remain geometry-only.
*/
export function tourSource(tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>): TourSource {
export function tourSource(tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints' | 'origin'>): TourSource {
// The origin the server recorded; a list cached before it existed still reads off the tour's shape.
if (tour.origin) return tour.origin
if (tour.wanderer_ref) return 'wanderer'
return tour.has_waypoints === false ? 'gpx' : 'trek'
}
export function hikeSourceBadgeLabel(
tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>,
tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints' | 'origin'>,
t: Translate,
): string {
return t(`tours.badge.${tourSource(tour)}`)
@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from 'react';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap } from '../../types';
/** A tour fills its day, so a day holds it at most once; the server refuses a second time the same way. */
export function isTourOnDay(assignments: AssignmentsMap, placeId: number, dayId: number): boolean {
return (assignments[String(dayId)] ?? []).some((assignment) => assignment.place_id === placeId);
}
/** The one write a tour goes onto a day through; `false` when it did not happen. */
export type AssignTourToDay = (
placeId: number,
dayId: number,
position?: number
) => void | boolean | Promise<void | boolean>;
interface TourDayAssignmentOptions {
/** Whether the user may change the days of the trip. */
canAssign: boolean;
onAssignToDay: AssignTourToDay;
}
/**
* PR1-18: the rules every way of putting a tour on a day shares, the day menu
* of a tour list, the tour's detail and the phone's action sheet alike: the
* right to change the days, a day that already has the tour (from the store,
* so a change shows at once), and a request still on its way, so a second
* click cannot send it twice. The write itself is the plan's one assignment
* path, which also says what happened.
*/
export function useTourDayAssignment({ canAssign, onAssignToDay }: TourDayAssignmentOptions) {
const assignments = useTripStore((state) => state.assignments);
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
// Read before the state has rendered: two clicks in one frame still send once.
const inFlight = useRef(new Set<string>());
const isOnDay = useCallback(
(placeId: number, dayId: number) => isTourOnDay(assignments ?? {}, placeId, dayId),
[assignments]
);
const isPending = useCallback((placeId: number, dayId: number) => pending.has(`${placeId}:${dayId}`), [pending]);
const isAvailable = useCallback(
(placeId: number, dayId: number) => canAssign && !isOnDay(placeId, dayId) && !isPending(placeId, dayId),
[canAssign, isOnDay, isPending]
);
const assign = useCallback(
async (placeId: number, dayId: number, position?: number): Promise<boolean> => {
const key = `${placeId}:${dayId}`;
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
inFlight.current.add(key);
setPending((current) => new Set(current).add(key));
try {
return (await onAssignToDay(placeId, dayId, position)) !== false;
} finally {
inFlight.current.delete(key);
setPending((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
},
[isAvailable, onAssignToDay]
);
return { canAssign, isOnDay, isPending, isAvailable, assign };
}
export type TourDayAssignment = ReturnType<typeof useTourDayAssignment>;
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useAuthStore } from '../../store/authStore';
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore';
const NONE: ReadonlySet<number> = new Set();
/**
* PR1-07/08: the tours the signed-in person hid from this trip's map, read
* against the tours the trip has now, so a deleted tour hides nothing. The
* list and the map read the same store, so a toggle shows on both at once.
*/
export function useTourMapVisibility(tripId: number, tourIds: ReadonlySet<number>) {
const userId = useAuthStore((state) => state.user?.id ?? null);
const scope = userId === null ? null : tourVisibilityScope(userId, tripId);
const load = useTourMapVisibilityStore((state) => state.load);
const loaded = useTourMapVisibilityStore((state) => scope !== null && state.scope === scope);
const hidden = useTourMapVisibilityStore((state) => state.hidden);
const toggleStored = useTourMapVisibilityStore((state) => state.toggle);
const showAll = useTourMapVisibilityStore((state) => state.showAll);
useEffect(() => {
if (scope) load(scope);
}, [load, scope]);
const hiddenIds = useMemo(
() => (loaded ? new Set(hidden.filter((id) => tourIds.has(id))) : NONE),
[hidden, loaded, tourIds]
);
const isHidden = useCallback((placeId: number) => hiddenIds.has(placeId), [hiddenIds]);
const toggle = useCallback(
(placeId: number) => {
if (loaded) toggleStored(placeId);
},
[loaded, toggleStored]
);
return { hiddenIds, isHidden, toggle, showAll, hiddenCount: hiddenIds.size };
}
export type TourMapVisibility = ReturnType<typeof useTourMapVisibility>;
+9 -2
View File
@@ -1,4 +1,4 @@
import type { Ferrata, RoadtripPreferences, TourListItem, TourWaypoint } from '@trek/shared';
import type { Ferrata, RoadtripPreferences, TourListItem, TourProvenance, TourWaypoint } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -15,6 +15,8 @@ export interface CachedTripMember extends TripMember {
export interface CachedTour extends TourListItem {
trip_id: number;
waypoints?: TourWaypoint[];
/** Where the tour came from, as its detail last said; kept across list refreshes, since it does not change. */
provenance?: TourProvenance;
}
/**
@@ -464,7 +466,12 @@ export async function replaceTripTours(tripId: number, tours: TourListItem[]): P
&& previous.elevation_gain === tour.elevation_gain
&& previous.elevation_loss === tour.elevation_loss
&& previous.has_waypoints === tour.has_waypoints;
return { ...tour, trip_id: tripId, ...(sameRoute && previous.waypoints ? { waypoints: previous.waypoints } : {}) };
return {
...tour,
trip_id: tripId,
...(sameRoute && previous.waypoints ? { waypoints: previous.waypoints } : {}),
...(previous?.provenance ? { provenance: previous.provenance } : {}),
};
}));
});
}
@@ -6,6 +6,7 @@ import { useTranslation } from '../../../../i18n'
import { useAddonStore } from '../../../../store/addonStore'
import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../../../../components/Collections/saveTarget'
import { useTourDayAssignment } from '../../../../components/Tours/useTourDayAssignment'
import type { Place } from '../../../../types'
interface BrowseActionsPayload {
@@ -31,7 +32,8 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
const openSavePicker = useSaveToCollectionStore(s => s.open)
const [daysOpen, setDaysOpen] = useState(false)
const pendingTourDayIds = useRef(new Set<string>())
// A tour goes onto a day by the rules every tour list shares (PR1-18); a place keeps its own way.
const dayAssignment = useTourDayAssignment({ canAssign: canEditDays, onAssignToDay: planner.handleAssignToDay })
useEffect(() => { setDaysOpen(open && Boolean(payload.dayPicker)) }, [open, payload.dayPicker])
// Hold the last place so the card content survives the exit animation.
@@ -58,20 +60,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
openSavePicker(placeToSaveTarget(place))
}
const tourAlreadyAssignedToDay = (dayId: number) => isTourPlace(place.id)
&& (planner.assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === place.id)
const tourUnavailableOnDay = (dayId: number) => isTourPlace(place.id) && !dayAssignment.isAvailable(place.id, dayId)
const assignToDay = async (dayId: number) => {
const isTour = isTourPlace(place.id)
const pendingKey = `${place.id}:${dayId}`
if (isTour && (tourAlreadyAssignedToDay(dayId) || pendingTourDayIds.current.has(pendingKey))) return
if (isTour) pendingTourDayIds.current.add(pendingKey)
try {
const assigned = await planner.handleAssignToDay(place.id, dayId)
if (assigned !== false) shell.closeSheet()
} finally {
if (isTour) pendingTourDayIds.current.delete(pendingKey)
}
const assigned = isTourPlace(place.id)
? await dayAssignment.assign(place.id, dayId)
: await planner.handleAssignToDay(place.id, dayId)
if (assigned !== false) shell.closeSheet()
}
const deletePlace = () => {
@@ -124,7 +119,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
<button
key={d.id}
type="button"
disabled={tourAlreadyAssignedToDay(d.id) || (isTourPlace(place.id) && pendingTourDayIds.current.has(`${place.id}:${d.id}`))}
disabled={tourUnavailableOnDay(d.id)}
onClick={() => { void assignToDay(d.id) }}
className={`flex w-full items-center gap-2 px-3 py-[10px] text-start disabled:cursor-default disabled:opacity-40 ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
>
+2 -4
View File
@@ -138,6 +138,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
wandererBrowser,
viewImportedTour,
tourConversion,
tourDetailActions,
recordedTrack,
permissions: { canPlaceEdit, canDayEdit },
tourDetails,
@@ -230,10 +231,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onImageFromFile: async (placeId: number, fileId: number) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) },
onRate: placeActions.ratePlace,
}
const tourDetailProps = {
...selectedPlaceProps,
onUpdatePlace: async (placeId: number, data: Partial<Place>) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() },
}
const tourDetailProps = { ...selectedPlaceProps, ...tourDetailActions }
// The trip is a family of help screens: the frame, then one per tab, and on
// the plan one per overlay that is open. A screen that has no help yet falls
@@ -10,6 +10,7 @@ import { restoreVisit, visitOf } from './placeUndo'
import type { PlannerBase, PlannerHistory } from './plannerTypes'
import type { PlaceEdits } from './usePlaceEdits'
import type { RoadtripFeed } from './useRoadtripFeed'
import { isTourOnDay } from '../../components/Tours/useTourDayAssignment'
interface DayPlanEditsOptions
extends Pick<PlannerBase, 'tripId' | 'tripActions' | 'toast' | 't'>,
@@ -46,7 +47,7 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
// A tour is on a day at most once; a drop onto such a day still gets here, and says why nothing happens.
if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(a => a.place_id === placeId)) { toast.info(t('tours.alreadyOnDay')); return false }
if (isTourPlace(placeId) && isTourOnDay(storedAssignments, placeId, target)) { toast.info(t('tours.alreadyOnDay')); 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.
@@ -7,6 +7,8 @@
import { act, renderHook } from '@testing-library/react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
@@ -20,6 +22,9 @@ interface Props {
tripAccommodations: Accommodation[]
reservations: Reservation[]
toursEnabled: boolean
tripId: number
tourPlaceIds: ReadonlySet<number>
isMobile: boolean
}
// Four places: one on day 1, one on day 2 and two in the pool, one of which has no
@@ -47,6 +52,9 @@ function renderMapPlaces(over: Partial<Props> = {}) {
tripAccommodations: [],
reservations: [],
toursEnabled: false,
tripId: 7,
tourPlaceIds: new Set<number>(),
isMobile: false,
...over,
}
return renderHook((props: Props) => useMapPlaces(props), { initialProps })
@@ -101,7 +109,7 @@ describe('useMapPlaces', () => {
act(() => { useTripStore.setState({ placesFilter: 'unplanned' }) })
rerender({
places: [museum, harbour, bakery, nowhere], assignments, selectedDayId: 1, days,
tripAccommodations: [], reservations: [], toursEnabled: false,
tripAccommodations: [], reservations: [], toursEnabled: false, tripId: 7, tourPlaceIds: new Set<number>(), isMobile: false,
})
expect(ids(result.current.mapPlaces)).toEqual([3])
})
@@ -180,4 +188,16 @@ describe('useMapPlaces', () => {
expect(renderMapPlaces({ assignments: day, selectedDayId: 9 }).result.current.dayPlaces).toEqual([])
expect(renderMapPlaces({ assignments: day, selectedDayId: 3 }).result.current.dayPlaces).toEqual([bakery])
})
it('FE-TP-MAPPLACES-011: PR1-07 a tour the person hid stays off the desktop map, the phone map and a disabled addon keep it', () => {
seedStore(useAuthStore, { user: { id: 3 } as never })
useTourMapVisibilityStore.setState({ scope: tourVisibilityScope(3, 7), hidden: [3, 99] })
const tours = new Set([3])
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours }).result.current.mapPlaces)).toEqual([1, 2])
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours, isMobile: true }).result.current.mapPlaces)).toEqual([1, 2, 3])
expect(ids(renderMapPlaces({ toursEnabled: false, tourPlaceIds: tours }).result.current.mapPlaces)).toEqual([1, 2, 3])
// Another trip of the same person is not this choice.
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours, tripId: 8 }).result.current.mapPlaces)).toEqual([1, 2, 3])
})
})
+11 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { useTourMapVisibility } from '../../components/Tours/useTourMapVisibility'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import type { Accommodation, Place } from '../../types'
@@ -15,6 +16,11 @@ interface MapPlacesOptions {
tripAccommodations: Accommodation[]
reservations: TripStoreState['reservations']
toursEnabled: boolean
tripId: number
/** The trip's tours, to read the person's hidden ones against. */
tourPlaceIds: ReadonlySet<number>
/** The phone map keeps every tour: its list has no switch to show one again. */
isMobile: boolean
}
/**
@@ -25,6 +31,9 @@ interface MapPlacesOptions {
*/
export function useMapPlaces(options: MapPlacesOptions) {
const { places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled } = options
// PR1-07: the tours this person hid from the map stay off it, line and marker alike.
const { hiddenIds: hiddenTourIds } = useTourMapVisibility(options.tripId, options.tourPlaceIds)
const hideTours = toursEnabled && !options.isMobile && hiddenTourIds.size > 0
// Same filter the places sidebar renders, shared via the store so tab
// switches can't desync the marker set from the filter UI (#1541).
const placesFilter = useTripStore((s) => s.placesFilter)
@@ -71,6 +80,7 @@ export function useMapPlaces(options: MapPlacesOptions) {
const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (hideTours && hiddenTourIds.has(p.id)) return false
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
// there the user asked to see the whole plan on the map, so a collapsed day
// must not drop its planned places.
@@ -83,7 +93,7 @@ export function useMapPlaces(options: MapPlacesOptions) {
const pending = pendingIds.has(p.id)
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
})
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned, hideTours, hiddenTourIds])
// The "Tracks" pool is only offered while a place carries a track. When the last
// one goes (deleted here or by a collaborator), fall back to "all". That happens here
@@ -7,6 +7,7 @@ import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
import { useAddonStore } from '../../store/addonStore';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import type { useTripPlanner } from './useTripPlanner';
/**
@@ -116,5 +117,28 @@ export function useTourWorkspace(
},
});
return { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack };
// What the tour's detail does beyond a place's: a rename reloads the tours, a reimport from its
// source check takes the new snapshot in and opens it.
const tourDetailActions = {
onUpdatePlace: async (placeId: number, data: Partial<Place>) => {
await tripActions.updatePlace(tripId, placeId, data);
await reloadTourPlaceIds();
},
onSourceReimported: async (tour: TourListItem) => {
upsertTour(tour);
await useTripStore.getState().refreshPlaces(tripId);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
planner.handlePlaceClick(tour.place_id);
},
};
return {
tourPlanner,
viewImportedTour,
tourGpxImport,
wandererBrowser,
tourConversion,
recordedTrack,
tourDetailActions,
};
}
@@ -414,7 +414,7 @@ export function useTripPlanner() {
useTripWebSocket(tripId, invalidateTourPlaceIds)
const { expandedDayIds, setExpandedDayIds, mapPlaces, dayOrderMap, dayPlaces } = useMapPlaces({
places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled,
places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled, tripId, tourPlaceIds, isMobile,
})
// Fed whenever the addon is on, not only while the mode is being looked at.
@@ -22,7 +22,7 @@ export function useTripPlannerPage() {
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack } =
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, tourDetailActions } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
@@ -119,6 +119,7 @@ export function useTripPlannerPage() {
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourConversion,
recordedTrack,
tourDetailActions,
tourMap: { route,
routeWalking: route && route.length === 2 && tourConversion.line ? [true, false] : null,
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
+8 -3
View File
@@ -8,7 +8,7 @@ import { onlineThenCache } from './withOfflineFallback'
import type { Place } from '../types'
/** The list item a cached row stands for: the cache-only fields stripped. */
function toListItem({ trip_id: _tripId, waypoints: _waypoints, ...tour }: CachedTour): TourListItem {
function toListItem({ trip_id: _tripId, waypoints: _waypoints, provenance: _provenance, ...tour }: CachedTour): TourListItem {
return tour
}
@@ -99,7 +99,12 @@ export const tourRepo = {
return onlineThenCache(
async () => {
const result = await toursApi.detail(tripId, placeId, signal)
await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints })
await offlineDb.tours.put({
...result.tour,
trip_id: Number(tripId),
waypoints: result.waypoints,
...(result.provenance ? { provenance: result.provenance } : {}),
})
return result
},
async () => {
@@ -110,7 +115,7 @@ export const tourRepo = {
const waypoints = cached.waypoints?.length
? cached.waypoints
: endpointsOf((await offlineDb.places.get(placeId))?.route_geometry)
return { tour: toListItem(cached), waypoints }
return { tour: toListItem(cached), waypoints, ...(cached.provenance ? { provenance: cached.provenance } : {}) }
},
)
},
+5
View File
@@ -25,6 +25,11 @@ export const wandererRepo = {
return await wandererApi.preview(tripId, trailId, signal);
},
async checkSource(tripId: number, placeId: number) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
return await wandererApi.checkSource(tripId, placeId);
},
async importTrail(tripId: number, body: WandererImportRequest) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
const result = await wandererApi.importTrail(tripId, body);
@@ -0,0 +1,67 @@
import { create } from 'zustand';
const STORAGE_PREFIX = 'trek:hidden-tours:';
/** The scope one person's choice belongs to: this user in this trip. */
export function tourVisibilityScope(userId: number, tripId: number): string {
return `${userId}:${tripId}`;
}
function readHidden(scope: string): number[] {
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_PREFIX + scope) ?? '[]');
return Array.isArray(parsed) ? parsed.filter((id): id is number => Number.isSafeInteger(id) && id > 0) : [];
} catch {
// Storage blocked or the entry unreadable: every tour shows.
return [];
}
}
function writeHidden(scope: string, hidden: readonly number[]): void {
try {
if (hidden.length) window.localStorage.setItem(STORAGE_PREFIX + scope, JSON.stringify(hidden));
else window.localStorage.removeItem(STORAGE_PREFIX + scope);
} catch {
// Storage blocked: the choice still holds while the page is up.
}
}
interface TourMapVisibilityState {
/** Whose choice is loaded, `user:trip`; null before a planner opened. */
scope: string | null;
/** The tours this person hid from the map of this trip. */
hidden: readonly number[];
load: (scope: string) => void;
toggle: (placeId: number) => void;
showAll: () => void;
}
/**
* PR1-07: which tours one person hides from the map of one trip. A view
* setting of that person on this device, like which bookings' routes the map
* draws: it never changes the tour, its days or its colour, and nobody else
* sees it. Kept per user and trip in the browser's storage, so a reload keeps
* it. Ids of tours that are gone are dropped where the list is read, so they
* cannot hide anything later.
*/
export const useTourMapVisibilityStore = create<TourMapVisibilityState>()((set, get) => ({
scope: null,
hidden: [],
load: (scope) => {
if (get().scope === scope) return;
set({ scope, hidden: readHidden(scope) });
},
toggle: (placeId) => {
const { scope, hidden } = get();
if (!scope) return;
const next = hidden.includes(placeId) ? hidden.filter((id) => id !== placeId) : [...hidden, placeId];
writeHidden(scope, next);
set({ hidden: next });
},
showAll: () => {
const { scope } = get();
if (!scope) return;
writeHidden(scope, []);
set({ hidden: [] });
},
}));
+1 -1
View File
@@ -16,6 +16,6 @@
"src/nest/plugins/plugin-runtime.service.ts": 1463,
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1007,
"src/nest/trips/trips.service.ts": 1006,
"src/nest/vacay/vacay.service.ts": 1420
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { Collection, EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { ToursRepository } from '../repositories/Tours.repository';
import { Places } from './Places.entity';
import { TourSources } from './TourSources.entity';
@@ -25,11 +25,21 @@ export class Tours {
derivedFromPlace?: Ref<Places> | null;
derived_from_place_id?: number | null;
recorded_route_geometry?: string | null;
origin: TToursOrigin & Opt = ToursOrigin.TREK;
origin_file_name?: string | null;
tourTypeRef!: Ref<TourTypes>;
tour_sources: Ref<TourSources> | null = null;
tour_waypoints_collection = new Collection<TourWaypoints>(this);
}
export const ToursOrigin = {
TREK: 'trek',
GPX: 'gpx',
WANDERER: 'wanderer',
} as const;
export type TToursOrigin = (typeof ToursOrigin)[keyof typeof ToursOrigin];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -58,6 +68,8 @@ export const ToursSchema = defineEntity({
derivedFromPlace: () => p.manyToOne(Places).ref().nullable().hidden(),
derived_from_place_id: p.integer().nullable().persist(false),
recorded_route_geometry: p.text().nullable(),
origin: p.enum(() => ToursOrigin).default('trek'),
origin_file_name: p.text().nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
+2
View File
@@ -18,4 +18,6 @@ export interface ToursTable {
break_additional_minutes: InsertOptional<number | null>;
derived_from_place_id: InsertOptional<number | null>;
recorded_route_geometry: InsertOptional<string | null>;
origin: InsertOptional<string>;
origin_file_name: InsertOptional<string | null>;
}
@@ -0,0 +1,27 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Where a tour came from, recorded instead of read off its shape: planned in
* TREK, imported from a GPX file (with the file's name) or copied in from
* wanderer. Until now a list told a GPX import from a planned tour by whether it
* had waypoints, which an import planned over in place no longer holds.
*
* The backfill is exact for every tour that can exist before this step: a
* wanderer reference only comes from the wanderer import, and a tour without
* stored waypoints, or one that keeps a recorded track, only from an import.
* A GPX import's file name was never stored, so it stays unknown (NULL).
*/
export class Migration20200101043800_a_tour_records_where_it_came_from extends Migration {
override name = 'Migration20200101043800_a_tour_records_where_it_came_from';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN origin TEXT NOT NULL DEFAULT 'trek' CHECK (origin IN ('trek', 'gpx', 'wanderer'))",
);
this.addSql('ALTER TABLE tours ADD COLUMN origin_file_name TEXT');
this.addSql("UPDATE tours SET origin = 'wanderer' WHERE wanderer_ref IS NOT NULL");
this.addSql(
"UPDATE tours SET origin = 'gpx' WHERE origin = 'trek' AND (recorded_route_geometry IS NOT NULL OR NOT EXISTS (SELECT 1 FROM tour_waypoints w WHERE w.place_id = tours.place_id))",
);
}
}
+110 -2
View File
@@ -1,4 +1,4 @@
import type { Tours } from '../entities/Tours.entity';
import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.entity';
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -26,6 +26,7 @@ export interface TourListRow {
match_confidence: number | null;
max_hiking_difficulty: number | null;
derived_from_place_id: number | null;
origin: string;
has_recorded_track: number;
planned: number;
has_waypoints: number;
@@ -48,6 +49,8 @@ export interface TourRow {
max_hiking_difficulty: number | null;
derived_from_place_id: number | null;
recorded_route_geometry: string | null;
origin: string;
origin_file_name: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -71,7 +74,32 @@ export type TourUpdate = Omit<TourInsert, 'place_id' | 'planned_duration_minutes
break_additional_minutes?: number | null;
};
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints'>;
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources'>;
/**
* Where one tour came from, as the provenance contract reads it: the facet's
* own record (origin, the GPX file name, when it was saved, the wanderer
* reference), the import's snapshot row when there is one, and the name of the
* import a planned tour came from while that import is still in the trip.
*/
export interface TourProvenanceRow {
origin: string;
origin_file_name: string | null;
created_at: string | null;
wanderer_ref: string | null;
has_recorded_track: number;
derived_from_place_id: number | null;
derived_from_name: string | null;
source_instance: string | null;
source_external_id: string | null;
source_external_url: string | null;
source_external_updated: string | null;
source_imported_at: string | null;
source_content_hash: string | null;
source_local_hash: string | null;
route_geometry: string | null;
name: string;
}
/**
* `tours` — the facet that makes a place a tour (#2586). Keyed on the owning
@@ -110,6 +138,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.match_confidence as match_confidence',
't.max_hiking_difficulty as max_hiking_difficulty',
't.derived_from_place_id as derived_from_place_id',
't.origin as origin',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
eb
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
@@ -141,6 +170,79 @@ export class ToursRepository extends TrekRepository<Tours> {
return row ? toListRow(row) : undefined;
}
/** Where a tour of this trip came from; `undefined` unless the place is a tour of that trip. */
async findProvenance(trip_id: number, place_id: number): Promise<TourProvenanceRow | undefined> {
return await this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.leftJoin('tour_sources as s', 's.place_id', 't.place_id')
.leftJoin('places as d', (join) =>
join.onRef('d.id', '=', 't.derived_from_place_id').on('d.trip_id', '=', trip_id),
)
.select((eb) => [
't.origin as origin',
't.origin_file_name as origin_file_name',
't.created_at as created_at',
't.wanderer_ref as wanderer_ref',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
't.derived_from_place_id as derived_from_place_id',
'd.name as derived_from_name',
's.instance as source_instance',
's.external_id as source_external_id',
's.external_url as source_external_url',
's.external_updated as source_external_updated',
's.imported_at as source_imported_at',
's.content_hash as source_content_hash',
's.local_hash as source_local_hash',
'p.route_geometry as route_geometry',
'p.name as name',
])
.where('p.trip_id', '=', trip_id)
.where('p.id', '=', place_id)
.executeTakeFirst()
.then((row) => (row ? { ...row, has_recorded_track: Number(row.has_recorded_track) } : undefined));
}
/**
* A copied tour keeps the import record of the tour it was copied from, so a
* trip copy still says where its imports came from and when. Nothing happens
* for a tour without one.
*/
async copySource(source_place_id: number, place_id: number): Promise<void> {
await this.db()
.insertInto('tour_sources')
.columns([
'place_id',
'provider',
'instance',
'external_id',
'external_url',
'external_updated',
'content_hash',
'local_hash',
'imported_by',
'imported_at',
])
.expression((eb) =>
eb
.selectFrom('tour_sources')
.select((inner) => [
inner.val(place_id).as('place_id'),
'provider',
'instance',
'external_id',
'external_url',
'external_updated',
'content_hash',
'local_hash',
'imported_by',
'imported_at',
])
.where('place_id', '=', source_place_id),
)
.execute();
}
/** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */
async existsForPlace(place_id: number): Promise<boolean> {
const row = await this.db()
@@ -193,15 +295,21 @@ export class ToursRepository extends TrekRepository<Tours> {
/**
* `wanderer_ref` and `difficulty` are set only by an import from a source that
* carries them, `derived_from_place_id` only by a tour planned from an import.
* `origin` says which way the tour came in (planned here unless an import
* says otherwise), `origin_file_name` the GPX file it was read from.
*/
async insertTour(
input: TourInsert & {
wanderer_ref?: string | null;
difficulty?: string | null;
derived_from_place_id?: number | null;
origin?: TToursOrigin;
origin_file_name?: string | null;
},
): Promise<void> {
await this.insert({
origin: input.origin ?? ToursOrigin.TREK,
origin_file_name: input.origin_file_name ?? null,
derivedFromPlace: input.derived_from_place_id ?? null,
wanderer_ref: input.wanderer_ref ?? null,
difficulty: input.difficulty ?? null,
@@ -10,7 +10,12 @@ import { WandererEnabledGuard } from '../wanderer/wanderer-enabled.guard';
import { WandererImportDto, WandererSearchDto } from './dto/tours-wanderer.dto';
import { ToursWandererService } from './tours-wanderer.service';
import { Body, Controller, Get, Headers, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
import { wandererImportPreviewSchema, wandererImportResponseSchema, wandererSearchResponseSchema } from '@trek/shared';
import {
tourSourceCheckResponseSchema,
wandererImportPreviewSchema,
wandererImportResponseSchema,
wandererSearchResponseSchema,
} from '@trek/shared';
/**
* `/api/trips/:tripId/tours/wanderer`: the caller's wanderer trails, seen from
@@ -40,6 +45,15 @@ export class ToursWandererController {
return this.wanderer.preview(tripId, user.id, trailId);
}
/** MF-03: whether the trail a wanderer snapshot came from changed since; reads, writes nothing. */
@RequirePermission('place_edit')
@Post('sources/:placeId/check')
@HttpCode(200)
@ResponseContract(tourSourceCheckResponseSchema)
checkSource(@Param('tripId') tripId: string, @Param('placeId') placeId: string, @CurrentUser() user: User) {
return this.wanderer.checkSource(tripId, user.id, placeId);
}
@RequirePermission('place_edit')
@Post('import')
@ResponseContract(wandererImportResponseSchema)
+66 -36
View File
@@ -3,7 +3,7 @@ import { TourSources } from '../../db/entities/TourSources.entity';
import { Tours } from '../../db/entities/Tours.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import type { TourSnapshotRow, TourSourcesRepository } from '../../db/repositories/TourSources.repository';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import type { TourProvenanceRow, ToursRepository } from '../../db/repositories/Tours.repository';
import { thinLine } from '../../utils/polyline';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
@@ -11,7 +11,7 @@ import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { WandererService } from '../wanderer/wanderer.service';
import { computeTourMetrics, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
import { changedParts, computeTourMetrics, contentHashOf, localHashOf, parseRouteGeometry } from './tours.helpers';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, Logger } from '@nestjs/common';
@@ -19,50 +19,27 @@ import type {
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
TourSourceCheckResponse,
WandererSearchRequest,
WandererSearchResponse,
} from '@trek/shared';
import { wandererDifficultyToSac } from '@trek/shared';
import { createHash } from 'node:crypto';
const PROVIDER = 'wanderer';
/** The line the preview draws: the whole tour, thinned for the map. */
const PREVIEW_LINE_POINTS = 800;
/** A short stable fingerprint of one part of a trail. */
function fingerprint(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
}
/** Coordinates to five places (a metre) and heights to the metre, so a re-export with other float noise reads the same. */
function normalizedRoute(points: readonly GeometryPoint[]): number[][] {
return points.map(([lat, lng, ele]) =>
ele == null ? [+lat.toFixed(5), +lng.toFixed(5)] : [+lat.toFixed(5), +lng.toFixed(5), Math.round(ele)],
);
}
/** What the source delivered, part by part: `name.description.route`. */
function contentHashOf(name: string, description: string | null, route: readonly GeometryPoint[]): string {
return [fingerprint(name.trim()), fingerprint((description ?? '').trim()), fingerprint(normalizedRoute(route))].join(
'.',
);
}
/** The tour as saved here: `name.route`, to tell later whether it was edited. */
function localHashOf(name: string, route: readonly GeometryPoint[]): string {
return [fingerprint(name.trim()), fingerprint(normalizedRoute(route))].join('.');
}
const CONTENT_PARTS = ['name', 'description', 'route'] as const;
function changedParts(before: string, now: string): Array<(typeof CONTENT_PARTS)[number]> {
const a = before.split('.');
const b = now.split('.');
return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]);
}
/** The trip id the guard already resolved; a malformed one never got this far, and still answers 404. */
/** The instance and trail a snapshot came from: its import record, else the reference the tour carries. */
function sourceIdentity(row: TourProvenanceRow): { instance: string; externalId: string } {
if (row.source_instance !== null && row.source_external_id !== null) {
return { instance: row.source_instance, externalId: row.source_external_id };
}
const ref = row.wanderer_ref ?? '';
const at = ref.lastIndexOf('#');
return { instance: ref.slice(0, Math.max(at, 0)), externalId: ref.slice(at + 1) };
}
function tripRowId(tripId: string): number {
const tid = toRowId(tripId);
if (tid === null) throw new DomainError(404, 'Trip not found');
@@ -155,6 +132,58 @@ export class ToursWandererService {
return await this.sources.listSnapshots(tid, PROVIDER, instance, [trailId]);
}
/**
* MF-03: ask the source of one wanderer snapshot whether the trail changed
* since it came in, on the caller's own connection. Reads only: the
* snapshot stays as it is whatever the source says, and taking a change
* over is the confirmed reimport, with the hash this answer carries.
*/
async checkSource(tripId: string, userId: number, placeId: string): Promise<TourSourceCheckResponse> {
const tid = tripRowId(tripId);
const pid = toRowId(placeId);
const row = pid === null ? undefined : await this.toursRepo.findProvenance(tid, pid);
if (!row) throw new DomainError(404, 'Tour not found');
if (row.origin !== 'wanderer') {
throw new DomainError(400, 'This tour has no wanderer source', {
code: 'wanderer_no_source',
details: { code: 'no_source' },
});
}
const { instance, externalId } = sourceIdentity(row);
const editedLocally =
row.source_local_hash === null
? null
: localHashOf(row.name, parseRouteGeometry(row.route_geometry)) !== row.source_local_hash;
const answer = (state: TourSourceCheckResponse['state']): TourSourceCheckResponse => ({
state,
changes: [],
externalUpdated: null,
importedUpdated: row.source_external_updated,
editedLocally,
reimport: null,
});
const connected = await this.wanderer.getConnection(userId);
if (!connected.connected) return answer('not_connected');
if ((await this.wanderer.instanceOf(userId)) !== instance) return answer('other_instance');
let fetched: Awaited<ReturnType<ToursWandererService['fetchTrail']>>;
try {
fetched = await this.fetchTrail(userId, externalId);
} catch (err: unknown) {
// Gone, or no longer the caller's to read: said as such, the snapshot is not touched.
if (err instanceof DomainError && (err.getStatus() === 404 || err.getStatus() === 403))
return answer('unavailable');
throw err;
}
const reimport = { trailId: externalId, contentHash: fetched.contentHash };
const externalUpdated = fetched.trail.updated ?? null;
if (row.source_content_hash === null) return { ...answer('unknown'), externalUpdated, reimport };
const changes = changedParts(row.source_content_hash, fetched.contentHash);
return changes.length === 0
? { ...answer('unchanged'), externalUpdated }
: { ...answer('changed'), changes, externalUpdated, reimport };
}
async preview(tripId: string, userId: number, trailId: string): Promise<WandererImportPreview> {
const tid = tripRowId(tripId);
const fetched = await this.fetchTrail(userId, trailId);
@@ -242,6 +271,7 @@ export class ToursWandererService {
max_hiking_difficulty: wandererDifficultyToSac(fetched.summary.difficulty),
wanderer_ref: `${fetched.instance}#${trailId}`,
difficulty: fetched.summary.difficulty,
origin: 'wanderer',
});
await this.sources.insertSource({
place_id: id,
+34
View File
@@ -5,6 +5,8 @@
*/
import type { GeometryPoint } from '@trek/shared';
import { createHash } from 'node:crypto';
// The metrics are shared with the client, which computes them for a tour saved offline.
export { computeTourMetrics, type GeometryPoint, type TourMetrics } from '@trek/shared';
@@ -30,3 +32,35 @@ export function parseRouteGeometry(routeGeometry: string | null | undefined): Ge
/** Confidence threshold: below this, the tour imports with a caution flag. */
export const LOW_CONFIDENCE_THRESHOLD = 0.5;
/** A short stable fingerprint of one part of a trail. */
function fingerprint(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
}
/** Coordinates to five places (a metre) and heights to the metre, so a re-export with other float noise reads the same. */
function normalizedRoute(points: readonly GeometryPoint[]): number[][] {
return points.map(([lat, lng, ele]) =>
ele == null ? [+lat.toFixed(5), +lng.toFixed(5)] : [+lat.toFixed(5), +lng.toFixed(5), Math.round(ele)],
);
}
/** What the source delivered, part by part: `name.description.route`. */
export function contentHashOf(name: string, description: string | null, route: readonly GeometryPoint[]): string {
return [fingerprint(name.trim()), fingerprint((description ?? '').trim()), fingerprint(normalizedRoute(route))].join(
'.',
);
}
/** The tour as saved here: `name.route`, to tell later whether it was edited. */
export function localHashOf(name: string, route: readonly GeometryPoint[]): string {
return [fingerprint(name.trim()), fingerprint(normalizedRoute(route))].join('.');
}
const CONTENT_PARTS = ['name', 'description', 'route'] as const;
export function changedParts(before: string, now: string): Array<(typeof CONTENT_PARTS)[number]> {
const a = before.split('.');
const b = now.split('.');
return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]);
}
+80 -7
View File
@@ -5,13 +5,24 @@ import { Tours } from '../../db/entities/Tours.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
import type { TourListRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository';
import type {
TourListRow,
TourProvenanceRow,
ToursRepository,
TourUpdate,
} from '../../db/repositories/Tours.repository';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers';
import {
computeTourMetrics,
localHashOf,
parseRouteGeometry,
LOW_CONFIDENCE_THRESHOLD,
type GeometryPoint,
} from './tours.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
@@ -22,6 +33,9 @@ import {
type TourCreateResponse,
type TourDetailResponse,
type TourListItem,
type TourOrigin,
type TourProvenance,
type TourProvenanceSource,
type TourWaypoint,
type WandererDifficulty,
wandererDifficultyToSac,
@@ -72,6 +86,7 @@ export class ToursService {
match_confidence: r.match_confidence,
max_hiking_difficulty: r.max_hiking_difficulty,
derived_from_place_id: r.derived_from_place_id,
origin: r.origin as TourOrigin,
has_recorded_track: Boolean(r.has_recorded_track),
planned: Boolean(r.planned),
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
@@ -168,7 +183,33 @@ export class ToursService {
}
}
return { tour: this.toItem(row), waypoints };
return { tour: this.toItem(row), waypoints, provenance: await this.provenanceOf(tid!, row.place_id) };
}
/**
* Where a tour came from (P2C-02): the origin it was saved with, what its
* source carried, the import it was planned from, and whether it still
* matches the import. Nothing here is guessed: a field the tour never
* recorded stays null.
*/
private async provenanceOf(tripId: number, placeId: number): Promise<TourProvenance> {
const row = await this.toursRepo.findProvenance(tripId, placeId);
if (!row) throw new NotFoundException('Tour not found');
const origin = row.origin as TourOrigin;
return {
origin,
createdAt: row.created_at,
source: provenanceSource(origin, row),
derivedFrom:
row.derived_from_place_id !== null && row.derived_from_name !== null
? { placeId: row.derived_from_place_id, name: row.derived_from_name }
: null,
plannedOver: row.has_recorded_track === 1,
editedLocally:
origin === 'wanderer' && row.source_local_hash !== null
? localHashOf(row.name, parseRouteGeometry(row.route_geometry)) !== row.source_local_hash
: null,
};
}
/** Create the owning Place, Tours facet, and ordered control points as one write. */
@@ -187,7 +228,7 @@ export class ToursService {
// read in the write so it cannot go meanwhile.
if (derivedFrom !== null) {
const source = await this.toursRepo.findInTrip(tid, derivedFrom);
if (!source || source.has_waypoints) {
if (!source || source.origin === 'trek' || source.has_waypoints) {
throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip');
}
}
@@ -224,7 +265,7 @@ export class ToursService {
} catch {
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
}
return { tour: this.toItem(row), waypoints };
return { tour: this.toItem(row), waypoints, provenance: await this.provenanceOf(tid, placeId) };
}
/**
@@ -360,14 +401,14 @@ export class ToursService {
async importGpxAsTour(
tripId: string,
fileBuffer: Buffer,
defaultName?: string,
fileName?: string,
socketId?: string,
): Promise<ImportGpxAsTourResult | null> {
const rows = this.placeImport.readGpx(fileBuffer, {
importWaypoints: false,
importRoutes: true,
importTracks: true,
defaultName,
defaultName: fileName,
});
if (rows.length === 0) return null;
@@ -389,6 +430,8 @@ export class ToursService {
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: gpxFileName(fileName),
});
tours.push(
this.toItem({
@@ -408,6 +451,7 @@ export class ToursService {
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
@@ -433,3 +477,32 @@ export class ToursService {
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
}
}
/** The file name a GPX import keeps: the name as uploaded, without a path, cut to what the column shows. */
function gpxFileName(name: string | undefined): string | null {
const base = name?.split(/[\/]/).pop()?.trim();
return base ? base.slice(0, 255) : null;
}
/** What the source of a tour carried, read from the tour's own record and its import's snapshot row. */
function provenanceSource(origin: TourOrigin, row: TourProvenanceRow): TourProvenanceSource {
if (origin === 'gpx') return { kind: 'gpx', fileName: row.origin_file_name };
if (origin === 'wanderer') {
// The reference `instance#trail` is on the tour itself, so a snapshot whose row is missing still names its source.
const [refInstance, refTrail] = splitWandererRef(row.wanderer_ref);
return {
kind: 'wanderer',
instance: row.source_instance ?? refInstance,
externalId: row.source_external_id ?? refTrail,
externalUrl: row.source_external_url,
externalUpdated: row.source_external_updated,
importedAt: row.source_imported_at,
};
}
return { kind: 'trek' };
}
function splitWandererRef(ref: string | null): [string, string] {
const at = ref?.lastIndexOf('#') ?? -1;
return ref && at > 0 ? [ref.slice(0, at), ref.slice(at + 1)] : ['', ''];
}
+4 -5
View File
@@ -733,17 +733,16 @@ export class TripsService {
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
}
// A Tour is a place plus its `tours` facet and the waypoints its route was
// planned through. Without them the copy holds an ordinary place with a
// line on it: no longer listed as a Tour, and its route can't be edited
// because the points it was drawn from are gone. The facet row goes first,
// since the waypoints reference it.
// A Tour is a place plus its `tours` facet, its import record and the waypoints its route was planned
// through; without them the copy is a plain place with a line, no Tour, its route not editable.
// The facet row goes first, since the waypoints and the import record reference it.
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
const copiedTours = new Set<number>();
for (const { place_id, ...tour } of oldTours) {
const newPlaceId = placeMap.get(place_id);
if (!newPlaceId) continue;
await this.toursRepo.insertCopy(newPlaceId, tour, placeMap);
await this.toursRepo.copySource(place_id, newPlaceId);
copiedTours.add(place_id);
}
+46
View File
@@ -502,6 +502,52 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
});
});
describe('provenance (P2C-02)', () => {
const detail = async (placeId: number) =>
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
it('a planned tour says it was planned here, with no source invented', async () => {
const placeId = await createViaApi();
const body = await detail(placeId);
expect(body.tour.origin).toBe('trek');
expect(body.provenance).toMatchObject({
origin: 'trek',
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
});
expect(body.provenance.createdAt).toEqual(expect.any(String));
});
it('a GPX import keeps its origin and file name, also after it is planned over in place', async () => {
const res = await importGpx(OWNER, TRACK_GPX);
const placeId = res.body.tours[0].place_id as number;
expect(res.body.tours[0].origin).toBe('gpx');
expect((await detail(placeId)).provenance).toMatchObject({
origin: 'gpx',
source: { kind: 'gpx', fileName: 'track.gpx' },
plannedOver: false,
});
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)).send(tourBody());
const after = await detail(placeId);
expect(after.tour).toMatchObject({ origin: 'gpx', has_waypoints: true });
expect(after.provenance).toMatchObject({ origin: 'gpx', plannedOver: true });
});
it('a tour planned from an import names it while it is in the trip', async () => {
const res = await importGpx(OWNER, TRACK_GPX);
const source = res.body.tours[0].place_id as number;
const derived = await createViaApi(5, tourBody({ derived_from_place_id: source }));
const sourceName = (await placeColumns(source, 'name')).name;
expect((await detail(derived)).provenance).toMatchObject({
origin: 'trek',
derivedFrom: { placeId: source, name: sourceName },
});
});
});
describe('planning over an import (PR1-05)', () => {
/** An imported GPX tour on trip 5: no waypoints, the recorded line as it came. */
const importedTour = async (tripId = 5) => {
+112
View File
@@ -400,4 +400,116 @@ describe('wanderer e2e', () => {
expect(client.gpx).not.toHaveBeenCalled();
});
});
describe('checking the source of a snapshot (MF-03)', () => {
const check = (placeId: number, userId = OWNER) =>
request(server).post(`/api/trips/5/tours/wanderer/sources/${placeId}/check`).set(as(userId));
const imported = async () => {
await connect();
const res = await importTrail((await preview()).body.contentHash);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
it('an unchanged trail says so and offers nothing to take in; the check writes nothing', async () => {
const placeId = await imported();
const before = await countRows(orm, Places);
const res = await check(placeId);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
state: 'unchanged',
changes: [],
externalUpdated: RECORD.updated,
importedUpdated: RECORD.updated,
editedLocally: false,
reimport: null,
});
expect(await countRows(orm, Places)).toBe(before);
});
it('a changed trail names what changed, and its hash takes it in as a new snapshot beside the old one', async () => {
const placeId = await imported();
client.trail.mockResolvedValue({ ...RECORD, name: 'Partnachklamm (new route)', updated: '2026-10-14' });
const res = await check(placeId);
expect(res.body).toMatchObject({
state: 'changed',
changes: ['name'],
externalUpdated: '2026-10-14',
importedUpdated: '2026-10-01',
reimport: { trailId: HIT.id },
});
const again = await importTrail(res.body.reimport.contentHash);
expect(again.status).toBe(201);
expect(again.body.tour.place_id).not.toBe(placeId);
expect(await countRows(orm, Places)).toBe(2);
});
it('a trail that is gone or no longer the caller own leaves the snapshot alone', async () => {
const placeId = await imported();
client.searchById.mockResolvedValue(null);
expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: null });
expect(await countRows(orm, Places)).toBe(1);
});
it('without a connection, or one to another instance, there is nothing to ask', async () => {
const placeId = await imported();
await request(server)
.put('/api/integrations/wanderer/settings')
.set(as(OWNER))
.send({ url: 'https://other.example.org/', token: TOKEN });
expect((await check(placeId)).body).toMatchObject({ state: 'other_instance' });
await request(server).delete('/api/integrations/wanderer/settings').set(as(OWNER));
expect((await check(placeId)).body).toMatchObject({ state: 'not_connected' });
});
it('a snapshot without its import record cannot be compared, the current trail can still come in', async () => {
const placeId = await imported();
await deleteRows(orm, TourSources, { place: placeId });
expect((await check(placeId)).body).toMatchObject({
state: 'unknown',
editedLocally: null,
reimport: { trailId: HIT.id },
});
});
it('only a wanderer snapshot of the trip is checked, and only by who may add places', async () => {
const placeId = await imported();
expect((await check(placeId, VIEWER)).status).toBe(403);
expect((await check(999_999)).status).toBe(404);
const planned = await request(server)
.post('/api/trips/5/tours')
.set(as(OWNER))
.send({
name: 'Drawn',
tour_type: 'hike',
route_geometry: [
[47.45, 11.1, 700],
[47.46, 11.11, 800],
],
waypoints: [
{ lat: 47.45, lng: 11.1, role: 'start', sequence: 0 },
{ lat: 47.46, lng: 11.11, role: 'end', sequence: 1 },
],
});
const res = await check(planned.body.tour.place_id as number);
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ code: 'no_source' });
});
it('a wanderer import says where it came from in its detail, and a trip copy keeps it (P2C-02)', async () => {
const placeId = await imported();
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set(as(OWNER));
expect(detail.body.provenance).toMatchObject({
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://wanderer.example.org',
externalId: HIT.id,
externalUpdated: RECORD.updated,
},
editedLocally: false,
});
expect(detail.body.provenance.source.importedAt).toEqual(expect.any(String));
});
});
});
@@ -20,6 +20,7 @@ describe('known migrations', () => {
expect(known.has('Migration20200101043500_wanderer_difficulty_on_the_sac_scale')).toBe(true);
expect(known.has('Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from')).toBe(true);
expect(known.has('Migration20200101043700_an_import_planned_over_keeps_its_recorded_track')).toBe(true);
expect(known.has('Migration20200101043800_a_tour_records_where_it_came_from')).toBe(true);
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
});
@@ -49,6 +49,7 @@ const UNNUMBERED = [
'Migration20200101043500_wanderer_difficulty_on_the_sac_scale',
'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from',
'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track',
'Migration20200101043800_a_tour_records_where_it_came_from',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -4,6 +4,7 @@
* (TO6/TO8), and the create/edit writes (TO7/TO9).
*/
import { Places } from '../../../../src/db/entities/Places.entity';
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../../src/db/entities/Tours.entity';
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
@@ -90,6 +91,7 @@ describe('ToursRepository reads', () => {
planned: 0,
has_waypoints: 1,
derived_from_place_id: null,
origin: 'trek',
has_recorded_track: 0,
},
{
@@ -111,6 +113,7 @@ describe('ToursRepository reads', () => {
planned: 1,
has_waypoints: 0,
derived_from_place_id: null,
origin: 'trek',
has_recorded_track: 0,
},
]);
@@ -188,6 +191,7 @@ describe('ToursRepository writes', () => {
planned: 0,
has_waypoints: 0,
derived_from_place_id: null,
origin: 'trek',
has_recorded_track: 0,
});
});
@@ -249,6 +253,8 @@ describe('ToursRepository writes', () => {
max_hiking_difficulty: 4,
derived_from_place_id: null,
recorded_route_geometry: null,
origin: 'trek',
origin_file_name: null,
},
]);
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
@@ -277,4 +283,86 @@ describe('ToursRepository writes', () => {
expect(copied.find((r) => r.place_id === copy.id)?.derived_from_place_id).toBe(copiedImport.id);
expect(copied.find((r) => r.place_id === orphan.id)?.derived_from_place_id).toBeNull();
});
it('TOURREPO-010: findProvenance reads origin, file name, import record and the import it was planned from', async () => {
const { trip, other } = fixture();
const imported = createPlace(testDb, trip.id, { name: 'Ridge import' });
await repo.insertTour({ ...METRICS, place_id: imported.id, origin: 'gpx', origin_file_name: 'ridge.gpx' });
const planned = createPlace(testDb, trip.id, { name: 'Ridge planned' });
await repo.insertTour({ ...METRICS, place_id: planned.id, derived_from_place_id: imported.id });
const snapshot = createPlace(testDb, trip.id, { name: 'Gorge' });
await repo.insertTour({
...METRICS,
place_id: snapshot.id,
origin: 'wanderer',
wanderer_ref: 'https://w.example#t1',
});
await t.repo(TourSources).insertSource({
place_id: snapshot.id,
provider: 'wanderer',
instance: 'https://w.example',
external_id: 't1',
external_url: 'https://w.example/trail/view/@me/t1',
external_updated: '2026-10-01 08:00:00Z',
content_hash: 'c.c.c',
local_hash: 'l.l',
imported_by: null,
});
expect(await repo.findProvenance(trip.id, imported.id)).toMatchObject({
origin: 'gpx',
origin_file_name: 'ridge.gpx',
derived_from_place_id: null,
derived_from_name: null,
source_instance: null,
has_recorded_track: 0,
});
expect(await repo.findProvenance(trip.id, planned.id)).toMatchObject({
origin: 'trek',
derived_from_place_id: imported.id,
derived_from_name: 'Ridge import',
});
expect(await repo.findProvenance(trip.id, snapshot.id)).toMatchObject({
origin: 'wanderer',
source_instance: 'https://w.example',
source_external_id: 't1',
source_content_hash: 'c.c.c',
source_local_hash: 'l.l',
name: 'Gorge',
});
expect(await repo.findProvenance(other.id, snapshot.id)).toBeUndefined();
});
it('TOURREPO-011: copySource copies the import record onto the copied tour, and nothing for a tour without one', async () => {
const { trip, other } = fixture();
const snapshot = createPlace(testDb, trip.id);
await repo.insertTour({ ...METRICS, place_id: snapshot.id, origin: 'wanderer' });
await t.repo(TourSources).insertSource({
place_id: snapshot.id,
provider: 'wanderer',
instance: 'https://w.example',
external_id: 't1',
external_url: null,
external_updated: null,
content_hash: 'c.c.c',
local_hash: 'l.l',
imported_by: null,
});
const planned = createPlace(testDb, trip.id);
await repo.insertTour({ ...METRICS, place_id: planned.id });
const copiedSnapshot = createPlace(testDb, other.id);
await repo.insertTour({ ...METRICS, place_id: copiedSnapshot.id, origin: 'wanderer' });
const copiedPlanned = createPlace(testDb, other.id);
await repo.insertTour({ ...METRICS, place_id: copiedPlanned.id });
await repo.copySource(snapshot.id, copiedSnapshot.id);
await repo.copySource(planned.id, copiedPlanned.id);
expect(await repo.findProvenance(other.id, copiedSnapshot.id)).toMatchObject({
source_instance: 'https://w.example',
source_external_id: 't1',
source_content_hash: 'c.c.c',
});
expect((await repo.findProvenance(other.id, copiedPlanned.id))?.source_instance).toBeNull();
});
});
@@ -0,0 +1,90 @@
/**
* `Migration20200101043800_a_tour_records_where_it_came_from`: every tour gets
* the way it came in. A wanderer reference makes it a wanderer snapshot; a
* tour without stored waypoints, or one that keeps a recorded track, is a GPX
* import; everything else was planned in TREK. No GPX file name is invented.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101043800_a_tour_records_where_it_came_from';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function seed(orm: MikroORM): Promise<void> {
// 1 planned in TREK (waypoints), 2 a GPX import (none), 3 a wanderer snapshot,
// 4 a GPX import planned over in place (waypoints and a kept track),
// 5 a wanderer snapshot planned over in place.
await rawExec(orm, `INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')`);
await rawExec(orm, `INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'Alps')`);
await rawExec(
orm,
`INSERT INTO places (id, trip_id, name) VALUES (1, 1, 'Planned'), (2, 1, 'Gpx'), (3, 1, 'Wanderer'), (4, 1, 'Gpx over'), (5, 1, 'Wanderer over')`,
);
await rawExec(
orm,
`INSERT INTO tours (place_id, tour_type, wanderer_ref, recorded_route_geometry) VALUES
(1, 'hike', NULL, NULL),
(2, 'hike', NULL, NULL),
(3, 'hike', 'https://w.example#t1', NULL),
(4, 'hike', NULL, '[[47,11],[47.1,11.1]]'),
(5, 'hike', 'https://w.example#t2', '[[47,11],[47.1,11.1]]')`,
);
await rawExec(
orm,
`INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES
(1, 47, 11, 'start', 0), (1, 47.1, 11.1, 'end', 1),
(4, 47, 11, 'start', 0), (4, 47.1, 11.1, 'end', 1),
(5, 47, 11, 'start', 0), (5, 47.1, 11.1, 'end', 1)`,
);
}
const origins = (orm: MikroORM) =>
rawQuery<{ place_id: number; origin: string; origin_file_name: string | null }>(
orm,
'SELECT place_id, origin, origin_file_name FROM tours ORDER BY place_id',
);
const EXPECTED = [
{ place_id: 1, origin: 'trek', origin_file_name: null },
{ place_id: 2, origin: 'gpx', origin_file_name: null },
{ place_id: 3, origin: 'wanderer', origin_file_name: null },
{ place_id: 4, origin: 'gpx', origin_file_name: null },
{ place_id: 5, origin: 'wanderer', origin_file_name: null },
];
describe('tour origin migration (P2C-02)', () => {
it('TOURORIGIN-MIG-001: each tour gets the way it came in, without a file name it never had', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
expect(await origins(orm)).toEqual(EXPECTED);
} finally {
await orm.close(true);
}
}, 30000);
it('TOURORIGIN-MIG-002: a new tour defaults to planned in TREK, and an unknown origin is refused', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
await rawExec(orm, `INSERT INTO places (id, trip_id, name) VALUES (6, 1, 'Later')`);
await rawExec(orm, `INSERT INTO tours (place_id, tour_type) VALUES (6, 'hike')`);
expect((await origins(orm)).at(-1)).toEqual({ place_id: 6, origin: 'trek', origin_file_name: null });
await expect(rawExec(orm, `UPDATE tours SET origin = 'komoot' WHERE place_id = 6`)).rejects.toThrow();
} finally {
await orm.close(true);
}
}, 30000);
});
+37
View File
@@ -232,5 +232,42 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'يمر المسار {length} عبر «{name}». يتطلب ذلك معدات الطريق الحديدي وخبرة.',
'tours.ferrata.crossingShort': 'يمر المسار عبر طريق حديدي',
'tours.ferrata.waypointAtEnd': 'بداية أو نهاية «{name}»',
'tours.provenance.label': 'المصدر الأصلي',
'tours.provenance.source': 'المصدر',
'tours.provenance.importedAt': 'تم الاستيراد',
'tours.provenance.createdAt': 'تم الإنشاء',
'tours.provenance.externalUpdated': 'تغيّر في wanderer',
'tours.provenance.externalUpdatedHint':
'تاريخ التغيير الذي ذكره wanderer عند الاستيراد. مجرد إشارة وليس دليلًا على نسخة.',
'tours.provenance.localState': 'هنا',
'tours.provenance.plannedOver': 'أعيد تخطيطه مع الاحتفاظ بالمسار المسجّل',
'tours.provenance.editedLocally': 'عُدّل في TREK',
'tours.provenance.asImported': 'كما تم استيراده',
'tours.provenance.openSource': 'فتح في wanderer',
'tours.provenance.check': 'تحقّق من المصدر',
'tours.provenance.checkHint': 'يسأل wanderer هل تغيّر هذا المسار منذ الاستيراد. لا يتغيّر شيء في الرحلة.',
'tours.provenance.state.unchanged': 'لم يتغيّر منذ الاستيراد',
'tours.provenance.state.changed': 'تغيّر في wanderer',
'tours.provenance.state.unavailable': 'لم يعد متاحًا',
'tours.provenance.state.not_connected': 'لا يوجد اتصال بـ wanderer',
'tours.provenance.state.other_instance': 'هناك wanderer آخر متصل',
'tours.provenance.state.unknown': 'تتعذّر المقارنة',
'tours.provenance.body.unavailable': 'المسار حُذف أو لم يعد اتصالك يستطيع قراءته. تبقى هذه الجولة كما استُوردت.',
'tours.provenance.body.not_connected': 'اربط wanderer الخاص بك من الإعدادات، التكاملات للتحقق من المصدر.',
'tours.provenance.body.other_instance': 'اتصالك يشير إلى نسخة غير التي جاءت منها هذه الجولة.',
'tours.provenance.body.unknown':
'لا يحتوي هذا الاستيراد على ما يمكن المقارنة به. ومع ذلك يمكنك جلب المسار الحالي كلقطة جديدة.',
'tours.provenance.change.name': 'الاسم',
'tours.provenance.change.description': 'الوصف',
'tours.provenance.change.route': 'خط السير',
'tours.provenance.nowUpdated': 'يذكر wanderer أن آخر تغيير كان في {date}',
'tours.provenance.reimport': 'استيراد كلقطة جديدة',
'tours.provenance.reimportTitle': 'استيراد المسار المعدّل؟',
'tours.provenance.reimportBody': 'يأتي كجولة جديدة بجانب هذه الجولة. تبقى هذه الجولة وأيامها وملاحظاتها كما هي.',
'tours.provenance.reimported': 'تم استيراد لقطة جديدة',
'tours.visibility.hidden': 'مخفية على الخريطة',
'tours.visibility.showAll': 'إظهار الكل',
'tours.visibility.show': 'إظهار على الخريطة',
'tours.visibility.hide': 'إخفاء على الخريطة',
};
export default tours;
+40
View File
@@ -218,5 +218,45 @@ const tours: TranslationStrings = {
'Marşrut {length} boyunca “{name}” üzərindən keçir. Bunun üçün via ferrata ləvazimatı və təcrübə lazımdır.',
'tours.ferrata.crossingShort': 'Marşrut via ferrata üzərindən keçir',
'tours.ferrata.waypointAtEnd': '“{name}” başlanğıcı və ya sonu',
'tours.provenance.label': 'Mənşə',
'tours.provenance.source': 'Mənbə',
'tours.provenance.importedAt': 'İdxal edilib',
'tours.provenance.createdAt': 'Yaradılıb',
'tours.provenance.externalUpdated': 'wanderer-də dəyişib',
'tours.provenance.externalUpdatedHint':
'wanderer-in idxal zamanı verdiyi dəyişiklik tarixi. İpucudur, versiya sübutu deyil.',
'tours.provenance.localState': 'Burada',
'tours.provenance.plannedOver': 'Yenidən planlaşdırılıb, yazılmış iz saxlanılıb',
'tours.provenance.editedLocally': 'TREK-də redaktə edilib',
'tours.provenance.asImported': 'İdxal edildiyi kimi',
'tours.provenance.openSource': 'wanderer-də aç',
'tours.provenance.check': 'Mənbəni yoxla',
'tours.provenance.checkHint':
'wanderer-dən bu marşrutun idxaldan sonra dəyişib-dəyişmədiyini soruşur. Səyahətdə heç nə dəyişmir.',
'tours.provenance.state.unchanged': 'İdxaldan sonra dəyişməyib',
'tours.provenance.state.changed': 'wanderer-də dəyişib',
'tours.provenance.state.unavailable': 'Artıq mövcud deyil',
'tours.provenance.state.not_connected': 'wanderer bağlantısı yoxdur',
'tours.provenance.state.other_instance': 'Başqa wanderer qoşulub',
'tours.provenance.state.unknown': 'Müqayisə etmək olmur',
'tours.provenance.body.unavailable':
'Marşrut silinib və ya bağlantınla artıq oxunmur. Bu tur idxal edildiyi kimi qalır.',
'tours.provenance.body.not_connected': 'Mənbəni yoxlamaq üçün wanderer-ini Ayarlar, İnteqrasiyalar bölməsində qoş.',
'tours.provenance.body.other_instance': 'Bağlantın bu turun gəldiyi nüsxədən fərqli bir nüsxəyə yönəlib.',
'tours.provenance.body.unknown':
'Bu idxalda müqayisə üçün heç nə yoxdur. Cari marşrutu yenə də yeni bir snapşot kimi gətirə bilərsən.',
'tours.provenance.change.name': 'Ad',
'tours.provenance.change.description': 'Təsvir',
'tours.provenance.change.route': 'Marşrut',
'tours.provenance.nowUpdated': 'wanderer son dəyişiklik kimi {date} tarixini göstərir',
'tours.provenance.reimport': 'Yeni snapşot kimi idxal et',
'tours.provenance.reimportTitle': 'Dəyişmiş marşrut idxal edilsin?',
'tours.provenance.reimportBody':
'O, bu turun yanında yeni tur kimi gəlir. Bu tur, onun günləri və qeydləri olduğu kimi qalır.',
'tours.provenance.reimported': 'Yeni snapşot idxal edildi',
'tours.visibility.hidden': 'Xəritədə gizlidir',
'tours.visibility.showAll': 'Hamısını göstər',
'tours.visibility.show': 'Xəritədə göstər',
'tours.visibility.hide': 'Xəritədə gizlət',
};
export default tours;
+40
View File
@@ -223,5 +223,45 @@ const tours: TranslationStrings = {
'A rota passa {length} pela “{name}”. Isso exige equipamento de via ferrata e experiência.',
'tours.ferrata.crossingShort': 'A rota passa por uma via ferrata',
'tours.ferrata.waypointAtEnd': 'Início ou fim de “{name}”',
'tours.provenance.label': 'Origem',
'tours.provenance.source': 'Fonte',
'tours.provenance.importedAt': 'Importado',
'tours.provenance.createdAt': 'Criado',
'tours.provenance.externalUpdated': 'Alterado no wanderer',
'tours.provenance.externalUpdatedHint':
'A data de alteração que o wanderer informou na importação. Uma pista, não prova de versão.',
'tours.provenance.localState': 'Aqui',
'tours.provenance.plannedOver': 'Replanejado, trilha gravada mantida',
'tours.provenance.editedLocally': 'Editado no TREK',
'tours.provenance.asImported': 'Como importado',
'tours.provenance.openSource': 'Abrir no wanderer',
'tours.provenance.check': 'Verificar fonte',
'tours.provenance.checkHint': 'Pergunta ao wanderer se esta trilha mudou desde a importação. Nada muda na viagem.',
'tours.provenance.state.unchanged': 'Sem alterações desde a importação',
'tours.provenance.state.changed': 'Alterado no wanderer',
'tours.provenance.state.unavailable': 'Não está mais disponível',
'tours.provenance.state.not_connected': 'Sem conexão com o wanderer',
'tours.provenance.state.other_instance': 'Outro wanderer está conectado',
'tours.provenance.state.unknown': 'Não é possível comparar',
'tours.provenance.body.unavailable':
'A trilha sumiu ou não pode mais ser lida com a sua conexão. Este passeio fica como foi importado.',
'tours.provenance.body.not_connected': 'Conecte seu wanderer em Configurações, Integrações para verificar a fonte.',
'tours.provenance.body.other_instance':
'Sua conexão aponta para uma instância diferente daquela de onde veio este passeio.',
'tours.provenance.body.unknown':
'Esta importação não tem nada para comparar. Mesmo assim você pode trazer a trilha atual como um novo instantâneo.',
'tours.provenance.change.name': 'Nome',
'tours.provenance.change.description': 'Descrição',
'tours.provenance.change.route': 'Percurso',
'tours.provenance.nowUpdated': 'o wanderer informa {date} como última alteração',
'tours.provenance.reimport': 'Importar como novo instantâneo',
'tours.provenance.reimportTitle': 'Importar a trilha alterada?',
'tours.provenance.reimportBody':
'Ela entra como um novo passeio ao lado deste. Este passeio, seus dias e notas ficam como estão.',
'tours.provenance.reimported': 'Novo instantâneo importado',
'tours.visibility.hidden': 'Oculto no mapa',
'tours.visibility.showAll': 'Mostrar todos',
'tours.visibility.show': 'Mostrar no mapa',
'tours.visibility.hide': 'Ocultar no mapa',
};
export default tours;
+41
View File
@@ -223,5 +223,46 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'La ruta fa {length} per «{name}». Cal material de via ferrada i experiència.',
'tours.ferrata.crossingShort': 'La ruta passa per una via ferrada',
'tours.ferrata.waypointAtEnd': 'Inici o final de «{name}»',
'tours.provenance.label': 'Origen',
'tours.provenance.source': 'Font',
'tours.provenance.importedAt': 'Importat',
'tours.provenance.createdAt': 'Creat',
'tours.provenance.externalUpdated': 'Modificat a wanderer',
'tours.provenance.externalUpdatedHint':
'La data de canvi que wanderer va indicar en importar. Una pista, no una prova de versió.',
'tours.provenance.localState': 'Aquí',
'tours.provenance.plannedOver': 'Replanificat, traça enregistrada conservada',
'tours.provenance.editedLocally': 'Editat a TREK',
'tours.provenance.asImported': 'Tal com es va importar',
'tours.provenance.openSource': 'Obre a wanderer',
'tours.provenance.check': 'Comprova la font',
'tours.provenance.checkHint':
'Pregunta a wanderer si aquesta ruta ha canviat des de la importació. No canvia res al viatge.',
'tours.provenance.state.unchanged': 'Sense canvis des de la importació',
'tours.provenance.state.changed': 'Modificat a wanderer',
'tours.provenance.state.unavailable': 'Ja no està disponible',
'tours.provenance.state.not_connected': 'Sense connexió amb wanderer',
'tours.provenance.state.other_instance': 'Hi ha un altre wanderer connectat',
'tours.provenance.state.unknown': 'No es pot comparar',
'tours.provenance.body.unavailable':
'La ruta ja no existeix o no es pot llegir amb la teva connexió. Aquesta excursió es queda tal com es va importar.',
'tours.provenance.body.not_connected': 'Connecta el teu wanderer a Configuració, Integracions per comprovar la font.',
'tours.provenance.body.other_instance':
'La teva connexió apunta a una instància diferent de la que va venir aquesta excursió.',
'tours.provenance.body.unknown':
'Aquesta importació no té res per comparar. Igualment pots portar la ruta actual com a nova instantània.',
'tours.provenance.change.name': 'Nom',
'tours.provenance.change.description': 'Descripció',
'tours.provenance.change.route': 'Recorregut',
'tours.provenance.nowUpdated': 'wanderer indica {date} com a darrer canvi',
'tours.provenance.reimport': 'Importa com a nova instantània',
'tours.provenance.reimportTitle': 'Vols importar la ruta modificada?',
'tours.provenance.reimportBody':
'Arriba com una excursió nova al costat d’aquesta. Aquesta excursió, els seus dies i notes es queden com estan.',
'tours.provenance.reimported': 'Nova instantània importada',
'tours.visibility.hidden': 'Amagada al mapa',
'tours.visibility.showAll': 'Mostra-les totes',
'tours.visibility.show': 'Mostra al mapa',
'tours.visibility.hide': 'Amaga al mapa',
};
export default tours;
+38
View File
@@ -220,5 +220,43 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'Trasa vede {length} po „{name}“. Potřebujete feratové vybavení a zkušenosti.',
'tours.ferrata.crossingShort': 'Trasa vede po feratě',
'tours.ferrata.waypointAtEnd': 'Začátek nebo konec „{name}“',
'tours.provenance.label': 'Původ',
'tours.provenance.source': 'Zdroj',
'tours.provenance.importedAt': 'Importováno',
'tours.provenance.createdAt': 'Vytvořeno',
'tours.provenance.externalUpdated': 'Změněno ve wanderer',
'tours.provenance.externalUpdatedHint': 'Datum změny, které wanderer uvedl při importu. Vodítko, ne důkaz verze.',
'tours.provenance.localState': 'Zde',
'tours.provenance.plannedOver': 'Přeplánováno, zaznamenaná stopa zachována',
'tours.provenance.editedLocally': 'Upraveno v TREK',
'tours.provenance.asImported': 'Jak bylo importováno',
'tours.provenance.openSource': 'Otevřít ve wanderer',
'tours.provenance.check': 'Zkontrolovat zdroj',
'tours.provenance.checkHint': 'Zeptá se wanderer, zda se tato trasa od importu změnila. V cestě se nic nezmění.',
'tours.provenance.state.unchanged': 'Beze změny od importu',
'tours.provenance.state.changed': 'Změněno ve wanderer',
'tours.provenance.state.unavailable': 'Už není k dispozici',
'tours.provenance.state.not_connected': 'Žádné připojení k wanderer',
'tours.provenance.state.other_instance': 'Je připojen jiný wanderer',
'tours.provenance.state.unknown': 'Nelze porovnat',
'tours.provenance.body.unavailable':
'Trasa zmizela nebo ji vaše připojení už nepřečte. Tato túra zůstane tak, jak byla importována.',
'tours.provenance.body.not_connected': 'Připojte svůj wanderer v Nastavení, Integrace a zkontrolujte zdroj.',
'tours.provenance.body.other_instance': 'Vaše připojení míří na jinou instanci než tu, ze které tato túra pochází.',
'tours.provenance.body.unknown':
'Tento import nemá s čím porovnat. Aktuální trasu přesto můžete převzít jako nový snímek.',
'tours.provenance.change.name': 'Název',
'tours.provenance.change.description': 'Popis',
'tours.provenance.change.route': 'Průběh trasy',
'tours.provenance.nowUpdated': 'wanderer uvádí {date} jako poslední změnu',
'tours.provenance.reimport': 'Importovat jako nový snímek',
'tours.provenance.reimportTitle': 'Importovat změněnou trasu?',
'tours.provenance.reimportBody':
'Přibude jako nová túra vedle této. Tato túra, její dny a poznámky zůstanou, jak jsou.',
'tours.provenance.reimported': 'Nový snímek importován',
'tours.visibility.hidden': 'Skryto na mapě',
'tours.visibility.showAll': 'Zobrazit vše',
'tours.visibility.show': 'Zobrazit na mapě',
'tours.visibility.hide': 'Skrýt na mapě',
};
export default tours;
+42
View File
@@ -223,6 +223,48 @@ const tours: TranslationStrings = {
'Die Route führt {length} über „{name}“. Dafür brauchst du Klettersteigausrüstung und Erfahrung.',
'tours.ferrata.crossingShort': 'Die Route führt über einen Klettersteig',
'tours.ferrata.waypointAtEnd': 'Anfang oder Ende von „{name}“',
'tours.provenance.label': 'Herkunft',
'tours.provenance.source': 'Quelle',
'tours.provenance.importedAt': 'Importiert',
'tours.provenance.createdAt': 'Angelegt',
'tours.provenance.externalUpdated': 'Geändert bei wanderer',
'tours.provenance.externalUpdatedHint':
'Das Änderungsdatum, das wanderer beim Import genannt hat. Ein Hinweis, kein Versionsnachweis.',
'tours.provenance.localState': 'Hier',
'tours.provenance.plannedOver': 'Überplant, Originalspur gesichert',
'tours.provenance.editedLocally': 'In TREK bearbeitet',
'tours.provenance.asImported': 'Wie importiert',
'tours.provenance.openSource': 'In wanderer öffnen',
'tours.provenance.check': 'Quelle prüfen',
'tours.provenance.checkHint':
'Fragt wanderer, ob sich dieser Trail seit dem Import geändert hat. Im Trip ändert sich dabei nichts.',
'tours.provenance.state.unchanged': 'Unverändert seit dem Import',
'tours.provenance.state.changed': 'Bei wanderer geändert',
'tours.provenance.state.unavailable': 'Nicht mehr verfügbar',
'tours.provenance.state.not_connected': 'Keine wanderer-Verbindung',
'tours.provenance.state.other_instance': 'Anderes wanderer verbunden',
'tours.provenance.state.unknown': 'Kein Vergleich möglich',
'tours.provenance.body.unavailable':
'Der Trail ist weg oder mit deiner Verbindung nicht mehr lesbar. Diese Tour bleibt so, wie sie importiert wurde.',
'tours.provenance.body.not_connected':
'Verbinde dein wanderer unter Einstellungen, Integrationen, um die Quelle zu prüfen.',
'tours.provenance.body.other_instance':
'Deine Verbindung zeigt auf eine andere Instanz als die, aus der diese Tour stammt.',
'tours.provenance.body.unknown':
'Dieser Import enthält nichts zum Vergleichen. Den aktuellen Trail kannst du trotzdem als neuen Snapshot übernehmen.',
'tours.provenance.change.name': 'Name', // same-as-en
'tours.provenance.change.description': 'Beschreibung',
'tours.provenance.change.route': 'Strecke',
'tours.provenance.nowUpdated': 'wanderer nennt {date} als letzte Änderung',
'tours.provenance.reimport': 'Als neuen Snapshot importieren',
'tours.provenance.reimportTitle': 'Geänderten Trail importieren?',
'tours.provenance.reimportBody':
'Er kommt als neue Tour neben diese. Diese Tour, ihre Tage und Notizen bleiben, wie sie sind.',
'tours.provenance.reimported': 'Neuer Snapshot importiert',
'tours.visibility.hidden': 'Auf der Karte ausgeblendet',
'tours.visibility.showAll': 'Alle einblenden',
'tours.visibility.show': 'Auf der Karte einblenden',
'tours.visibility.hide': 'Auf der Karte ausblenden',
};
export default tours;
+41
View File
@@ -216,5 +216,46 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'The route climbs {length} of “{name}”. That takes via ferrata gear and experience.',
'tours.ferrata.crossingShort': 'The route climbs a via ferrata',
'tours.ferrata.waypointAtEnd': 'Start or end of “{name}”',
'tours.provenance.label': 'Origin',
'tours.provenance.source': 'Source',
'tours.provenance.importedAt': 'Imported',
'tours.provenance.createdAt': 'Created',
'tours.provenance.externalUpdated': 'Changed on wanderer',
'tours.provenance.externalUpdatedHint':
'The change date wanderer gave at the import. A hint, not proof of a version.',
'tours.provenance.localState': 'Here',
'tours.provenance.plannedOver': 'Planned over, recorded track kept',
'tours.provenance.editedLocally': 'Edited in TREK',
'tours.provenance.asImported': 'As imported',
'tours.provenance.openSource': 'Open in wanderer',
'tours.provenance.check': 'Check source',
'tours.provenance.checkHint':
'Asks wanderer whether this trail changed since the import. Nothing in the trip changes.',
'tours.provenance.state.unchanged': 'Unchanged since the import',
'tours.provenance.state.changed': 'Changed on wanderer',
'tours.provenance.state.unavailable': 'No longer available',
'tours.provenance.state.not_connected': 'No wanderer connection',
'tours.provenance.state.other_instance': 'Another wanderer is connected',
'tours.provenance.state.unknown': 'Cannot compare',
'tours.provenance.body.unavailable':
'The trail is gone or can no longer be read with your connection. This tour stays as it was imported.',
'tours.provenance.body.not_connected': 'Connect your wanderer under Settings, Integrations to check the source.',
'tours.provenance.body.other_instance':
'Your connection points to a different instance than the one this tour came from.',
'tours.provenance.body.unknown':
'This import carries nothing to compare against. You can still bring in the current trail as a new snapshot.',
'tours.provenance.change.name': 'Name',
'tours.provenance.change.description': 'Description',
'tours.provenance.change.route': 'Route',
'tours.provenance.nowUpdated': 'wanderer gives {date} as the last change',
'tours.provenance.reimport': 'Import as new snapshot',
'tours.provenance.reimportTitle': 'Import the changed trail?',
'tours.provenance.reimportBody':
'It comes in as a new tour beside this one. This tour, its days and notes stay as they are.',
'tours.provenance.reimported': 'New snapshot imported',
'tours.visibility.hidden': 'Hidden on the map',
'tours.visibility.showAll': 'Show all',
'tours.visibility.show': 'Show on the map',
'tours.visibility.hide': 'Hide on the map',
};
export default tours;
+40
View File
@@ -222,5 +222,45 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'La ruta recorre {length} de «{name}». Necesitas equipo de vía ferrata y experiencia.',
'tours.ferrata.crossingShort': 'La ruta pasa por una vía ferrata',
'tours.ferrata.waypointAtEnd': 'Inicio o final de «{name}»',
'tours.provenance.label': 'Origen',
'tours.provenance.source': 'Fuente',
'tours.provenance.importedAt': 'Importado',
'tours.provenance.createdAt': 'Creado',
'tours.provenance.externalUpdated': 'Modificado en wanderer',
'tours.provenance.externalUpdatedHint':
'La fecha de cambio que wanderer indicó al importar. Una pista, no una prueba de versión.',
'tours.provenance.localState': 'Aquí',
'tours.provenance.plannedOver': 'Replanificado, traza grabada conservada',
'tours.provenance.editedLocally': 'Editado en TREK',
'tours.provenance.asImported': 'Tal como se importó',
'tours.provenance.openSource': 'Abrir en wanderer',
'tours.provenance.check': 'Comprobar fuente',
'tours.provenance.checkHint':
'Pregunta a wanderer si esta ruta cambió desde la importación. No cambia nada en el viaje.',
'tours.provenance.state.unchanged': 'Sin cambios desde la importación',
'tours.provenance.state.changed': 'Modificado en wanderer',
'tours.provenance.state.unavailable': 'Ya no disponible',
'tours.provenance.state.not_connected': 'Sin conexión con wanderer',
'tours.provenance.state.other_instance': 'Hay otro wanderer conectado',
'tours.provenance.state.unknown': 'No se puede comparar',
'tours.provenance.body.unavailable':
'La ruta ya no existe o no se puede leer con tu conexión. Esta excursión queda tal como se importó.',
'tours.provenance.body.not_connected': 'Conecta tu wanderer en Ajustes, Integraciones para comprobar la fuente.',
'tours.provenance.body.other_instance': 'Tu conexión apunta a otra instancia distinta de la que vino esta excursión.',
'tours.provenance.body.unknown':
'Esta importación no tiene nada con qué comparar. Aun así puedes traer la ruta actual como nueva instantánea.',
'tours.provenance.change.name': 'Nombre',
'tours.provenance.change.description': 'Descripción',
'tours.provenance.change.route': 'Recorrido',
'tours.provenance.nowUpdated': 'wanderer indica {date} como último cambio',
'tours.provenance.reimport': 'Importar como nueva instantánea',
'tours.provenance.reimportTitle': '¿Importar la ruta modificada?',
'tours.provenance.reimportBody':
'Llega como una excursión nueva junto a esta. Esta excursión, sus días y notas quedan como están.',
'tours.provenance.reimported': 'Nueva instantánea importada',
'tours.visibility.hidden': 'Oculta en el mapa',
'tours.visibility.showAll': 'Mostrar todas',
'tours.visibility.show': 'Mostrar en el mapa',
'tours.visibility.hide': 'Ocultar en el mapa',
};
export default tours;
+40
View File
@@ -220,5 +220,45 @@ const tours: TranslationStrings = {
'Marsruut läbib {length} rajal „{name}“. Selleks on vaja via ferrata varustust ja kogemust.',
'tours.ferrata.crossingShort': 'Marsruut läbib via ferrata raja',
'tours.ferrata.waypointAtEnd': '„{name}“ algus või lõpp',
'tours.provenance.label': 'Päritolu',
'tours.provenance.source': 'Allikas',
'tours.provenance.importedAt': 'Imporditud',
'tours.provenance.createdAt': 'Loodud',
'tours.provenance.externalUpdated': 'Muudetud wandereris',
'tours.provenance.externalUpdatedHint':
'Muutmise kuupäev, mille wanderer impordil andis. Vihje, mitte versiooni tõend.',
'tours.provenance.localState': 'Siin',
'tours.provenance.plannedOver': 'Ümber planeeritud, salvestatud rada alles',
'tours.provenance.editedLocally': 'Muudetud TREKis',
'tours.provenance.asImported': 'Nagu imporditud',
'tours.provenance.openSource': 'Ava wandereris',
'tours.provenance.check': 'Kontrolli allikat',
'tours.provenance.checkHint': 'Küsib wandererilt, kas see rada on pärast importi muutunud. Reisis ei muutu midagi.',
'tours.provenance.state.unchanged': 'Pärast importi muutmata',
'tours.provenance.state.changed': 'Muudetud wandereris',
'tours.provenance.state.unavailable': 'Pole enam saadaval',
'tours.provenance.state.not_connected': 'wandereri ühendus puudub',
'tours.provenance.state.other_instance': 'Ühendatud on teine wanderer',
'tours.provenance.state.unknown': 'Võrrelda ei saa',
'tours.provenance.body.unavailable':
'Rada on kadunud või sinu ühendusega enam loetamatu. See matk jääb selliseks, nagu see imporditi.',
'tours.provenance.body.not_connected':
'Ühenda oma wanderer jaotises Seaded, Integratsioonid, et allikat kontrollida.',
'tours.provenance.body.other_instance': 'Sinu ühendus osutab teisele eksemplarile kui see, kust see matk pärit on.',
'tours.provenance.body.unknown':
'Selles impordis pole millegagi võrrelda. Praeguse raja saad siiski tuua uue hetktõmmisena.',
'tours.provenance.change.name': 'Nimi',
'tours.provenance.change.description': 'Kirjeldus',
'tours.provenance.change.route': 'Marsruut',
'tours.provenance.nowUpdated': 'wanderer nimetab viimaseks muudatuseks {date}',
'tours.provenance.reimport': 'Impordi uue hetktõmmisena',
'tours.provenance.reimportTitle': 'Kas importida muudetud rada?',
'tours.provenance.reimportBody':
'See lisandub uue matkana selle kõrvale. See matk, selle päevad ja märkmed jäävad samaks.',
'tours.provenance.reimported': 'Uus hetktõmmis imporditud',
'tours.visibility.hidden': 'Kaardil peidetud',
'tours.visibility.showAll': 'Näita kõiki',
'tours.visibility.show': 'Näita kaardil',
'tours.visibility.hide': 'Peida kaardil',
};
export default tours;
+42
View File
@@ -225,5 +225,47 @@ const tours: TranslationStrings = {
'L’itinéraire passe {length} sur « {name} ». Il faut l’équipement de via ferrata et de l’expérience.',
'tours.ferrata.crossingShort': 'L’itinéraire passe par une via ferrata',
'tours.ferrata.waypointAtEnd': 'Début ou fin de « {name} »',
'tours.provenance.label': 'Origine',
'tours.provenance.source': 'Source', // same-as-en
'tours.provenance.importedAt': 'Importé',
'tours.provenance.createdAt': 'Créé',
'tours.provenance.externalUpdated': 'Modifié sur wanderer',
'tours.provenance.externalUpdatedHint':
'La date de modification indiquée par wanderer lors de l’import. Un indice, pas la preuve d’une version.',
'tours.provenance.localState': 'Ici',
'tours.provenance.plannedOver': 'Replanifié, tracé enregistré conservé',
'tours.provenance.editedLocally': 'Modifié dans TREK',
'tours.provenance.asImported': 'Tel qu’importé',
'tours.provenance.openSource': 'Ouvrir dans wanderer',
'tours.provenance.check': 'Vérifier la source',
'tours.provenance.checkHint':
'Demande à wanderer si ce sentier a changé depuis l’import. Rien ne change dans le voyage.',
'tours.provenance.state.unchanged': 'Inchangé depuis l’import',
'tours.provenance.state.changed': 'Modifié sur wanderer',
'tours.provenance.state.unavailable': 'Plus disponible',
'tours.provenance.state.not_connected': 'Aucune connexion wanderer',
'tours.provenance.state.other_instance': 'Un autre wanderer est connecté',
'tours.provenance.state.unknown': 'Comparaison impossible',
'tours.provenance.body.unavailable':
'Le sentier a disparu ou n’est plus lisible avec votre connexion. Cette sortie reste telle qu’elle a été importée.',
'tours.provenance.body.not_connected':
'Connectez votre wanderer dans Paramètres, Intégrations pour vérifier la source.',
'tours.provenance.body.other_instance':
'Votre connexion pointe vers une autre instance que celle d’où vient cette sortie.',
'tours.provenance.body.unknown':
'Cet import ne contient rien à comparer. Vous pouvez quand même importer le sentier actuel comme nouvel instantané.',
'tours.provenance.change.name': 'Nom',
'tours.provenance.change.description': 'Description', // same-as-en
'tours.provenance.change.route': 'Itinéraire',
'tours.provenance.nowUpdated': 'wanderer indique {date} comme dernière modification',
'tours.provenance.reimport': 'Importer comme nouvel instantané',
'tours.provenance.reimportTitle': 'Importer le sentier modifié ?',
'tours.provenance.reimportBody':
'Il arrive comme nouvelle sortie à côté de celle-ci. Cette sortie, ses jours et ses notes restent inchangés.',
'tours.provenance.reimported': 'Nouvel instantané importé',
'tours.visibility.hidden': 'Masqué sur la carte',
'tours.visibility.showAll': 'Tout afficher',
'tours.visibility.show': 'Afficher sur la carte',
'tours.visibility.hide': 'Masquer sur la carte',
};
export default tours;
+42
View File
@@ -227,5 +227,47 @@ const tours: TranslationStrings = {
'Η διαδρομή περνά {length} από το «{name}». Χρειάζεσαι εξοπλισμό via ferrata και εμπειρία.',
'tours.ferrata.crossingShort': 'Η διαδρομή περνά από via ferrata',
'tours.ferrata.waypointAtEnd': 'Αρχή ή τέλος του «{name}»',
'tours.provenance.label': 'Προέλευση',
'tours.provenance.source': 'Πηγή',
'tours.provenance.importedAt': 'Εισήχθη',
'tours.provenance.createdAt': 'Δημιουργήθηκε',
'tours.provenance.externalUpdated': 'Άλλαξε στο wanderer',
'tours.provenance.externalUpdatedHint':
'Η ημερομηνία αλλαγής που έδωσε το wanderer κατά την εισαγωγή. Ένδειξη, όχι απόδειξη έκδοσης.',
'tours.provenance.localState': 'Εδώ',
'tours.provenance.plannedOver': 'Ξανασχεδιάστηκε, η καταγεγραμμένη διαδρομή κρατήθηκε',
'tours.provenance.editedLocally': 'Επεξεργάστηκε στο TREK',
'tours.provenance.asImported': 'Όπως εισήχθη',
'tours.provenance.openSource': 'Άνοιγμα στο wanderer',
'tours.provenance.check': 'Έλεγχος πηγής',
'tours.provenance.checkHint':
'Ρωτά το wanderer αν αυτό το μονοπάτι άλλαξε από την εισαγωγή. Τίποτα δεν αλλάζει στο ταξίδι.',
'tours.provenance.state.unchanged': 'Αμετάβλητο από την εισαγωγή',
'tours.provenance.state.changed': 'Άλλαξε στο wanderer',
'tours.provenance.state.unavailable': 'Δεν είναι πλέον διαθέσιμο',
'tours.provenance.state.not_connected': 'Χωρίς σύνδεση wanderer',
'tours.provenance.state.other_instance': 'Είναι συνδεδεμένο άλλο wanderer',
'tours.provenance.state.unknown': 'Δεν γίνεται σύγκριση',
'tours.provenance.body.unavailable':
'Το μονοπάτι χάθηκε ή δεν διαβάζεται πια με τη σύνδεσή σου. Αυτή η εκδρομή μένει όπως εισήχθη.',
'tours.provenance.body.not_connected':
'Σύνδεσε το wanderer σου στις Ρυθμίσεις, Ενσωματώσεις για να ελέγξεις την πηγή.',
'tours.provenance.body.other_instance':
'Η σύνδεσή σου δείχνει σε άλλη εγκατάσταση από αυτή από την οποία ήρθε η εκδρομή.',
'tours.provenance.body.unknown':
'Αυτή η εισαγωγή δεν έχει τίποτα για σύγκριση. Μπορείς όμως να φέρεις το τρέχον μονοπάτι ως νέο στιγμιότυπο.',
'tours.provenance.change.name': 'Όνομα',
'tours.provenance.change.description': 'Περιγραφή',
'tours.provenance.change.route': 'Διαδρομή',
'tours.provenance.nowUpdated': 'Το wanderer δίνει τελευταία αλλαγή στις {date}',
'tours.provenance.reimport': 'Εισαγωγή ως νέο στιγμιότυπο',
'tours.provenance.reimportTitle': 'Εισαγωγή του αλλαγμένου μονοπατιού;',
'tours.provenance.reimportBody':
'Έρχεται ως νέα εκδρομή δίπλα σε αυτήν. Αυτή η εκδρομή, οι μέρες και οι σημειώσεις της μένουν ως έχουν.',
'tours.provenance.reimported': 'Εισήχθη νέο στιγμιότυπο',
'tours.visibility.hidden': 'Κρυμμένο στον χάρτη',
'tours.visibility.showAll': 'Εμφάνιση όλων',
'tours.visibility.show': 'Εμφάνιση στον χάρτη',
'tours.visibility.hide': 'Απόκρυψη στον χάρτη',
};
export default tours;
+41
View File
@@ -223,5 +223,46 @@ const tours: TranslationStrings = {
'Az útvonal {length} hosszan a(z) „{name}” vasalt úton halad. Ehhez via ferrata felszerelés és gyakorlat kell.',
'tours.ferrata.crossingShort': 'Az útvonal vasalt úton halad',
'tours.ferrata.waypointAtEnd': '„{name}” eleje vagy vége',
'tours.provenance.label': 'Eredet',
'tours.provenance.source': 'Forrás',
'tours.provenance.importedAt': 'Importálva',
'tours.provenance.createdAt': 'Létrehozva',
'tours.provenance.externalUpdated': 'Módosítva a wandereren',
'tours.provenance.externalUpdatedHint':
'A wanderer által az importáláskor megadott módosítási dátum. Támpont, nem a verzió bizonyítéka.',
'tours.provenance.localState': 'Itt',
'tours.provenance.plannedOver': 'Újratervezve, a rögzített nyomvonal megőrizve',
'tours.provenance.editedLocally': 'Szerkesztve a TREK-ben',
'tours.provenance.asImported': 'Ahogy importálva',
'tours.provenance.openSource': 'Megnyitás a wandereren',
'tours.provenance.check': 'Forrás ellenőrzése',
'tours.provenance.checkHint':
'Megkérdezi a wanderert, változott-e ez az útvonal az importálás óta. Az utazásban semmi sem változik.',
'tours.provenance.state.unchanged': 'Változatlan az importálás óta',
'tours.provenance.state.changed': 'Módosítva a wandereren',
'tours.provenance.state.unavailable': 'Már nem érhető el',
'tours.provenance.state.not_connected': 'Nincs wanderer-kapcsolat',
'tours.provenance.state.other_instance': 'Másik wanderer van csatlakoztatva',
'tours.provenance.state.unknown': 'Nem összehasonlítható',
'tours.provenance.body.unavailable':
'Az útvonal megszűnt, vagy a kapcsolatoddal már nem olvasható. Ez a túra úgy marad, ahogy importálták.',
'tours.provenance.body.not_connected':
'Csatlakoztasd a wanderered a Beállítások, Integrációk alatt a forrás ellenőrzéséhez.',
'tours.provenance.body.other_instance': 'A kapcsolatod egy másik példányra mutat, mint ahonnan ez a túra származik.',
'tours.provenance.body.unknown':
'Ebben az importban nincs mihez hasonlítani. Az aktuális útvonalat így is behozhatod új pillanatképként.',
'tours.provenance.change.name': 'Név',
'tours.provenance.change.description': 'Leírás',
'tours.provenance.change.route': 'Nyomvonal',
'tours.provenance.nowUpdated': 'a wanderer szerint az utolsó módosítás: {date}',
'tours.provenance.reimport': 'Importálás új pillanatképként',
'tours.provenance.reimportTitle': 'Importálod a módosított útvonalat?',
'tours.provenance.reimportBody':
'Új túraként érkezik e mellé. Ez a túra, a napjai és jegyzetei változatlanok maradnak.',
'tours.provenance.reimported': 'Új pillanatkép importálva',
'tours.visibility.hidden': 'Elrejtve a térképen',
'tours.visibility.showAll': 'Összes megjelenítése',
'tours.visibility.show': 'Megjelenítés a térképen',
'tours.visibility.hide': 'Elrejtés a térképen',
};
export default tours;
+40
View File
@@ -217,5 +217,45 @@ const tours: TranslationStrings = {
'Rute melewati {length} di “{name}”. Ini butuh perlengkapan via ferrata dan pengalaman.',
'tours.ferrata.crossingShort': 'Rute melewati via ferrata',
'tours.ferrata.waypointAtEnd': 'Awal atau akhir “{name}”',
'tours.provenance.label': 'Asal',
'tours.provenance.source': 'Sumber',
'tours.provenance.importedAt': 'Diimpor',
'tours.provenance.createdAt': 'Dibuat',
'tours.provenance.externalUpdated': 'Diubah di wanderer',
'tours.provenance.externalUpdatedHint':
'Tanggal perubahan yang diberikan wanderer saat impor. Sebuah petunjuk, bukan bukti versi.',
'tours.provenance.localState': 'Di sini',
'tours.provenance.plannedOver': 'Direncanakan ulang, jejak rekaman disimpan',
'tours.provenance.editedLocally': 'Diedit di TREK',
'tours.provenance.asImported': 'Seperti saat diimpor',
'tours.provenance.openSource': 'Buka di wanderer',
'tours.provenance.check': 'Periksa sumber',
'tours.provenance.checkHint':
'Menanyakan ke wanderer apakah jalur ini berubah sejak diimpor. Tidak ada yang berubah di perjalanan.',
'tours.provenance.state.unchanged': 'Tidak berubah sejak impor',
'tours.provenance.state.changed': 'Diubah di wanderer',
'tours.provenance.state.unavailable': 'Tidak tersedia lagi',
'tours.provenance.state.not_connected': 'Tidak ada koneksi wanderer',
'tours.provenance.state.other_instance': 'wanderer lain yang tersambung',
'tours.provenance.state.unknown': 'Tidak bisa dibandingkan',
'tours.provenance.body.unavailable':
'Jalur sudah hilang atau tidak bisa dibaca lagi dengan koneksimu. Tur ini tetap seperti saat diimpor.',
'tours.provenance.body.not_connected': 'Sambungkan wanderer-mu di Pengaturan, Integrasi untuk memeriksa sumber.',
'tours.provenance.body.other_instance': 'Koneksimu mengarah ke instans yang berbeda dari asal tur ini.',
'tours.provenance.body.unknown':
'Impor ini tidak memuat apa pun untuk dibandingkan. Kamu tetap bisa mengambil jalur saat ini sebagai snapshot baru.',
'tours.provenance.change.name': 'Nama',
'tours.provenance.change.description': 'Deskripsi',
'tours.provenance.change.route': 'Rute',
'tours.provenance.nowUpdated': 'wanderer menyebut {date} sebagai perubahan terakhir',
'tours.provenance.reimport': 'Impor sebagai snapshot baru',
'tours.provenance.reimportTitle': 'Impor jalur yang berubah?',
'tours.provenance.reimportBody':
'Jalur ini masuk sebagai tur baru di samping tur ini. Tur ini, hari dan catatannya tetap seperti semula.',
'tours.provenance.reimported': 'Snapshot baru diimpor',
'tours.visibility.hidden': 'Disembunyikan di peta',
'tours.visibility.showAll': 'Tampilkan semua',
'tours.visibility.show': 'Tampilkan di peta',
'tours.visibility.hide': 'Sembunyikan di peta',
};
export default tours;
+42
View File
@@ -222,5 +222,47 @@ const tours: TranslationStrings = {
'Il percorso sale per {length} sulla «{name}». Servono attrezzatura da ferrata ed esperienza.',
'tours.ferrata.crossingShort': 'Il percorso passa per una via ferrata',
'tours.ferrata.waypointAtEnd': 'Inizio o fine di «{name}»',
'tours.provenance.label': 'Origine',
'tours.provenance.source': 'Fonte',
'tours.provenance.importedAt': 'Importato',
'tours.provenance.createdAt': 'Creato',
'tours.provenance.externalUpdated': 'Modificato su wanderer',
'tours.provenance.externalUpdatedHint':
'La data di modifica indicata da wanderer al momento dell’importazione. Un indizio, non la prova di una versione.',
'tours.provenance.localState': 'Qui',
'tours.provenance.plannedOver': 'Ripianificato, traccia registrata conservata',
'tours.provenance.editedLocally': 'Modificato in TREK',
'tours.provenance.asImported': 'Come importato',
'tours.provenance.openSource': 'Apri in wanderer',
'tours.provenance.check': 'Controlla la fonte',
'tours.provenance.checkHint':
'Chiede a wanderer se questo sentiero è cambiato dall’importazione. Nel viaggio non cambia nulla.',
'tours.provenance.state.unchanged': 'Invariato dall’importazione',
'tours.provenance.state.changed': 'Modificato su wanderer',
'tours.provenance.state.unavailable': 'Non più disponibile',
'tours.provenance.state.not_connected': 'Nessuna connessione wanderer',
'tours.provenance.state.other_instance': 'È collegato un altro wanderer',
'tours.provenance.state.unknown': 'Confronto impossibile',
'tours.provenance.body.unavailable':
'Il sentiero non c’è più o non è più leggibile con la tua connessione. Questo tour resta com’era all’importazione.',
'tours.provenance.body.not_connected':
'Collega il tuo wanderer in Impostazioni, Integrazioni per controllare la fonte.',
'tours.provenance.body.other_instance':
'La tua connessione punta a un’istanza diversa da quella da cui proviene questo tour.',
'tours.provenance.body.unknown':
'Questa importazione non contiene nulla da confrontare. Puoi comunque importare il sentiero attuale come nuova istantanea.',
'tours.provenance.change.name': 'Nome',
'tours.provenance.change.description': 'Descrizione',
'tours.provenance.change.route': 'Percorso',
'tours.provenance.nowUpdated': 'wanderer indica {date} come ultima modifica',
'tours.provenance.reimport': 'Importa come nuova istantanea',
'tours.provenance.reimportTitle': 'Importare il sentiero modificato?',
'tours.provenance.reimportBody':
'Arriva come nuovo tour accanto a questo. Questo tour, i suoi giorni e le note restano come sono.',
'tours.provenance.reimported': 'Nuova istantanea importata',
'tours.visibility.hidden': 'Nascosto sulla mappa',
'tours.visibility.showAll': 'Mostra tutti',
'tours.visibility.show': 'Mostra sulla mappa',
'tours.visibility.hide': 'Nascondi sulla mappa',
};
export default tours;
+40
View File
@@ -211,5 +211,45 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'ルートは「{name}」を {length} 通ります。ヴィア・フェラータ用の装備と経験が必要です。',
'tours.ferrata.crossingShort': 'ルートがヴィア・フェラータを通ります',
'tours.ferrata.waypointAtEnd': '「{name}」の始点または終点',
'tours.provenance.label': '出どころ',
'tours.provenance.source': 'ソース',
'tours.provenance.importedAt': 'インポート日',
'tours.provenance.createdAt': '作成日',
'tours.provenance.externalUpdated': 'wanderer での変更',
'tours.provenance.externalUpdatedHint':
'インポート時に wanderer が示した変更日です。手がかりであり、版の証明ではありません。',
'tours.provenance.localState': 'ここ',
'tours.provenance.plannedOver': '計画し直し済み、記録トラックは保持',
'tours.provenance.editedLocally': 'TREK で編集済み',
'tours.provenance.asImported': 'インポート時のまま',
'tours.provenance.openSource': 'wanderer で開く',
'tours.provenance.check': 'ソースを確認',
'tours.provenance.checkHint':
'インポート後にこのトレイルが変わったかを wanderer に問い合わせます。旅行の内容は変わりません。',
'tours.provenance.state.unchanged': 'インポート後の変更なし',
'tours.provenance.state.changed': 'wanderer で変更あり',
'tours.provenance.state.unavailable': '利用できなくなりました',
'tours.provenance.state.not_connected': 'wanderer 未接続',
'tours.provenance.state.other_instance': '別の wanderer に接続中',
'tours.provenance.state.unknown': '比較できません',
'tours.provenance.body.unavailable':
'トレイルが削除されたか、あなたの接続では読めなくなりました。このツアーはインポート時のまま残ります。',
'tours.provenance.body.not_connected': 'ソースを確認するには、設定の連携で wanderer を接続してください。',
'tours.provenance.body.other_instance': 'あなたの接続は、このツアーの取得元とは別のインスタンスを指しています。',
'tours.provenance.body.unknown':
'このインポートには比較できる情報がありません。それでも現在のトレイルを新しいスナップショットとして取り込めます。',
'tours.provenance.change.name': '名前',
'tours.provenance.change.description': '説明',
'tours.provenance.change.route': 'ルート',
'tours.provenance.nowUpdated': 'wanderer によると最終変更は {date}',
'tours.provenance.reimport': '新しいスナップショットとしてインポート',
'tours.provenance.reimportTitle': '変更されたトレイルをインポートしますか?',
'tours.provenance.reimportBody':
'このツアーの隣に新しいツアーとして追加されます。このツアーとその日程やメモはそのままです。',
'tours.provenance.reimported': '新しいスナップショットをインポートしました',
'tours.visibility.hidden': '地図で非表示',
'tours.visibility.showAll': 'すべて表示',
'tours.visibility.show': '地図に表示',
'tours.visibility.hide': '地図で非表示にする',
};
export default tours;
+39
View File
@@ -210,5 +210,44 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': '경로가 “{name}”을(를) {length} 지납니다. 비아 페라타 장비와 경험이 필요합니다.',
'tours.ferrata.crossingShort': '경로가 비아 페라타를 지납니다',
'tours.ferrata.waypointAtEnd': '“{name}”의 시작 또는 끝',
'tours.provenance.label': '출처',
'tours.provenance.source': '소스',
'tours.provenance.importedAt': '가져온 날짜',
'tours.provenance.createdAt': '만든 날짜',
'tours.provenance.externalUpdated': 'wanderer에서 변경됨',
'tours.provenance.externalUpdatedHint':
'가져올 때 wanderer가 알려 준 변경 날짜입니다. 참고일 뿐 버전의 증거는 아닙니다.',
'tours.provenance.localState': '여기',
'tours.provenance.plannedOver': '다시 계획함, 기록된 트랙 보관',
'tours.provenance.editedLocally': 'TREK에서 편집함',
'tours.provenance.asImported': '가져온 그대로',
'tours.provenance.openSource': 'wanderer에서 열기',
'tours.provenance.check': '소스 확인',
'tours.provenance.checkHint':
'가져온 뒤 이 트레일이 바뀌었는지 wanderer에 묻습니다. 여행에는 아무것도 바뀌지 않습니다.',
'tours.provenance.state.unchanged': '가져온 뒤 변경 없음',
'tours.provenance.state.changed': 'wanderer에서 변경됨',
'tours.provenance.state.unavailable': '더 이상 사용할 수 없음',
'tours.provenance.state.not_connected': 'wanderer 연결 없음',
'tours.provenance.state.other_instance': '다른 wanderer가 연결됨',
'tours.provenance.state.unknown': '비교할 수 없음',
'tours.provenance.body.unavailable':
'트레일이 없어졌거나 내 연결로 더 이상 읽을 수 없습니다. 이 투어는 가져온 그대로 남습니다.',
'tours.provenance.body.not_connected': '소스를 확인하려면 설정, 통합에서 wanderer를 연결하세요.',
'tours.provenance.body.other_instance': '내 연결이 이 투어를 가져온 곳과 다른 인스턴스를 가리킵니다.',
'tours.provenance.body.unknown':
'이 가져오기에는 비교할 정보가 없습니다. 그래도 현재 트레일을 새 스냅샷으로 가져올 수 있습니다.',
'tours.provenance.change.name': '이름',
'tours.provenance.change.description': '설명',
'tours.provenance.change.route': '경로',
'tours.provenance.nowUpdated': 'wanderer가 알려 준 마지막 변경: {date}',
'tours.provenance.reimport': '새 스냅샷으로 가져오기',
'tours.provenance.reimportTitle': '변경된 트레일을 가져올까요?',
'tours.provenance.reimportBody': '이 투어 옆에 새 투어로 추가됩니다. 이 투어와 날짜, 메모는 그대로입니다.',
'tours.provenance.reimported': '새 스냅샷을 가져왔습니다',
'tours.visibility.hidden': '지도에서 숨김',
'tours.visibility.showAll': '모두 표시',
'tours.visibility.show': '지도에 표시',
'tours.visibility.hide': '지도에서 숨기기',
};
export default tours;
+42
View File
@@ -221,5 +221,47 @@ const tours: TranslationStrings = {
'De route loopt {length} over „{name}”. Daarvoor heb je via-ferratamateriaal en ervaring nodig.',
'tours.ferrata.crossingShort': 'De route loopt over een via ferrata',
'tours.ferrata.waypointAtEnd': 'Begin of einde van „{name}”',
'tours.provenance.label': 'Herkomst',
'tours.provenance.source': 'Bron',
'tours.provenance.importedAt': 'Geïmporteerd',
'tours.provenance.createdAt': 'Aangemaakt',
'tours.provenance.externalUpdated': 'Gewijzigd op wanderer',
'tours.provenance.externalUpdatedHint':
'De wijzigingsdatum die wanderer bij de import gaf. Een aanwijzing, geen bewijs van een versie.',
'tours.provenance.localState': 'Hier',
'tours.provenance.plannedOver': 'Opnieuw gepland, opgenomen spoor bewaard',
'tours.provenance.editedLocally': 'Bewerkt in TREK',
'tours.provenance.asImported': 'Zoals geïmporteerd',
'tours.provenance.openSource': 'Openen in wanderer',
'tours.provenance.check': 'Bron controleren',
'tours.provenance.checkHint':
'Vraagt wanderer of deze route sinds de import is gewijzigd. In de reis verandert niets.',
'tours.provenance.state.unchanged': 'Ongewijzigd sinds de import',
'tours.provenance.state.changed': 'Gewijzigd op wanderer',
'tours.provenance.state.unavailable': 'Niet meer beschikbaar',
'tours.provenance.state.not_connected': 'Geen wanderer-verbinding',
'tours.provenance.state.other_instance': 'Er is een andere wanderer verbonden',
'tours.provenance.state.unknown': 'Vergelijken niet mogelijk',
'tours.provenance.body.unavailable':
'De route is weg of niet meer leesbaar met je verbinding. Deze tocht blijft zoals hij is geïmporteerd.',
'tours.provenance.body.not_connected':
'Verbind je wanderer onder Instellingen, Integraties om de bron te controleren.',
'tours.provenance.body.other_instance':
'Je verbinding wijst naar een andere instantie dan die waar deze tocht vandaan komt.',
'tours.provenance.body.unknown':
'Deze import bevat niets om mee te vergelijken. Je kunt de huidige route toch als nieuwe momentopname binnenhalen.',
'tours.provenance.change.name': 'Naam',
'tours.provenance.change.description': 'Beschrijving',
'tours.provenance.change.route': 'Traject',
'tours.provenance.nowUpdated': 'wanderer noemt {date} als laatste wijziging',
'tours.provenance.reimport': 'Importeren als nieuwe momentopname',
'tours.provenance.reimportTitle': 'Gewijzigde route importeren?',
'tours.provenance.reimportBody':
'Hij komt als nieuwe tocht naast deze. Deze tocht, zijn dagen en notities blijven zoals ze zijn.',
'tours.provenance.reimported': 'Nieuwe momentopname geïmporteerd',
'tours.visibility.hidden': 'Verborgen op de kaart',
'tours.visibility.showAll': 'Alles tonen',
'tours.visibility.show': 'Tonen op de kaart',
'tours.visibility.hide': 'Verbergen op de kaart',
};
export default tours;
+40
View File
@@ -231,5 +231,45 @@ const tours: TranslationStrings = {
'Trasa prowadzi {length} przez „{name}”. Potrzebny jest sprzęt do via ferraty i doświadczenie.',
'tours.ferrata.crossingShort': 'Trasa prowadzi przez via ferratę',
'tours.ferrata.waypointAtEnd': 'Początek lub koniec „{name}”',
'tours.provenance.label': 'Pochodzenie',
'tours.provenance.source': 'Źródło',
'tours.provenance.importedAt': 'Zaimportowano',
'tours.provenance.createdAt': 'Utworzono',
'tours.provenance.externalUpdated': 'Zmieniono w wanderer',
'tours.provenance.externalUpdatedHint':
'Data zmiany podana przez wanderer przy imporcie. Wskazówka, nie dowód wersji.',
'tours.provenance.localState': 'Tutaj',
'tours.provenance.plannedOver': 'Przeplanowano, nagrany ślad zachowany',
'tours.provenance.editedLocally': 'Edytowano w TREK',
'tours.provenance.asImported': 'Jak zaimportowano',
'tours.provenance.openSource': 'Otwórz w wanderer',
'tours.provenance.check': 'Sprawdź źródło',
'tours.provenance.checkHint': 'Pyta wanderer, czy ten szlak zmienił się od importu. W podróży nic się nie zmienia.',
'tours.provenance.state.unchanged': 'Bez zmian od importu',
'tours.provenance.state.changed': 'Zmieniono w wanderer',
'tours.provenance.state.unavailable': 'Już niedostępne',
'tours.provenance.state.not_connected': 'Brak połączenia z wanderer',
'tours.provenance.state.other_instance': 'Połączono inny wanderer',
'tours.provenance.state.unknown': 'Nie można porównać',
'tours.provenance.body.unavailable':
'Szlak zniknął lub nie da się go już odczytać przez twoje połączenie. Ta trasa zostaje taka, jak ją zaimportowano.',
'tours.provenance.body.not_connected': 'Połącz swój wanderer w Ustawieniach, Integracjach, aby sprawdzić źródło.',
'tours.provenance.body.other_instance':
'Twoje połączenie wskazuje na inną instancję niż ta, z której pochodzi ta trasa.',
'tours.provenance.body.unknown':
'Ten import nie zawiera niczego do porównania. Nadal możesz pobrać bieżący szlak jako nową migawkę.',
'tours.provenance.change.name': 'Nazwa',
'tours.provenance.change.description': 'Opis',
'tours.provenance.change.route': 'Przebieg',
'tours.provenance.nowUpdated': 'wanderer podaje {date} jako ostatnią zmianę',
'tours.provenance.reimport': 'Importuj jako nową migawkę',
'tours.provenance.reimportTitle': 'Zaimportować zmieniony szlak?',
'tours.provenance.reimportBody':
'Pojawi się jako nowa trasa obok tej. Ta trasa, jej dni i notatki zostają bez zmian.',
'tours.provenance.reimported': 'Zaimportowano nową migawkę',
'tours.visibility.hidden': 'Ukryte na mapie',
'tours.visibility.showAll': 'Pokaż wszystkie',
'tours.visibility.show': 'Pokaż na mapie',
'tours.visibility.hide': 'Ukryj na mapie',
};
export default tours;
+42
View File
@@ -232,5 +232,47 @@ const tours: TranslationStrings = {
'Маршрут проходит {length} по «{name}». Для этого нужны снаряжение для виа ферраты и опыт.',
'tours.ferrata.crossingShort': 'Маршрут проходит по виа феррате',
'tours.ferrata.waypointAtEnd': 'Начало или конец «{name}»',
'tours.provenance.label': 'Происхождение',
'tours.provenance.source': 'Источник',
'tours.provenance.importedAt': 'Импортировано',
'tours.provenance.createdAt': 'Создано',
'tours.provenance.externalUpdated': 'Изменено в wanderer',
'tours.provenance.externalUpdatedHint':
'Дата изменения, которую wanderer указал при импорте. Подсказка, а не доказательство версии.',
'tours.provenance.localState': 'Здесь',
'tours.provenance.plannedOver': 'Перепланировано, записанный трек сохранён',
'tours.provenance.editedLocally': 'Изменено в TREK',
'tours.provenance.asImported': 'Как импортировано',
'tours.provenance.openSource': 'Открыть в wanderer',
'tours.provenance.check': 'Проверить источник',
'tours.provenance.checkHint':
'Спрашивает wanderer, изменился ли этот маршрут после импорта. В поездке ничего не меняется.',
'tours.provenance.state.unchanged': 'Без изменений после импорта',
'tours.provenance.state.changed': 'Изменено в wanderer',
'tours.provenance.state.unavailable': 'Больше недоступно',
'tours.provenance.state.not_connected': 'Нет подключения к wanderer',
'tours.provenance.state.other_instance': 'Подключён другой wanderer',
'tours.provenance.state.unknown': 'Сравнить нельзя',
'tours.provenance.body.unavailable':
'Маршрут удалён или больше не читается через ваше подключение. Этот поход остаётся таким, каким был импортирован.',
'tours.provenance.body.not_connected':
'Подключите свой wanderer в разделе Настройки, Интеграции, чтобы проверить источник.',
'tours.provenance.body.other_instance':
'Ваше подключение указывает на другой экземпляр, не тот, откуда пришёл этот поход.',
'tours.provenance.body.unknown':
'В этом импорте не с чем сравнивать. Текущий маршрут всё равно можно забрать как новый снимок.',
'tours.provenance.change.name': 'Название',
'tours.provenance.change.description': 'Описание',
'tours.provenance.change.route': 'Маршрут',
'tours.provenance.nowUpdated': 'wanderer указывает {date} как последнее изменение',
'tours.provenance.reimport': 'Импортировать как новый снимок',
'tours.provenance.reimportTitle': 'Импортировать изменённый маршрут?',
'tours.provenance.reimportBody':
'Он появится как новый поход рядом с этим. Этот поход, его дни и заметки останутся как есть.',
'tours.provenance.reimported': 'Новый снимок импортирован',
'tours.visibility.hidden': 'Скрыто на карте',
'tours.visibility.showAll': 'Показать все',
'tours.visibility.show': 'Показать на карте',
'tours.visibility.hide': 'Скрыть на карте',
};
export default tours;
+38
View File
@@ -221,5 +221,43 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'Trasa vedie {length} po „{name}“. Potrebujete feratovú výstroj a skúsenosti.',
'tours.ferrata.crossingShort': 'Trasa vedie po ferate',
'tours.ferrata.waypointAtEnd': 'Začiatok alebo koniec „{name}“',
'tours.provenance.label': 'Pôvod',
'tours.provenance.source': 'Zdroj',
'tours.provenance.importedAt': 'Importované',
'tours.provenance.createdAt': 'Vytvorené',
'tours.provenance.externalUpdated': 'Zmenené vo wanderer',
'tours.provenance.externalUpdatedHint': 'Dátum zmeny, ktorý wanderer uviedol pri importe. Náznak, nie dôkaz verzie.',
'tours.provenance.localState': 'Tu',
'tours.provenance.plannedOver': 'Preplánované, zaznamenaná stopa zachovaná',
'tours.provenance.editedLocally': 'Upravené v TREK',
'tours.provenance.asImported': 'Ako bolo importované',
'tours.provenance.openSource': 'Otvoriť vo wanderer',
'tours.provenance.check': 'Skontrolovať zdroj',
'tours.provenance.checkHint': 'Opýta sa wanderer, či sa táto trasa od importu zmenila. V ceste sa nič nezmení.',
'tours.provenance.state.unchanged': 'Bez zmeny od importu',
'tours.provenance.state.changed': 'Zmenené vo wanderer',
'tours.provenance.state.unavailable': 'Už nie je dostupné',
'tours.provenance.state.not_connected': 'Žiadne pripojenie k wanderer',
'tours.provenance.state.other_instance': 'Je pripojený iný wanderer',
'tours.provenance.state.unknown': 'Nedá sa porovnať',
'tours.provenance.body.unavailable':
'Trasa zmizla alebo ju vaše pripojenie už neprečíta. Táto túra zostane tak, ako bola importovaná.',
'tours.provenance.body.not_connected': 'Pripojte svoj wanderer v Nastaveniach, Integráciách a skontrolujte zdroj.',
'tours.provenance.body.other_instance':
'Vaše pripojenie smeruje na inú inštanciu než tú, z ktorej táto túra pochádza.',
'tours.provenance.body.unknown':
'Tento import nemá s čím porovnať. Aktuálnu trasu aj tak môžete prevziať ako novú snímku.',
'tours.provenance.change.name': 'Názov',
'tours.provenance.change.description': 'Popis',
'tours.provenance.change.route': 'Priebeh trasy',
'tours.provenance.nowUpdated': 'wanderer uvádza {date} ako poslednú zmenu',
'tours.provenance.reimport': 'Importovať ako novú snímku',
'tours.provenance.reimportTitle': 'Importovať zmenenú trasu?',
'tours.provenance.reimportBody': 'Pribudne ako nová túra vedľa tejto. Táto túra, jej dni a poznámky zostanú, ako sú.',
'tours.provenance.reimported': 'Nová snímka importovaná',
'tours.visibility.hidden': 'Skryté na mape',
'tours.visibility.showAll': 'Zobraziť všetky',
'tours.visibility.show': 'Zobraziť na mape',
'tours.visibility.hide': 'Skryť na mape',
};
export default tours;
+40
View File
@@ -216,5 +216,45 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'Rutten går {length} längs ”{name}”. Det kräver via ferrata-utrustning och erfarenhet.',
'tours.ferrata.crossingShort': 'Rutten går längs en via ferrata',
'tours.ferrata.waypointAtEnd': 'Början eller slutet av ”{name}”',
'tours.provenance.label': 'Ursprung',
'tours.provenance.source': 'Källa',
'tours.provenance.importedAt': 'Importerad',
'tours.provenance.createdAt': 'Skapad',
'tours.provenance.externalUpdated': 'Ändrad i wanderer',
'tours.provenance.externalUpdatedHint':
'Ändringsdatumet som wanderer angav vid importen. En ledtråd, inte ett bevis på en version.',
'tours.provenance.localState': 'Här',
'tours.provenance.plannedOver': 'Omplanerad, inspelat spår sparat',
'tours.provenance.editedLocally': 'Redigerad i TREK',
'tours.provenance.asImported': 'Som importerad',
'tours.provenance.openSource': 'Öppna i wanderer',
'tours.provenance.check': 'Kontrollera källan',
'tours.provenance.checkHint': 'Frågar wanderer om leden har ändrats sedan importen. Inget i resan ändras.',
'tours.provenance.state.unchanged': 'Oförändrad sedan importen',
'tours.provenance.state.changed': 'Ändrad i wanderer',
'tours.provenance.state.unavailable': 'Inte längre tillgänglig',
'tours.provenance.state.not_connected': 'Ingen wanderer-anslutning',
'tours.provenance.state.other_instance': 'En annan wanderer är ansluten',
'tours.provenance.state.unknown': 'Kan inte jämföra',
'tours.provenance.body.unavailable':
'Leden finns inte längre eller kan inte läsas med din anslutning. Turen förblir som den importerades.',
'tours.provenance.body.not_connected':
'Anslut din wanderer under Inställningar, Integrationer för att kontrollera källan.',
'tours.provenance.body.other_instance': 'Din anslutning pekar på en annan instans än den som turen kom från.',
'tours.provenance.body.unknown':
'Den här importen har inget att jämföra med. Du kan ändå hämta in den aktuella leden som en ny ögonblicksbild.',
'tours.provenance.change.name': 'Namn',
'tours.provenance.change.description': 'Beskrivning',
'tours.provenance.change.route': 'Sträckning',
'tours.provenance.nowUpdated': 'wanderer anger {date} som senaste ändring',
'tours.provenance.reimport': 'Importera som ny ögonblicksbild',
'tours.provenance.reimportTitle': 'Importera den ändrade leden?',
'tours.provenance.reimportBody':
'Den kommer in som en ny tur bredvid den här. Den här turen, dess dagar och anteckningar förblir som de är.',
'tours.provenance.reimported': 'Ny ögonblicksbild importerad',
'tours.visibility.hidden': 'Dold på kartan',
'tours.visibility.showAll': 'Visa alla',
'tours.visibility.show': 'Visa på kartan',
'tours.visibility.hide': 'Dölj på kartan',
};
export default tours;
+38
View File
@@ -207,5 +207,43 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'เส้นทางผ่าน “{name}” เป็นระยะ {length} ต้องใช้อุปกรณ์เวียเฟอร์ราตาและประสบการณ์',
'tours.ferrata.crossingShort': 'เส้นทางผ่านเวียเฟอร์ราตา',
'tours.ferrata.waypointAtEnd': 'จุดเริ่มหรือจุดสิ้นสุดของ “{name}”',
'tours.provenance.label': 'ที่มา',
'tours.provenance.source': 'แหล่งข้อมูล',
'tours.provenance.importedAt': 'นำเข้าเมื่อ',
'tours.provenance.createdAt': 'สร้างเมื่อ',
'tours.provenance.externalUpdated': 'มีการเปลี่ยนใน wanderer',
'tours.provenance.externalUpdatedHint':
'วันที่เปลี่ยนแปลงที่ wanderer แจ้งตอนนำเข้า เป็นเพียงข้อบ่งชี้ ไม่ใช่หลักฐานของเวอร์ชัน',
'tours.provenance.localState': 'ที่นี่',
'tours.provenance.plannedOver': 'วางแผนใหม่แล้ว เก็บเส้นทางที่บันทึกไว้',
'tours.provenance.editedLocally': 'แก้ไขใน TREK แล้ว',
'tours.provenance.asImported': 'เหมือนตอนนำเข้า',
'tours.provenance.openSource': 'เปิดใน wanderer',
'tours.provenance.check': 'ตรวจสอบแหล่งข้อมูล',
'tours.provenance.checkHint': 'ถาม wanderer ว่าเส้นทางนี้เปลี่ยนไปหรือไม่ตั้งแต่นำเข้า ไม่มีอะไรในทริปเปลี่ยนแปลง',
'tours.provenance.state.unchanged': 'ไม่เปลี่ยนตั้งแต่นำเข้า',
'tours.provenance.state.changed': 'มีการเปลี่ยนใน wanderer',
'tours.provenance.state.unavailable': 'ไม่มีให้ใช้แล้ว',
'tours.provenance.state.not_connected': 'ยังไม่ได้เชื่อมต่อ wanderer',
'tours.provenance.state.other_instance': 'เชื่อมต่อ wanderer อื่นอยู่',
'tours.provenance.state.unknown': 'เปรียบเทียบไม่ได้',
'tours.provenance.body.unavailable':
'เส้นทางถูกลบหรือการเชื่อมต่อของคุณอ่านไม่ได้แล้ว ทัวร์นี้จะคงอยู่ตามที่นำเข้ามา',
'tours.provenance.body.not_connected': 'เชื่อมต่อ wanderer ของคุณที่การตั้งค่า การผสานรวม เพื่อตรวจสอบแหล่งข้อมูล',
'tours.provenance.body.other_instance': 'การเชื่อมต่อของคุณชี้ไปยังอินสแตนซ์อื่นที่ไม่ใช่ที่มาของทัวร์นี้',
'tours.provenance.body.unknown':
'การนำเข้านี้ไม่มีข้อมูลให้เปรียบเทียบ แต่คุณยังนำเส้นทางปัจจุบันเข้ามาเป็นสแนปช็อตใหม่ได้',
'tours.provenance.change.name': 'ชื่อ',
'tours.provenance.change.description': 'คำอธิบาย',
'tours.provenance.change.route': 'แนวเส้นทาง',
'tours.provenance.nowUpdated': 'wanderer ระบุว่าเปลี่ยนล่าสุดเมื่อ {date}',
'tours.provenance.reimport': 'นำเข้าเป็นสแนปช็อตใหม่',
'tours.provenance.reimportTitle': 'นำเข้าเส้นทางที่เปลี่ยนแล้วหรือไม่',
'tours.provenance.reimportBody': 'จะเพิ่มเป็นทัวร์ใหม่ข้างทัวร์นี้ ทัวร์นี้ วัน และบันทึกของมันจะคงเดิม',
'tours.provenance.reimported': 'นำเข้าสแนปช็อตใหม่แล้ว',
'tours.visibility.hidden': 'ซ่อนบนแผนที่',
'tours.visibility.showAll': 'แสดงทั้งหมด',
'tours.visibility.show': 'แสดงบนแผนที่',
'tours.visibility.hide': 'ซ่อนบนแผนที่',
};
export default tours;
+2 -2
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 196;
const GENERAL_KEY_COUNT = 231;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
@@ -34,7 +34,7 @@ function literalKeys(source: string): string[] {
}
describe('Tours locale contracts', () => {
it('covers all 27 supported locales with 196 general source keys', () => {
it('covers all 27 supported locales with 231 general source keys', () => {
expect(Object.keys(domains)).toHaveLength(27);
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
+40
View File
@@ -218,5 +218,45 @@ const tours: TranslationStrings = {
'Rota {length} boyunca “{name}” üzerinden geçiyor. Bunun için via ferrata ekipmanı ve deneyim gerekir.',
'tours.ferrata.crossingShort': 'Rota bir via ferrata üzerinden geçiyor',
'tours.ferrata.waypointAtEnd': '“{name}” başlangıcı veya sonu',
'tours.provenance.label': 'Köken',
'tours.provenance.source': 'Kaynak',
'tours.provenance.importedAt': 'İçe aktarıldı',
'tours.provenance.createdAt': 'Oluşturuldu',
'tours.provenance.externalUpdated': 'wanderer üzerinde değişti',
'tours.provenance.externalUpdatedHint':
'wanderer’ın içe aktarma sırasında verdiği değişiklik tarihi. Bir ipucu, sürüm kanıtı değil.',
'tours.provenance.localState': 'Burada',
'tours.provenance.plannedOver': 'Yeniden planlandı, kayıtlı iz korundu',
'tours.provenance.editedLocally': 'TREK’te düzenlendi',
'tours.provenance.asImported': 'İçe aktarıldığı gibi',
'tours.provenance.openSource': 'wanderer’da aç',
'tours.provenance.check': 'Kaynağı denetle',
'tours.provenance.checkHint':
'wanderer’a bu parkurun içe aktarmadan beri değişip değişmediğini sorar. Seyahatte hiçbir şey değişmez.',
'tours.provenance.state.unchanged': 'İçe aktarmadan beri değişmedi',
'tours.provenance.state.changed': 'wanderer üzerinde değişti',
'tours.provenance.state.unavailable': 'Artık mevcut değil',
'tours.provenance.state.not_connected': 'wanderer bağlantısı yok',
'tours.provenance.state.other_instance': 'Başka bir wanderer bağlı',
'tours.provenance.state.unknown': 'Karşılaştırılamıyor',
'tours.provenance.body.unavailable':
'Parkur kaldırılmış ya da bağlantınla artık okunamıyor. Bu tur içe aktarıldığı gibi kalır.',
'tours.provenance.body.not_connected': 'Kaynağı denetlemek için wanderer’ını Ayarlar, Entegrasyonlar altında bağla.',
'tours.provenance.body.other_instance': 'Bağlantın, bu turun geldiğinden farklı bir sunucuyu gösteriyor.',
'tours.provenance.body.unknown':
'Bu içe aktarmada karşılaştırılacak bir şey yok. Güncel parkuru yine de yeni bir anlık görüntü olarak alabilirsin.',
'tours.provenance.change.name': 'Ad',
'tours.provenance.change.description': 'Açıklama',
'tours.provenance.change.route': 'Güzergâh',
'tours.provenance.nowUpdated': 'wanderer son değişiklik olarak {date} tarihini veriyor',
'tours.provenance.reimport': 'Yeni anlık görüntü olarak içe aktar',
'tours.provenance.reimportTitle': 'Değişen parkur içe aktarılsın mı?',
'tours.provenance.reimportBody':
'Bunun yanına yeni bir tur olarak gelir. Bu tur, günleri ve notları olduğu gibi kalır.',
'tours.provenance.reimported': 'Yeni anlık görüntü içe aktarıldı',
'tours.visibility.hidden': 'Haritada gizli',
'tours.visibility.showAll': 'Tümünü göster',
'tours.visibility.show': 'Haritada göster',
'tours.visibility.hide': 'Haritada gizle',
};
export default tours;
+42
View File
@@ -229,5 +229,47 @@ const tours: TranslationStrings = {
'Маршрут проходить {length} по «{name}». Для цього потрібні спорядження для віа ферати й досвід.',
'tours.ferrata.crossingShort': 'Маршрут проходить по віа фераті',
'tours.ferrata.waypointAtEnd': 'Початок або кінець «{name}»',
'tours.provenance.label': 'Походження',
'tours.provenance.source': 'Джерело',
'tours.provenance.importedAt': 'Імпортовано',
'tours.provenance.createdAt': 'Створено',
'tours.provenance.externalUpdated': 'Змінено у wanderer',
'tours.provenance.externalUpdatedHint':
'Дата зміни, яку wanderer вказав під час імпорту. Підказка, а не доказ версії.',
'tours.provenance.localState': 'Тут',
'tours.provenance.plannedOver': 'Перепланковано, записаний трек збережено',
'tours.provenance.editedLocally': 'Змінено в TREK',
'tours.provenance.asImported': 'Як імпортовано',
'tours.provenance.openSource': 'Відкрити у wanderer',
'tours.provenance.check': 'Перевірити джерело',
'tours.provenance.checkHint':
'Питає wanderer, чи змінився цей маршрут після імпорту. У подорожі нічого не змінюється.',
'tours.provenance.state.unchanged': 'Без змін після імпорту',
'tours.provenance.state.changed': 'Змінено у wanderer',
'tours.provenance.state.unavailable': 'Більше недоступно',
'tours.provenance.state.not_connected': 'Немає підключення до wanderer',
'tours.provenance.state.other_instance': 'Підключено інший wanderer',
'tours.provenance.state.unknown': 'Порівняти неможливо',
'tours.provenance.body.unavailable':
'Маршрут зник або більше не читається через ваше підключення. Цей похід лишається таким, яким його імпортовано.',
'tours.provenance.body.not_connected':
'Підключіть свій wanderer у розділі Налаштування, Інтеграції, щоб перевірити джерело.',
'tours.provenance.body.other_instance':
'Ваше підключення вказує на інший екземпляр, не той, звідки прийшов цей похід.',
'tours.provenance.body.unknown':
'У цьому імпорті немає з чим порівнювати. Поточний маршрут однаково можна забрати як новий знімок.',
'tours.provenance.change.name': 'Назва',
'tours.provenance.change.description': 'Опис',
'tours.provenance.change.route': 'Траса',
'tours.provenance.nowUpdated': 'wanderer вказує {date} як останню зміну',
'tours.provenance.reimport': 'Імпортувати як новий знімок',
'tours.provenance.reimportTitle': 'Імпортувати змінений маршрут?',
'tours.provenance.reimportBody':
'Він з’явиться як новий похід поруч із цим. Цей похід, його дні та нотатки лишаються як є.',
'tours.provenance.reimported': 'Новий знімок імпортовано',
'tours.visibility.hidden': 'Приховано на мапі',
'tours.visibility.showAll': 'Показати всі',
'tours.visibility.show': 'Показати на мапі',
'tours.visibility.hide': 'Приховати на мапі',
};
export default tours;
+41
View File
@@ -216,5 +216,46 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': 'Tuyến đi qua “{name}” dài {length}. Cần trang bị via ferrata và kinh nghiệm.',
'tours.ferrata.crossingShort': 'Tuyến đi qua một via ferrata',
'tours.ferrata.waypointAtEnd': 'Điểm đầu hoặc cuối của “{name}”',
'tours.provenance.label': 'Nguồn gốc',
'tours.provenance.source': 'Nguồn',
'tours.provenance.importedAt': 'Đã nhập',
'tours.provenance.createdAt': 'Đã tạo',
'tours.provenance.externalUpdated': 'Đã đổi trên wanderer',
'tours.provenance.externalUpdatedHint':
'Ngày thay đổi mà wanderer cho biết khi nhập. Chỉ là gợi ý, không phải bằng chứng phiên bản.',
'tours.provenance.localState': 'Tại đây',
'tours.provenance.plannedOver': 'Đã lên kế hoạch lại, giữ vết đã ghi',
'tours.provenance.editedLocally': 'Đã sửa trong TREK',
'tours.provenance.asImported': 'Như khi nhập',
'tours.provenance.openSource': 'Mở trong wanderer',
'tours.provenance.check': 'Kiểm tra nguồn',
'tours.provenance.checkHint':
'Hỏi wanderer xem tuyến này có thay đổi từ khi nhập không. Không có gì trong chuyến đi thay đổi.',
'tours.provenance.state.unchanged': 'Không đổi từ khi nhập',
'tours.provenance.state.changed': 'Đã đổi trên wanderer',
'tours.provenance.state.unavailable': 'Không còn khả dụng',
'tours.provenance.state.not_connected': 'Chưa kết nối wanderer',
'tours.provenance.state.other_instance': 'Đang kết nối một wanderer khác',
'tours.provenance.state.unknown': 'Không thể so sánh',
'tours.provenance.body.unavailable':
'Tuyến đã bị xóa hoặc kết nối của bạn không còn đọc được. Chuyến đi bộ này giữ nguyên như khi nhập.',
'tours.provenance.body.not_connected': 'Hãy kết nối wanderer của bạn trong Cài đặt, Tích hợp để kiểm tra nguồn.',
'tours.provenance.body.other_instance':
'Kết nối của bạn trỏ tới một máy chủ khác với nơi chuyến đi bộ này xuất phát.',
'tours.provenance.body.unknown':
'Lần nhập này không có gì để so sánh. Bạn vẫn có thể lấy tuyến hiện tại thành một bản chụp mới.',
'tours.provenance.change.name': 'Tên',
'tours.provenance.change.description': 'Mô tả',
'tours.provenance.change.route': 'Lộ trình',
'tours.provenance.nowUpdated': 'wanderer cho biết lần đổi cuối là {date}',
'tours.provenance.reimport': 'Nhập thành bản chụp mới',
'tours.provenance.reimportTitle': 'Nhập tuyến đã thay đổi?',
'tours.provenance.reimportBody':
'Tuyến sẽ thành một chuyến đi bộ mới bên cạnh chuyến này. Chuyến này, các ngày và ghi chú của nó giữ nguyên.',
'tours.provenance.reimported': 'Đã nhập bản chụp mới',
'tours.visibility.hidden': 'Đã ẩn trên bản đồ',
'tours.visibility.showAll': 'Hiện tất cả',
'tours.visibility.show': 'Hiện trên bản đồ',
'tours.visibility.hide': 'Ẩn trên bản đồ',
};
export default tours;
+35
View File
@@ -201,5 +201,40 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': '路線經過「{name}」{length}。需要岩釘路線裝備與經驗。',
'tours.ferrata.crossingShort': '路線經過岩釘路線',
'tours.ferrata.waypointAtEnd': '「{name}」的起點或終點',
'tours.provenance.label': '來源',
'tours.provenance.source': '出處',
'tours.provenance.importedAt': '匯入時間',
'tours.provenance.createdAt': '建立時間',
'tours.provenance.externalUpdated': '在 wanderer 上已變更',
'tours.provenance.externalUpdatedHint': 'wanderer 在匯入時提供的變更日期。只是提示,不能證明版本。',
'tours.provenance.localState': '這裡',
'tours.provenance.plannedOver': '已重新規劃,記錄的軌跡已保留',
'tours.provenance.editedLocally': '已在 TREK 中編輯',
'tours.provenance.asImported': '與匯入時相同',
'tours.provenance.openSource': '在 wanderer 中開啟',
'tours.provenance.check': '檢查出處',
'tours.provenance.checkHint': '詢問 wanderer 自匯入以來這條路線是否有變化。旅程中不會有任何改變。',
'tours.provenance.state.unchanged': '匯入後未變更',
'tours.provenance.state.changed': '在 wanderer 上已變更',
'tours.provenance.state.unavailable': '已無法使用',
'tours.provenance.state.not_connected': '未連線 wanderer',
'tours.provenance.state.other_instance': '已連線另一個 wanderer',
'tours.provenance.state.unknown': '無法比較',
'tours.provenance.body.unavailable': '這條路線已刪除,或你的連線已無法讀取。此遊程維持匯入時的樣子。',
'tours.provenance.body.not_connected': '請在設定的整合中連線你的 wanderer 以檢查出處。',
'tours.provenance.body.other_instance': '你的連線指向的執行個體與此遊程的出處不同。',
'tours.provenance.body.unknown': '這次匯入沒有可比較的內容。你仍可將目前路線作為新快照匯入。',
'tours.provenance.change.name': '名稱',
'tours.provenance.change.description': '說明',
'tours.provenance.change.route': '路線走向',
'tours.provenance.nowUpdated': 'wanderer 顯示最後變更於 {date}',
'tours.provenance.reimport': '作為新快照匯入',
'tours.provenance.reimportTitle': '要匯入已變更的路線嗎?',
'tours.provenance.reimportBody': '它會作為新遊程出現在此遊程旁邊。此遊程及其日程與筆記保持不變。',
'tours.provenance.reimported': '已匯入新快照',
'tours.visibility.hidden': '已在地圖上隱藏',
'tours.visibility.showAll': '全部顯示',
'tours.visibility.show': '在地圖上顯示',
'tours.visibility.hide': '在地圖上隱藏',
};
export default tours;
+35
View File
@@ -202,5 +202,40 @@ const tours: TranslationStrings = {
'tours.ferrata.crossingInfo': '路线经过“{name}”{length}。需要飞拉达装备和经验。',
'tours.ferrata.crossingShort': '路线经过飞拉达',
'tours.ferrata.waypointAtEnd': '“{name}”的起点或终点',
'tours.provenance.label': '来源',
'tours.provenance.source': '源',
'tours.provenance.importedAt': '导入时间',
'tours.provenance.createdAt': '创建时间',
'tours.provenance.externalUpdated': '在 wanderer 上已更改',
'tours.provenance.externalUpdatedHint': 'wanderer 在导入时给出的更改日期。只是提示,不能证明版本。',
'tours.provenance.localState': '此处',
'tours.provenance.plannedOver': '已重新规划,记录的轨迹已保留',
'tours.provenance.editedLocally': '已在 TREK 中编辑',
'tours.provenance.asImported': '与导入时相同',
'tours.provenance.openSource': '在 wanderer 中打开',
'tours.provenance.check': '检查来源',
'tours.provenance.checkHint': '询问 wanderer 自导入以来这条路线是否有变化。行程中不会有任何改变。',
'tours.provenance.state.unchanged': '导入后未更改',
'tours.provenance.state.changed': '在 wanderer 上已更改',
'tours.provenance.state.unavailable': '已不可用',
'tours.provenance.state.not_connected': '未连接 wanderer',
'tours.provenance.state.other_instance': '已连接另一个 wanderer',
'tours.provenance.state.unknown': '无法比较',
'tours.provenance.body.unavailable': '该路线已删除,或你的连接已无法读取。此路线保持导入时的样子。',
'tours.provenance.body.not_connected': '请在设置的集成中连接你的 wanderer 以检查来源。',
'tours.provenance.body.other_instance': '你的连接指向的实例与此路线的来源不同。',
'tours.provenance.body.unknown': '此次导入没有可比较的内容。你仍然可以将当前路线作为新快照导入。',
'tours.provenance.change.name': '名称',
'tours.provenance.change.description': '描述',
'tours.provenance.change.route': '路线走向',
'tours.provenance.nowUpdated': 'wanderer 显示最后更改于 {date}',
'tours.provenance.reimport': '作为新快照导入',
'tours.provenance.reimportTitle': '导入已更改的路线?',
'tours.provenance.reimportBody': '它会作为新路线出现在此路线旁边。此路线及其日程和备注保持不变。',
'tours.provenance.reimported': '已导入新快照',
'tours.visibility.hidden': '已在地图上隐藏',
'tours.visibility.showAll': '全部显示',
'tours.visibility.show': '在地图上显示',
'tours.visibility.hide': '在地图上隐藏',
};
export default tours;
+1
View File
@@ -49,6 +49,7 @@ export * from './place/place-website';
export * from './roadtrip/roadtrip.schema';
export * from './place/track-colors';
export * from './tours/tours.schema';
export * from './tours/tour-provenance.schema';
export * from './tours/tours.metrics';
export * from './wanderer/wanderer.schema';
export * from './geo/haversine';
@@ -0,0 +1,111 @@
import {
tourOriginSchema,
tourProvenanceSchema,
tourSourceCheckResponseSchema,
type TourProvenance,
} from './tour-provenance.schema';
import { tourCreateResponseSchema, tourListItemSchema } from './tours.schema';
import { describe, expect, it } from 'vitest';
const planned: TourProvenance = {
origin: 'trek',
createdAt: '2026-10-15 10:00:00',
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
};
describe('tour provenance contract (P2C-02)', () => {
it('knows the three ways a tour comes in, and nothing else', () => {
expect(tourOriginSchema.options).toEqual(['trek', 'gpx', 'wanderer']);
expect(tourOriginSchema.safeParse('komoot').success).toBe(false);
});
it('holds a planned tour without inventing a source for it', () => {
expect(tourProvenanceSchema.parse(planned)).toEqual(planned);
expect(
tourProvenanceSchema.safeParse({ ...planned, source: { kind: 'trek', fileName: 'x.gpx' } }).data?.source,
).toEqual({
kind: 'trek',
});
});
it('keeps what each source carries apart: a GPX file name, a wanderer identity', () => {
const gpx = { ...planned, origin: 'gpx', source: { kind: 'gpx', fileName: null }, plannedOver: true };
expect(tourProvenanceSchema.parse(gpx).source).toEqual({ kind: 'gpx', fileName: null });
const wanderer = {
...planned,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://trails.example',
externalId: 'abc123',
externalUrl: null,
externalUpdated: '2026-10-01 08:00:00Z',
importedAt: null,
},
editedLocally: true,
};
expect(tourProvenanceSchema.parse(wanderer).source).toMatchObject({ kind: 'wanderer', externalId: 'abc123' });
// A wanderer source without its identity is no wanderer source.
expect(tourProvenanceSchema.safeParse({ ...wanderer, source: { kind: 'wanderer' } }).success).toBe(false);
});
it('names the import a tour was planned from by id and name', () => {
const derived = { ...planned, derivedFrom: { placeId: 7, name: 'Ridge (GPX)' } };
expect(tourProvenanceSchema.parse(derived).derivedFrom).toEqual({ placeId: 7, name: 'Ridge (GPX)' });
expect(tourProvenanceSchema.safeParse({ ...planned, derivedFrom: { placeId: 0, name: 'x' } }).success).toBe(false);
});
it('is optional on the list and the detail, so a cache from before it still reads', () => {
const item = {
place_id: 1,
tour_type: 'hike',
distance: null,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
name: 'Ridge',
planned: false,
caution: false,
};
expect(tourListItemSchema.parse(item).origin).toBeUndefined();
expect(tourListItemSchema.parse({ ...item, origin: 'gpx' }).origin).toBe('gpx');
expect(tourCreateResponseSchema.parse({ tour: item, waypoints: [] }).provenance).toBeUndefined();
expect(tourCreateResponseSchema.parse({ tour: item, waypoints: [], provenance: planned }).provenance).toEqual(
planned,
);
});
});
describe('source check answer (MF-03)', () => {
it('says what changed and carries what a confirmed reimport sends', () => {
const answer = {
state: 'changed',
changes: ['name', 'route'],
externalUpdated: '2026-10-14 12:00:00Z',
importedUpdated: '2026-10-01 08:00:00Z',
editedLocally: false,
reimport: { trailId: 'abc123', contentHash: 'h.h.h' },
};
expect(tourSourceCheckResponseSchema.parse(answer)).toEqual(answer);
});
it('refuses a state or a change it does not know', () => {
const base = { changes: [], externalUpdated: null, importedUpdated: null, editedLocally: null, reimport: null };
for (const state of ['unchanged', 'unavailable', 'not_connected', 'other_instance', 'unknown']) {
expect(tourSourceCheckResponseSchema.safeParse({ ...base, state }).success).toBe(true);
}
expect(tourSourceCheckResponseSchema.safeParse({ ...base, state: 'synced' }).success).toBe(false);
expect(tourSourceCheckResponseSchema.safeParse({ ...base, state: 'changed', changes: ['author'] }).success).toBe(
false,
);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { z } from 'zod';
/**
* Which way a tour came into TREK. Recorded when it is created, never read off
* its shape: an import planned over in place keeps its origin.
*/
export const tourOriginSchema = z.enum(['trek', 'gpx', 'wanderer']);
export type TourOrigin = z.infer<typeof tourOriginSchema>;
/** What the import itself carried, one shape per kind of source. */
export const tourProvenanceSourceSchema = z.discriminatedUnion('kind', [
/** Planned in TREK: no outside source to name. */
z.object({ kind: z.literal('trek') }),
z.object({
kind: z.literal('gpx'),
/** The file the track was read from; null for an import from before TREK kept it. */
fileName: z.string().nullable(),
}),
z.object({
kind: z.literal('wanderer'),
/** The instance's origin, `https://host`. */
instance: z.string(),
/** The trail's id there. */
externalId: z.string(),
/** The trail's page there; null when the import's record is missing. */
externalUrl: z.string().nullable(),
/** wanderer's own change time of the trail at the import, as it said it; not a version. */
externalUpdated: z.string().nullable(),
/** When this snapshot came in; null when the import's record is missing. */
importedAt: z.string().nullable(),
}),
]);
export type TourProvenanceSource = z.infer<typeof tourProvenanceSourceSchema>;
/**
* Where a tour came from, kept apart from what was done to it since. The
* general part holds for every tour; `source` holds what only its kind of
* source carries. The tour's `website` is a link its people keep, not part of
* this.
*/
export const tourProvenanceSchema = z.object({
origin: tourOriginSchema,
/** When the tour was first saved in this trip: the import, or the first save of a planned tour. */
createdAt: z.string().nullable(),
source: tourProvenanceSourceSchema,
/** The import this tour was planned from, while that import is still in the trip. */
derivedFrom: z
.object({
placeId: z.number().int().positive(),
name: z.string(),
})
.nullable(),
/** The import was planned over in place and still keeps the track it was recorded with. */
plannedOver: z.boolean(),
/**
* Whether the tour's name or line differs from how the import saved it.
* Null where TREK has nothing to compare against: a planned tour, a GPX
* import, a snapshot whose import record is missing.
*/
editedLocally: z.boolean().nullable(),
});
export type TourProvenance = z.infer<typeof tourProvenanceSchema>;
/**
* What asking a wanderer snapshot's source for changes found. The check reads
* only, it changes nothing in the trip.
*
* - `unchanged` / `changed`: the trail was read and compared with this snapshot.
* - `unavailable`: the trail is gone or not readable with the user's connection
* any more; the snapshot stays as it is.
* - `not_connected`: the user has no wanderer connection to ask with.
* - `other_instance`: the user's connection points at another instance than
* the one the snapshot came from.
* - `unknown`: the snapshot carries nothing to compare against.
*/
export const tourSourceCheckStateSchema = z.enum([
'unchanged',
'changed',
'unavailable',
'not_connected',
'other_instance',
'unknown',
]);
export type TourSourceCheckState = z.infer<typeof tourSourceCheckStateSchema>;
export const tourSourceCheckResponseSchema = z.object({
state: tourSourceCheckStateSchema,
/** What differs between the trail now and this snapshot. */
changes: z.array(z.enum(['name', 'description', 'route'])),
/** wanderer's change time of the trail now, as it says it; only a hint, never the proof. */
externalUpdated: z.string().nullable(),
/** The change time it said at the import. */
importedUpdated: z.string().nullable(),
/** The tour was changed in TREK since the import; null when there is nothing to compare. */
editedLocally: z.boolean().nullable(),
/** What a confirmed reimport sends to the import route; null unless the trail was read. */
reimport: z
.object({
trailId: z.string(),
contentHash: z.string(),
})
.nullable(),
});
export type TourSourceCheckResponse = z.infer<typeof tourSourceCheckResponseSchema>;
+5
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
/**
* Tours contracts — the generalisation of "hike" into a domain of self-contained,
@@ -106,6 +107,8 @@ export const tourListItemSchema = tourSchema.extend({
has_waypoints: z.boolean().optional(),
/** An import planned over in place still keeps the track it was recorded with, and can go back to it. */
has_recorded_track: z.boolean().optional(),
/** Which way the tour came in; absent only in a list cached before TREK recorded it. */
origin: tourOriginSchema.optional(),
});
export type TourListItem = z.infer<typeof tourListItemSchema>;
@@ -236,6 +239,8 @@ export type TourCreateRequest = z.infer<typeof tourCreateRequestSchema>;
export const tourCreateResponseSchema = z.object({
tour: tourListItemSchema,
waypoints: z.array(tourWaypointSchema),
/** Where the tour came from (P2C-02); absent only in a detail cached before TREK sent it. */
provenance: tourProvenanceSchema.optional(),
});
export type TourCreateResponse = z.infer<typeof tourCreateResponseSchema>;