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: [] });
},
}));