feat(tours): complete planner follow-up scope

This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
This commit is contained in:
elmocito
2026-10-09 13:52:11 +02:00
committed by Maurice
parent af9f013c5e
commit 57ea219a43
56 changed files with 4315 additions and 462 deletions
+25 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
@@ -622,6 +623,7 @@ export function MapViewGL({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
@@ -708,6 +710,9 @@ export function MapViewGL({
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
const plannerWaypointsDraggable = !!onPlannerWaypointMove
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
@@ -744,6 +749,7 @@ export function MapViewGL({
if (!map || !mapReady) return
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
const dragCleanups: (() => void)[] = []
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId
@@ -754,18 +760,34 @@ export function MapViewGL({
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
const swallow = (event: Event) => event.stopPropagation()
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
const select = (event: Event) => { event.stopPropagation(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
const pin = attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)
plannerWaypointPinsRef.current.push(pin)
if (plannerWaypointsDraggable) {
el.style.cursor = 'grab'
dragCleanups.push(wirePlannerWaypointDrag({
element: el,
initial: { lat: point.lat, lng: point.lng },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([clientX - rect.left, clientY - rect.top])
return { lat: at.lat, lng: at.lng }
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) => plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
}))
}
}
return () => {
dragCleanups.forEach(cleanup => cleanup())
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
}
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
}, [gl, mapReady, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId])
useEffect(() => () => {
profileFocusPinRef.current?.remove()