mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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)]' : ''}`}
|
||||
>
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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 } : {}) }
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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: [] });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user