refactor(tours): hold the planner to the client's warning baseline

The last rounds left the client a few warnings over its baseline. Opening a
saved tour reads its editable fields through openedTourFields instead of
spelling out each default in the handler; the status badge's words come from
statusLabelOf; the hooks that adopt or refuse a revision name it in their
dependencies; the hiking preferences' ref is kept by the setters alone, as
the limit's is; the paddle notes' pane is made before the first line instead
of through a state set in an effect; the tour workspace is back to its
length; and a test reads the walking time without a non-null assertion.

A shaping-point test follows the route choices handed to the router (MF-08).
This commit is contained in:
Maurice
2026-10-19 13:07:19 +02:00
parent ad86713fdc
commit a20b682087
7 changed files with 69 additions and 51 deletions
@@ -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;
@@ -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 (
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
@@ -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;
@@ -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 });
});
@@ -418,7 +418,8 @@ describe('useTourPlanner route tools', () => {
expect(routeWalkingTour).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
expect.any(AbortSignal),
2
2,
null
);
});
@@ -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])
@@ -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),
};
}