From 3d9095ccdeb638025309083091dd31f116af96f2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 15 Oct 2026 21:51:22 +0200 Subject: [PATCH] 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 --- client/scripts/size-baseline.json | 2 +- client/scripts/theme-baseline.json | 2 +- client/src/api/wanderer.ts | 4 + client/src/components/Map/MapView.tsx | 28 +- client/src/components/Map/MapViewGL.tsx | 32 +- client/src/components/Map/trackColors.ts | 3 + .../src/components/Tours/TourDetailDialog.tsx | 12 +- client/src/components/Tours/TourListRow.tsx | 39 ++- client/src/components/Tours/ToursSidebar.tsx | 52 +++- .../Tours/planner/TourPlannerPanels.tsx | 6 +- .../provenance/TourProvenanceSection.tsx | 287 ++++++++++++++++++ .../Tours/provenance/useSourceCheck.ts | 80 +++++ .../Tours/provenance/useTourProvenance.ts | 39 +++ client/src/components/Tours/tourParts.tsx | 20 +- .../src/components/Tours/tourPresentation.ts | 10 +- .../components/Tours/useTourDayAssignment.ts | 70 +++++ .../components/Tours/useTourMapVisibility.ts | 40 +++ client/src/db/offlineDb.ts | 11 +- .../trip/sheets/MBrowseActionsSheet.tsx | 23 +- client/src/pages/TripPlannerPage.tsx | 6 +- .../src/pages/tripPlanner/useDayPlanEdits.ts | 3 +- .../pages/tripPlanner/useMapPlaces.test.ts | 22 +- client/src/pages/tripPlanner/useMapPlaces.ts | 12 +- .../src/pages/tripPlanner/useTourWorkspace.ts | 26 +- .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- .../pages/tripPlanner/useTripPlannerPage.ts | 3 +- client/src/repo/tourRepo.ts | 11 +- client/src/repo/wandererRepo.ts | 5 + client/src/store/tourMapVisibilityStore.ts | 67 ++++ server/scripts/size-baseline.json | 2 +- server/src/db/entities/Tours.entity.ts | 14 +- server/src/db/kysely/Tours.table.ts | 2 + ...43800_a_tour_records_where_it_came_from.ts | 27 ++ .../src/db/repositories/Tours.repository.ts | 112 ++++++- .../nest/tours/tours-wanderer.controller.ts | 16 +- .../src/nest/tours/tours-wanderer.service.ts | 102 ++++--- server/src/nest/tours/tours.helpers.ts | 34 +++ server/src/nest/tours/tours.service.ts | 87 +++++- server/src/nest/trips/trips.service.ts | 9 +- server/tests/e2e/tours.e2e.test.ts | 46 +++ server/tests/e2e/wanderer.e2e.test.ts | 112 +++++++ server/tests/unit/db/known-migrations.test.ts | 1 + server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Tours.repository.test.ts | 88 ++++++ .../unit/db/tour-origin-migration.test.ts | 90 ++++++ shared/src/i18n/ar/tours.ts | 37 +++ shared/src/i18n/az/tours.ts | 40 +++ shared/src/i18n/br/tours.ts | 40 +++ shared/src/i18n/ca/tours.ts | 41 +++ shared/src/i18n/cs/tours.ts | 38 +++ shared/src/i18n/de/tours.ts | 42 +++ shared/src/i18n/en/tours.ts | 41 +++ shared/src/i18n/es/tours.ts | 40 +++ shared/src/i18n/et/tours.ts | 40 +++ shared/src/i18n/fr/tours.ts | 42 +++ shared/src/i18n/gr/tours.ts | 42 +++ shared/src/i18n/hu/tours.ts | 41 +++ shared/src/i18n/id/tours.ts | 40 +++ shared/src/i18n/it/tours.ts | 42 +++ shared/src/i18n/ja/tours.ts | 40 +++ shared/src/i18n/ko/tours.ts | 39 +++ shared/src/i18n/nl/tours.ts | 42 +++ shared/src/i18n/pl/tours.ts | 40 +++ shared/src/i18n/ru/tours.ts | 42 +++ shared/src/i18n/sk/tours.ts | 38 +++ shared/src/i18n/sv/tours.ts | 40 +++ shared/src/i18n/th/tours.ts | 38 +++ shared/src/i18n/tours-locales.spec.ts | 4 +- shared/src/i18n/tr/tours.ts | 40 +++ shared/src/i18n/uk/tours.ts | 42 +++ shared/src/i18n/vi/tours.ts | 41 +++ shared/src/i18n/zh-TW/tours.ts | 35 +++ shared/src/i18n/zh/tours.ts | 35 +++ shared/src/index.ts | 1 + .../src/tours/tour-provenance.schema.test.ts | 111 +++++++ shared/src/tours/tour-provenance.schema.ts | 104 +++++++ shared/src/tours/tours.schema.ts | 5 + 77 files changed, 2845 insertions(+), 118 deletions(-) create mode 100644 client/src/components/Tours/provenance/TourProvenanceSection.tsx create mode 100644 client/src/components/Tours/provenance/useSourceCheck.ts create mode 100644 client/src/components/Tours/provenance/useTourProvenance.ts create mode 100644 client/src/components/Tours/useTourDayAssignment.ts create mode 100644 client/src/components/Tours/useTourMapVisibility.ts create mode 100644 client/src/store/tourMapVisibilityStore.ts create mode 100644 server/src/db/migrations/Migration20200101043800_a_tour_records_where_it_came_from.ts create mode 100644 server/tests/unit/db/tour-origin-migration.test.ts create mode 100644 shared/src/tours/tour-provenance.schema.test.ts create mode 100644 shared/src/tours/tour-provenance.schema.ts diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index df97d6b3b..e7a876d95 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -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, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 503ed104e..8542a7ee9 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -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, diff --git a/client/src/api/wanderer.ts b/client/src/api/wanderer.ts index 1317e9854..5d0a15f08 100644 --- a/client/src/api/wanderer.ts +++ b/client/src/api/wanderer.ts @@ -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 => 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 => + apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data), }; /** The admin's switch for wanderer under the Tours addon. */ diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 55a66c108..ee214d860 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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 && ( + <> + + + + ) + 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 */} {gpxPolylines} + {selectedTrackLine} {routeProfileFocus && ( { + 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. diff --git a/client/src/components/Map/trackColors.ts b/client/src/components/Map/trackColors.ts index bca982d81..e42d41615 100644 --- a/client/src/components/Map/trackColors.ts +++ b/client/src/components/Map/trackColors.ts @@ -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 diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 17565f605..11c2cbd23 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -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) => Promise | void; onFileUpload?: (formData: FormData) => Promise; + /** A wanderer snapshot's source check brought the changed trail in as a new tour (MF-03). */ + onSourceReimported?: (tour: TourListItem) => Promise | 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 && ( { - if (canAssign) onAssignToDay(place.id); - }} + onClick={() => void dayAssignment.assign(place.id, selectedDayId)} + disabled={!dayAssignment.isAvailable(place.id, selectedDayId)} icon={} > {t('inspector.addToDay')} @@ -582,6 +587,7 @@ export default function TourDetailDialog({ )} + ); diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index b22db93df..328ac2667 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -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 }) { + const label = toggle.hidden ? toggle.showLabel : toggle.hideLabel + return ( + + + + ) } /** @@ -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, }} >
+ {trackColor && ( + // The line's colour on the map, as a plan row shows its category. +
)} + {visibility.hiddenCount > 0 && ( +
+ + {t('tours.visibility.hidden')} + {visibility.hiddenCount} + +
+ )}
@@ -125,7 +154,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel {t('tours.empty.body')}

} /> ) : ( -
    +
      {filtered.map(tour => ( 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={ handleAssign(tour, day)} />} + action={} /> ))}
    diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index db622a428..1db7879d5 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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> = { @@ -510,7 +511,7 @@ interface TourPlannerToursRailProps extends TourPermissionProps { tours: TourListItem[] days: Day[] loading: boolean - onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise + 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(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 && ( diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.tsx new file mode 100644 index 000000000..c13069502 --- /dev/null +++ b/client/src/components/Tours/provenance/TourProvenanceSection.tsx @@ -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; + +/** 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 ( +
    + + {icon} + + + {label} + +
    + {children} +
    +
    + ); +} + +function SourceValue({ provenance, t }: { provenance: TourProvenance; t: Translate }) { + const { source } = provenance; + if (source.kind === 'gpx') { + return ( + <> + {t('tours.badge.gpx')} + {source.fileName && ( + + {source.fileName} + + )} + + ); + } + if (source.kind === 'wanderer') { + return ( + <> + {t('tours.badge.wanderer')} + + {hostOf(source.instance)} + + {source.externalUrl && ( + + + + + + )} + + ); + } + return {t('tours.badge.planned')}; +} + +/** 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( + + {t('tours.provenance.plannedOver')} + + ); + } + if (provenance.editedLocally === true) { + pills.push( + + {t('tours.provenance.editedLocally')} + + ); + } else if (provenance.editedLocally === false && !provenance.plannedOver) { + pills.push( + + {t('tours.provenance.asImported')} + + ); + } + return pills.length ? <>{pills} : null; +} + +const STATE_TONE: Record = { + 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 ( +
    +
    + {t(`tours.provenance.state.${result.state}`)} + {result.changes.map((part) => ( + {t(`tours.provenance.change.${part}`)} + ))} +
    + {body && ( +

    + {body} +

    + )} + {nowUpdated && ( +

    + {t('tours.provenance.nowUpdated', { date: nowUpdated })} +

    + )} + {check.canReimport && ( +
    + + ) : ( + + ) + } + onClick={check.askReimport} + disabled={check.reimporting} + > + {t('tours.provenance.reimport')} + +
    + )} +
    + ); +} + +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 = ; + const sourceIcon = + source.kind === 'gpx' ? ( + + ) : source.kind === 'wanderer' ? ( + + ) : ( + + ); + + return ( + + + + ) : ( + + ) + } + onClick={() => void check.check()} + disabled={check.checking} + > + {t('tours.provenance.check')} + + + + ) + } + > +
    + + + + {importedAt && ( + } + label={t(imported ? 'tours.provenance.importedAt' : 'tours.provenance.createdAt')} + > + {importedAt} + + )} + {externalUpdated && ( + } label={t('tours.provenance.externalUpdated')}> + {externalUpdated} + + + )} + {imported && (provenance.plannedOver || provenance.editedLocally !== null) && ( + } label={t('tours.provenance.localState')}> + {local} + + )} + {check && } +
    + {check && ( + void check.reimport()} + title={t('tours.provenance.reimportTitle')} + message={t('tours.provenance.reimportBody')} + confirmLabel={t('tours.provenance.reimport')} + danger={false} + /> + )} +
    + ); +} + +/** + * 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; +}) { + 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 ; +} diff --git a/client/src/components/Tours/provenance/useSourceCheck.ts b/client/src/components/Tours/provenance/useSourceCheck.ts new file mode 100644 index 000000000..052752237 --- /dev/null +++ b/client/src/components/Tours/provenance/useSourceCheck.ts @@ -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; +} + +/** + * 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(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; diff --git a/client/src/components/Tours/provenance/useTourProvenance.ts b/client/src/components/Tours/provenance/useTourProvenance.ts new file mode 100644 index 000000000..315c6191f --- /dev/null +++ b/client/src/components/Tours/provenance/useTourProvenance.ts @@ -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; +} diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index e6f87a59b..09fbc8204 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -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) => { diff --git a/client/src/components/Tours/tourPresentation.ts b/client/src/components/Tours/tourPresentation.ts index ddd218157..71d558aab 100644 --- a/client/src/components/Tours/tourPresentation.ts +++ b/client/src/components/Tours/tourPresentation.ts @@ -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 * A wanderer reference is authoritative. Planner-authored TREK tours persist * routing controls, while GPX imports intentionally remain geometry-only. */ -export function tourSource(tour: Pick): TourSource { +export function tourSource(tour: Pick): 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, + tour: Pick, t: Translate, ): string { return t(`tours.badge.${tourSource(tour)}`) diff --git a/client/src/components/Tours/useTourDayAssignment.ts b/client/src/components/Tours/useTourDayAssignment.ts new file mode 100644 index 000000000..168a0ec1a --- /dev/null +++ b/client/src/components/Tours/useTourDayAssignment.ts @@ -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; + +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>(() => new Set()); + // Read before the state has rendered: two clicks in one frame still send once. + const inFlight = useRef(new Set()); + + 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 => { + 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; diff --git a/client/src/components/Tours/useTourMapVisibility.ts b/client/src/components/Tours/useTourMapVisibility.ts new file mode 100644 index 000000000..30e40a439 --- /dev/null +++ b/client/src/components/Tours/useTourMapVisibility.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAuthStore } from '../../store/authStore'; +import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'; + +const NONE: ReadonlySet = 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) { + 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; diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 360b6a1ab..53ae0809a 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -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 } : {}), + }; })); }); } diff --git a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx index 620ba04b3..86220d80c 100644 --- a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx @@ -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()) + // 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