mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user