diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx index 92a64f0ce..1c14700cf 100644 --- a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx @@ -1,5 +1,5 @@ import L from 'leaflet'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'; import { useTranslation } from '../../../i18n'; import { @@ -21,23 +21,25 @@ const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const; */ const LINE_PANE = 'trek-paddle-lines'; -/** The line pane, made once per map; undefined where the renderer has no panes, which then stacks by order. */ +/** Makes the line pane on a map that has none yet; undefined where the renderer has no panes, which then stacks by order. */ +function ensureLinePane(map: L.Map): string | undefined { + if (typeof map.getPane !== 'function' || typeof map.createPane !== 'function') return undefined; + if (!map.getPane(LINE_PANE)) { + const pane = map.createPane(LINE_PANE); + pane.style.zIndex = '397'; + // The app's stylesheet levels every pane (`.leaflet-pane { z-index: 0 !important }`), so + // the order in the map pane decides: right before the overlay pane, under the route. + const overlay = map.getPane('overlayPane'); + if (overlay?.parentElement) overlay.parentElement.insertBefore(pane, overlay); + } + return LINE_PANE; +} + +/** The line pane, made once per map before the first line is drawn into it. */ function useLinePane(): string | undefined { const map = useMap(); - const [ready, setReady] = useState(false); - useEffect(() => { - if (typeof map.getPane !== 'function' || typeof map.createPane !== 'function') return; - if (!map.getPane(LINE_PANE)) { - const pane = map.createPane(LINE_PANE); - pane.style.zIndex = '397'; - // The app's stylesheet levels every pane (`.leaflet-pane { z-index: 0 !important }`), so - // the order in the map pane decides: right before the overlay pane, under the route. - const overlay = map.getPane('overlayPane'); - if (overlay?.parentElement) overlay.parentElement.insertBefore(pane, overlay); - } - setReady(true); - }, [map]); - return ready ? LINE_PANE : undefined; + const [pane] = useState(() => ensureLinePane(map)); + return pane; } const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const; diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 8065b88f2..e2d0fd51e 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -141,6 +141,13 @@ function statusText(planner: TourPlannerController, key: string, t: Translate): * a restored draft, routing or saving, and a failure with its retry. Nothing * when there is nothing to say. */ +/** The status badge's words: a failed save (or the conflict that refused it) first, else what the editor is doing. */ +function statusLabelOf(planner: TourPlannerController, statusKey: string | null | undefined, t: Translate) { + if (planner.error === 'conflict') return t('tours.revision.conflictTitle') + if (planner.error === 'save') return t('tours.planner.status.saveFailed') + return statusKey ? statusText(planner, statusKey, t) : null +} + function RailStatusBadges({ planner }: { planner: TourPlannerController }) { const { t } = useTranslation() const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status] @@ -150,9 +157,7 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) { const canRetry = retryable(planner) if (!restored && !statusKey && !failed && !onFerrata) return null const warning = planner.error != null || canRetry - const statusLabel = failed - ? t(planner.error === 'conflict' ? 'tours.revision.conflictTitle' : 'tours.planner.status.saveFailed') - : statusKey ? statusText(planner, statusKey, t) : null + const statusLabel = statusLabelOf(planner, statusKey, t) const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation' return ( diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index 6c867709a..88ab2bdc0 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -6,11 +6,26 @@ import { type PaddleLakeStyle, type PaddleWaterContext, type PlannableTourType, + type TourListItem, type TourMaxHikingDifficulty, } from '@trek/shared'; import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry'; import { hikingTimeMinutes } from '../hikingTime'; +/** The editable fields of a saved tour as the planner opens them, each with its empty value. */ +export function openedTourFields(tour: TourListItem) { + return { + name: tour.name, + description: tour.description ?? '', + website: tour.website ?? '', + plannedDurationMinutes: tour.planned_duration_minutes ?? null, + breakAdditionalMinutes: tour.break_additional_minutes ?? null, + difficulty: tour.max_hiking_difficulty ?? 2, + preferences: tour.hiking_preferences ?? null, + tourType: (tour.tour_type === 'kayak' ? 'kayak' : 'hike') as PlannableTourType, + }; +} + /** What the planner's facts are read from: the draft's fields and the route as last computed. */ export interface TourPlannerFactsInput { tourType: PlannableTourType; diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts index a732ab2bc..76e51af00 100644 --- a/client/src/components/Tours/planner/tourPlannerParts.test.ts +++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts @@ -26,9 +26,10 @@ describe('tourPlannerFacts', () => { const facts = tourPlannerFacts( hike({ routeAnalysis: analysis, durationSeconds: 3_600, breakAdditionalMinutes: 30 }) ); - expect(facts.walkingDurationMinutes).toBeGreaterThan(60); - expect(facts.plannedTotalMinutes).toBe(facts.walkingDurationMinutes! + 30); - expect(facts.savedDurationSeconds).toBe(facts.walkingDurationMinutes! * 60); + const walking = facts.walkingDurationMinutes ?? 0; + expect(walking).toBeGreaterThan(60); + expect(facts.plannedTotalMinutes).toBe(walking + 30); + expect(facts.savedDurationSeconds).toBe(walking * 60); expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx index eb1f3a427..dbb8e1962 100644 --- a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx @@ -418,7 +418,8 @@ describe('useTourPlanner route tools', () => { expect(routeWalkingTour).toHaveBeenLastCalledWith( expect.arrayContaining([expect.objectContaining({ shaping: true })]), expect.any(AbortSignal), - 2 + 2, + null ); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index fea4a8c07..88cdae021 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -7,7 +7,7 @@ import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/rou import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting' import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions' import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints' -import { tourPlannerFacts } from './tourPlannerFacts' +import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts' import { usePlannerHistory } from './usePlannerHistory' import { useTourDraftStorage } from './useTourDraftStorage' import { useTourRevisionGuard } from './useTourRevisionGuard' @@ -168,7 +168,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const tourTypeRef = useRef(tourType) waypointsRef.current = waypoints difficultyRef.current = maxHikingDifficulty - preferencesRef.current = hikingPreferences const snapshot = useCallback((): HistoryEntry => ({ waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current, tourType: tourTypeRef.current }), []) useEffect(() => { @@ -517,7 +516,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setElevationProfileExpanded(true) setNewDraftGeneration(generation => generation + 1) setMapFocusKey(key => key + 1) - }, [clearHistory, invalidateDetailRequest, interruptRouting, loadPaddle, forgetDraft]) + }, [adoptRevision, clearHistory, invalidateDetailRequest, interruptRouting, loadPaddle, forgetDraft]) const forgetDeletedTour = useCallback((placeId: number) => { deletedTourPlaceIdsRef.current.add(placeId) @@ -581,7 +580,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setHasUnsavedChanges(true) return true }, - [loadPaddle, returnToNeutral]) + [adoptRevision, loadPaddle, returnToNeutral]) const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), []) const toggleElevationProfile = useCallback(() => { @@ -639,25 +638,25 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe } interruptRouting() - const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike' - const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), openedType === 'kayak') + const opened = openedTourFields(result.tour) + const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), opened.tourType === 'kayak') forgetDraft() contextGenerationRef.current += 1 draftRevisionRef.current += 1 - setNameState(result.tour.name) - setDescriptionState(result.tour.description ?? '') - setWebsiteState(result.tour.website ?? '') - setPlannedDurationMinutesState(result.tour.planned_duration_minutes ?? null) - setBreakAdditionalMinutesState(result.tour.break_additional_minutes ?? null) + setNameState(opened.name) + setDescriptionState(opened.description) + setWebsiteState(opened.website) + setPlannedDurationMinutesState(opened.plannedDurationMinutes) + setBreakAdditionalMinutesState(opened.breakAdditionalMinutes) setWaypoints(nextWaypoints) - setMaxHikingDifficultyState(result.tour.max_hiking_difficulty ?? 2) - setTourTypeState(openedType) + setMaxHikingDifficultyState(opened.difficulty) + setTourTypeState(opened.tourType) loadPaddle(paddleFieldsOf(result.tour)) waypointsRef.current = nextWaypoints - difficultyRef.current = result.tour.max_hiking_difficulty ?? 2 - preferencesRef.current = result.tour.hiking_preferences ?? null - setHikingPreferencesState(preferencesRef.current) - tourTypeRef.current = openedType + difficultyRef.current = opened.difficulty + preferencesRef.current = opened.preferences + setHikingPreferencesState(opened.preferences) + tourTypeRef.current = opened.tourType setSelectedWaypointId(nextWaypoints[0]?.id ?? null) clearHistory() setStatus('dirty') @@ -687,7 +686,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setOpeningTourId(null) } } - }, [clearHistory, interruptRouting, loadPaddle, forgetDraft, tripId]) + }, [adoptRevision, clearHistory, interruptRouting, loadPaddle, forgetDraft, tripId]) const retry = useCallback(() => { if (waypointsRef.current.length < 2) return @@ -909,7 +908,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setIsSaving(false) } } - }, [clearHistory, breakAdditionalMinutes, canSave, + }, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, breakAdditionalMinutes, canSave, derivedFromPlaceId, description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tripId, typeFields, waypoints, website]) diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts index 8eb79efe8..8eef7fdab 100644 --- a/client/src/pages/tripPlanner/useTourWorkspace.ts +++ b/client/src/pages/tripPlanner/useTourWorkspace.ts @@ -53,12 +53,9 @@ export function useTourWorkspace( }, }); - // P2C-03: someone else saved the tour open in the editor; the trip's tours follow every save live. - useTourRevisionWatch(tourPlanner, tours); - - // A tour of the trip opened in the planner: an import has no waypoints of its own and - // shows as it came in, a planned tour opens in the editor. Never over an edit it would - // replace. + useTourRevisionWatch(tourPlanner, tours); // P2C-03: a save made elsewhere reaches the open editor + // A tour of the trip opened in the planner: an import (no waypoints of its own) shows as it + // came in, a planned tour opens in the editor. Never over an edit it would replace. const { hasUnsavedChanges, viewGpxTour, openTour } = tourPlanner; /** An import shown as it came in, over whatever the planner shows; the caller asked before discarding. */ const viewImportedTour = useCallback( @@ -148,8 +145,6 @@ export function useTourWorkspace( onSaved: takeInSavedTour, }); - const tourDetailActions = useTourDetailActions(planner); - return { tourPlanner, viewImportedTour, @@ -159,6 +154,6 @@ export function useTourWorkspace( recordedTrack, trackPaddlePlan, paddleChecklist, - tourDetailActions, + tourDetailActions: useTourDetailActions(planner), }; }