From dbee1664af489e1414efabfabee86f3199d51ff8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 09:11:47 +0200 Subject: [PATCH] fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev --- .../components/Map/MapView.leaflet.test.tsx | 35 +- client/src/components/Map/MapView.tsx | 4 +- client/src/components/Map/MapViewGL.tsx | 6 +- .../src/components/Planner/LocationSelect.tsx | 91 ++- .../src/components/Tours/TourDayRowFacts.tsx | 5 +- .../src/components/Tours/TourDetailDialog.tsx | 54 +- .../Tours/planner/TourPlannerPanels.tsx | 582 +++++++++--------- .../Tours/planner/useTourPlanner.test.tsx | 8 +- .../Tours/planner/useTourPlanner.ts | 57 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 8 +- .../tripPlanner/useMapLocationSearch.test.ts | 56 ++ .../pages/tripPlanner/useMapLocationSearch.ts | 43 ++ .../pages/tripPlanner/useTripPlannerPage.ts | 31 +- server/scripts/format-baseline.json | 2 - 14 files changed, 588 insertions(+), 394 deletions(-) create mode 100644 client/src/pages/tripPlanner/useMapLocationSearch.test.ts create mode 100644 client/src/pages/tripPlanner/useMapLocationSearch.ts diff --git a/client/src/components/Map/MapView.leaflet.test.tsx b/client/src/components/Map/MapView.leaflet.test.tsx index fa691f3c6..f142464bd 100644 --- a/client/src/components/Map/MapView.leaflet.test.tsx +++ b/client/src/components/Map/MapView.leaflet.test.tsx @@ -94,6 +94,16 @@ function clickOn(el: Element) { }) } +/** The Leaflet marker that renders `el`; throws when the map has none. */ +function markerOf(map: L.Map, el: Element): L.Marker { + let found: L.Marker | null = null + map.eachLayer(layer => { + if (layer instanceof L.Marker && layer.getElement() === el) found = layer + }) + if (!found) throw new Error('no marker renders this element') + return found +} + function pointer(type: string, x: number, y: number): Event { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperties(event, { @@ -149,11 +159,7 @@ describe('MapView on a real Leaflet map', () => { const map = maps[maps.length - 1] await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull()) const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement - let marker: L.Marker | null = null - map.eachLayer(layer => { - if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer - }) - expect(marker).not.toBeNull() + const marker = markerOf(map, pin) const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng]) const end = start.add([30, 20]) const expected = map.containerPointToLatLng(end) @@ -164,8 +170,8 @@ describe('MapView on a real Leaflet map', () => { pin.dispatchEvent(mouse('mousedown', start.x, start.y)) pin.dispatchEvent(pointer('pointermove', end.x, end.y)) }) - expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6) - expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6) + expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6) + expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6) expect(onMove).not.toHaveBeenCalled() expect(waypoints[0]).toEqual(waypoint) @@ -184,8 +190,8 @@ describe('MapView on a real Leaflet map', () => { routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }} /> ) - expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6) - expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6) + expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6) + expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6) act(() => { pin.dispatchEvent(pointer('pointerup', end.x, end.y)) @@ -238,10 +244,7 @@ describe('MapView on a real Leaflet map', () => { const map = maps[maps.length - 1] await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull()) const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement - let marker: L.Marker | null = null - map.eachLayer(layer => { - if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer - }) + const marker = markerOf(map, pin) const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng]) const end = start.add([30, 20]) @@ -250,15 +253,15 @@ describe('MapView on a real Leaflet map', () => { pin.dispatchEvent(pointer('pointermove', end.x, end.y)) pin.dispatchEvent(pointer('pointercancel', end.x, end.y)) }) - expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6) - expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6) + expect(marker.getLatLng().lat).toBeCloseTo(waypoint.lat, 6) + expect(marker.getLatLng().lng).toBeCloseTo(waypoint.lng, 6) expect(onMove).not.toHaveBeenCalled() clickOn(pin) expect(onSelect).toHaveBeenCalledOnce() expect(onSelect).toHaveBeenCalledWith(waypoint.id) expect(onMapClick).not.toHaveBeenCalled() - act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) })) + act(() => { map.fire('click', { latlng: L.latLng(34.7, 135.85) }) }) expect(onMapClick).toHaveBeenCalledOnce() }) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 8758f883e..41362a9ea 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react' import DOM from 'react-dom' import { renderIconMarkup } from '../../utils/iconMarkup' import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet' @@ -784,7 +784,7 @@ function PlannerWaypointMarker({ const map = useMap() const [marker, setMarker] = useState(null) const handlersRef = useRef({ onClick, onMove }) - handlersRef.current = { onClick, onMove } + useLayoutEffect(() => { handlersRef.current = { onClick, onMove } }, [onClick, onMove]) const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]) const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected]) const eventHandlers = useMemo(() => ({ diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 31fe7e33b..2c9ef77f1 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react' +import { useEffect, useLayoutEffect, useRef, useMemo, useState, createElement, useCallback } from 'react' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import { useStableVias } from './viaMarkerState' import { wirePlannerWaypointDrag } from './plannerWaypointDrag' @@ -711,7 +711,9 @@ export function MapViewGL({ const viaPinsRef = useRef([]) const plannerWaypointPinsRef = useRef([]) const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove }) - plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove } + useLayoutEffect(() => { + plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove } + }, [onPlannerWaypointClick, onPlannerWaypointMove]) const plannerWaypointsDraggable = !!onPlannerWaypointMove const profileFocusPinRef = useRef(null) /** The drive-time pills on the offered routes; same treatment. */ diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 6dd5fd717..502e0343c 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -117,24 +117,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel // than written into `results`, so a search answer that lands after the field was // shortened cannot replace them, and a place already picked does not reopen them. const typed = query.trim() - const showPicks = !value && typed.length < 3 && !!places?.length - const rows = showPicks - ? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) - : results - const showNoResults = - showSearchStatus && !showPicks && searchedQuery === typed && rows.length === 0 && !loading && !searchError - const showError = showSearchStatus && searchError && searchedQuery === typed - const listOpen = open && ((loading && !showPicks) || rows.length > 0 || showNoResults || showError) + const { showPicks, rows } = pickRows(value, typed, places, results) + const { showNoResults, showError, listOpen } = searchStatus({ + showSearchStatus, showPicks, searchedQuery, typed, rowCount: rows.length, loading, searchError, open, + }) const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation') - const statusLine = showError ? ( -
- {t('common.error')} -
- ) : showNoResults ? ( -
- {t('help.center.searchEmpty', { query: typed })} -
- ) : null + const statusLine = renderStatusLine(showError, showNoResults, typed, t) const onKeyDown = (e: React.KeyboardEvent) => { // Escape closes the list even while it only shows a status line. @@ -156,14 +144,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel onOpen={() => setOpen(true)} onKeyDown={onKeyDown} onClear={value ? clear : undefined} - inputAria={showSearchStatus ? { - role: 'combobox', - 'aria-label': accessibleLabel, - 'aria-autocomplete': 'list', - 'aria-expanded': listOpen, - 'aria-controls': listOpen ? listId : undefined, - 'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined, - } : undefined} + inputAria={showSearchStatus ? comboboxAria(accessibleLabel, listOpen, listId, highlight) : undefined} /> {open && ( @@ -190,3 +171,63 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel ) } + +function pickRows(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) { + const showPicks = !value && typed.length < 3 && !!places?.length + const rows: (T | LocationPoint)[] = showPicks + ? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) + : results + return { showPicks, rows } +} + +interface SearchStatusInput { + showSearchStatus: boolean + showPicks: boolean + searchedQuery: string | null + typed: string + rowCount: number + loading: boolean + searchError: boolean + open: boolean +} + +function searchStatus({ showSearchStatus, showPicks, searchedQuery, typed, rowCount, loading, searchError, open }: SearchStatusInput) { + const showNoResults = + showSearchStatus && !showPicks && searchedQuery === typed && rowCount === 0 && !loading && !searchError + const showError = showSearchStatus && searchError && searchedQuery === typed + const listOpen = open && ((loading && !showPicks) || rowCount > 0 || showNoResults || showError) + return { showNoResults, showError, listOpen } +} + +function renderStatusLine( + showError: boolean, + showNoResults: boolean, + typed: string, + t: ReturnType['t'], +) { + return showError ? ( +
+ {t('common.error')} +
+ ) : showNoResults ? ( +
+ {t('help.center.searchEmpty', { query: typed })} +
+ ) : null +} + +function comboboxAria( + accessibleLabel: string, + listOpen: boolean, + listId: string, + highlight: number, +): React.AriaAttributes & { role?: React.AriaRole } { + return { + role: 'combobox', + 'aria-label': accessibleLabel, + 'aria-autocomplete': 'list', + 'aria-expanded': listOpen, + 'aria-controls': listOpen ? listId : undefined, + 'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined, + } +} diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index fa8c1cc5c..4c6d32245 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -1,6 +1,6 @@ import type { TourListItem } from '@trek/shared'; import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react'; -import { useId, useState } from 'react'; +import { createElement, useId, useState } from 'react'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { formatDistance, formatElevation } from '../../utils/units'; @@ -10,7 +10,6 @@ import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation' export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) { const { t } = useTranslation(); - const Icon = tourDayRowIcon(type); const known = ['hike', 'bike', 'walk', 'kayak'].includes(type); return ( - ); } diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 59e9f55a1..557e157dd 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -137,7 +137,6 @@ export default function TourDetailDialog({ const navigationTargets = getNavigationTargets(place) const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation') const source = tourSource(tour) - const tourTimes = tourPlannedTimes(tour) const description = tour.description !== undefined ? tour.description : place.description const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website) const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto') @@ -220,27 +219,7 @@ export default function TourDetailDialog({
}>{t(`tourTypes.${tour.tour_type}`)} {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} - {tourTimes.walkingMinutes != null && ( - - - {t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} - - - )} - {tourTimes.breakMinutes != null && ( - - - {t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} - - - )} - {tourTimes.plannedTotalMinutes != null && ( - - - {t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} - - - )} + {hikeSourceBadgeLabel(tour, t)} {tour.caution && ( @@ -396,6 +375,37 @@ export default function TourDetailDialog({ ) } +/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */ +function TourTimePills({ tour }: { tour: TourListItem }) { + const { t } = useTranslation() + const tourTimes = tourPlannedTimes(tour) + return ( + <> + {tourTimes.walkingMinutes != null && ( + + + {t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + + + )} + {tourTimes.breakMinutes != null && ( + + + {t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} + + + )} + {tourTimes.plannedTotalMinutes != null && ( + + + {t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} + + + )} + + ) +} + /** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */ function MarkdownBox({ children }: { children: string }) { return ( diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index e5215d603..ef9432d4d 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -92,8 +92,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a const distanceUnit = useSettingsStore(state => state.settings.distance_unit) const [confirmDiscard, setConfirmDiscard] = useState(false) const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null) - const [draggedWaypointId, setDraggedWaypointId] = useState(null); - const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null); const alpineAcknowledged = useRef(false) const isSaving = planner.isSaving const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status] @@ -103,6 +101,10 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a if (planner.hasUnsavedChanges) setConfirmDiscard(true) else planner.returnToNeutral() } + const changeDifficulty = (value: 1 | 2 | 3 | 4 | 5 | 6) => { + if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value) + else planner.setMaxHikingDifficulty(value) + } if (planner.mode.type === 'neutral') { return ( @@ -169,7 +171,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a } const isSavedEdit = planner.mode.type === 'edit-saved' - const lastIndex = planner.waypoints.length - 1 return ( <> @@ -192,279 +193,9 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a }>{t('tours.planner.restored')} )} -
-
- - { if (canEdit && !isSaving) planner.setName(event.target.value) }} - maxLength={255} - placeholder={t('tours.planner.namePlaceholder')} - className={INPUT} - /> -
-
- -