From 1d7c8a63e29b293032cd0ace9249bdcd3be64c42 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 18:21:46 +0200 Subject: [PATCH] refactor(tours): hold the planner's routing choices in a hook of their own The hike's limit and preferences, the bike profile and the run profile were four states with four refs kept in step by hand in every handler that set them. useRoutingChoices holds them as one value with one ref, so a history step, an undo, a new draft and an opened tour each lay them in with one call, and useTourPlanner is back under the size limit. --- .../Tours/planner/useRoutingChoices.ts | 44 +++++++ .../Tours/planner/useTourPlanner.ts | 108 ++++++------------ 2 files changed, 77 insertions(+), 75 deletions(-) create mode 100644 client/src/components/Tours/planner/useRoutingChoices.ts diff --git a/client/src/components/Tours/planner/useRoutingChoices.ts b/client/src/components/Tours/planner/useRoutingChoices.ts new file mode 100644 index 000000000..6a1d0a13d --- /dev/null +++ b/client/src/components/Tours/planner/useRoutingChoices.ts @@ -0,0 +1,44 @@ +import type { BikeProfile, HikingPreferences, RunProfile, TourMaxHikingDifficulty } from '@trek/shared'; +import { useCallback, useRef, useState } from 'react'; + +/** + * What the route is planned with besides its points and type: a hike's limit + * and its preferences (MF-08), a bike tour's profile (BIK-01) and a run's + * (RUN-01). A change to any of them plans the route again. + */ +export interface RoutingChoices { + difficulty: TourMaxHikingDifficulty; + preferences: HikingPreferences | null; + bikeProfile: BikeProfile; + runProfile: RunProfile; +} + +/** The choices of a new draft. */ +export const DEFAULT_ROUTING_CHOICES: RoutingChoices = { + difficulty: 2, + preferences: null, + bikeProfile: 'touring', + runProfile: 'running', +}; + +/** + * The planner's routing choices, held as one value with a ref that is current + * at once, so a history step taken in the same handler reads what was just + * chosen. A choice a restored draft lacks starts at its default. `load` lays + * in the choices given and keeps the others; it is no step of the history, + * the planner records that around it. + */ +export function useRoutingChoices(initial: Partial) { + const [choices, setChoices] = useState(() => ({ + difficulty: initial.difficulty ?? DEFAULT_ROUTING_CHOICES.difficulty, + preferences: initial.preferences ?? DEFAULT_ROUTING_CHOICES.preferences, + bikeProfile: initial.bikeProfile ?? DEFAULT_ROUTING_CHOICES.bikeProfile, + runProfile: initial.runProfile ?? DEFAULT_ROUTING_CHOICES.runProfile, + })); + const ref = useRef(choices); + const load = useCallback((next: Partial) => { + ref.current = { ...ref.current, ...next }; + setChoices(ref.current); + }, []); + return { choices, ref, load }; +} diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 84e14812c..b521d9676 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -17,6 +17,7 @@ import { useRouteShaping } from './useRouteShaping' import { useLegVariants } from './useLegVariants' import { useRoundHikes } from './useRoundHikes' import { useTourMapView } from './useTourMapView' +import { DEFAULT_ROUTING_CHOICES, useRoutingChoices, type RoutingChoices } from './useRoutingChoices' import type { DraftPayload, DraftSource } from './tourPlannerDraft' import { paddleEstimateOf } from './paddlePlanOf' import { adoptChangedPoints } from './paddleRouting' @@ -66,9 +67,8 @@ export type TourPlannerStatus = | 'saved' /** One step of the history: the route, and what the route depends on. */ -interface HistoryEntry { - waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; preferences: HikingPreferences | null; bikeProfile: BikeProfile - runProfile: RunProfile; tourType: PlannableTourType +interface HistoryEntry extends RoutingChoices { + waypoints: TourPlannerWaypoint[]; tourType: PlannableTourType } /** CW-02: two steps plan the same route: their points alike but for a station's own fields, and the same choices. */ @@ -129,13 +129,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const [plannedDurationMinutes, setPlannedDurationMinutesState] = useState(restored?.plannedDurationMinutes ?? null) const [breakAdditionalMinutes, setBreakAdditionalMinutesState] = useState(restored?.breakAdditionalMinutes ?? null) const [waypoints, setWaypoints] = useState(restored?.waypoints ?? []) - const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState(restored?.maxHikingDifficulty ?? 2) - const [hikingPreferences, setHikingPreferencesState] = useState(restored?.hikingPreferences ?? null) - const [bikeProfile, setBikeProfileState] = useState(restored?.bikeProfile ?? 'touring') + // MF-08, BIK-01, RUN-01: what the route is planned with besides its points and type. + const routingChoices = useRoutingChoices({ difficulty: restored?.maxHikingDifficulty, preferences: restored?.hikingPreferences, + bikeProfile: restored?.bikeProfile, runProfile: restored?.runProfile }) + const { ref: choicesRef, load: loadChoices } = routingChoices + const { difficulty: maxHikingDifficulty, preferences: hikingPreferences, bikeProfile, runProfile } = routingChoices.choices const [winterActivity, setWinterActivityState] = useState(restored?.winterActivity ?? null) // HIK-01: the label the switch brings back, the tour's own while it is off, winter hiking for every other tour. const [winterActivityChoice, setWinterActivityChoice] = useState(restored?.winterActivity ?? 'winter_hike') - const [runProfile, setRunProfileState] = useState(restored?.runProfile ?? 'running') const [tourType, setTourTypeState] = useState(restored?.tourType ?? 'hike') const [selectedWaypointId, setSelectedWaypointId] = useState(restored?.waypoints[0]?.id ?? null) const history = usePlannerHistory() @@ -183,17 +184,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe tripIdRef.current = tripId editingPlaceIdRef.current = editingPlaceId const waypointsRef = useRef(waypoints) - const difficultyRef = useRef(maxHikingDifficulty) - const preferencesRef = useRef(hikingPreferences) - const bikeProfileRef = useRef(bikeProfile) - const runProfileRef = useRef(runProfile) const tourTypeRef = useRef(tourType) waypointsRef.current = waypoints - difficultyRef.current = maxHikingDifficulty const snapshot = useCallback((): HistoryEntry => ({ - waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current, - bikeProfile: bikeProfileRef.current, runProfile: runProfileRef.current, tourType: tourTypeRef.current, - }), []) + waypoints: waypointsRef.current, ...choicesRef.current, tourType: tourTypeRef.current, + }), [choicesRef]) useEffect(() => { mountedRef.current = true @@ -304,33 +299,23 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe * The routing choices, as one step of the history: a hike's limit and its * preferences (MF-08), a bike tour's profile (BIK-01), a run's (RUN-01). The route is planned again. */ - const setRoutingChoice = useCallback((change: { - difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null; bikeProfile?: BikeProfile; runProfile?: RunProfile - }) => { + const setRoutingChoice = useCallback((change: Partial) => { if (!editPermissionRef.current) return - const difficulty = change.difficulty ?? difficultyRef.current - const preferences = change.preferences === undefined ? preferencesRef.current : hikingPreferencesOf(change.preferences) - const profile = change.bikeProfile ?? bikeProfileRef.current - const run = change.runProfile ?? runProfileRef.current - if (difficulty === difficultyRef.current && profile === bikeProfileRef.current && run === runProfileRef.current - && JSON.stringify(preferences) === JSON.stringify(preferencesRef.current)) return + const current = choicesRef.current + const preferences = change.preferences === undefined ? current.preferences : hikingPreferencesOf(change.preferences) + const next = { ...current, ...change, preferences } + if (next.difficulty === current.difficulty && next.bikeProfile === current.bikeProfile && next.runProfile === current.runProfile + && JSON.stringify(preferences) === JSON.stringify(current.preferences)) return invalidateDetailRequest() interruptRouting() draftRevisionRef.current += 1 recordStep(snapshot()) - difficultyRef.current = difficulty - preferencesRef.current = preferences - bikeProfileRef.current = profile - runProfileRef.current = run - setMaxHikingDifficultyState(difficulty) - setHikingPreferencesState(preferences) - setBikeProfileState(profile) - setRunProfileState(run) + loadChoices(next) setHasUnsavedChanges(true) setSaveOutcome(null) setStatus('dirty') setError(null) - }, [recordStep, invalidateDetailRequest, interruptRouting, snapshot]) + }, [choicesRef, loadChoices, recordStep, invalidateDetailRequest, interruptRouting, snapshot]) const setMaxHikingDifficulty = useCallback((difficulty: TourMaxHikingDifficulty) => setRoutingChoice({ difficulty }), [setRoutingChoice]) const setHikingPreferences = useCallback((preferences: HikingPreferences | null) => setRoutingChoice({ preferences }), [setRoutingChoice]) const setBikeProfile = useCallback((profile: BikeProfile) => setRoutingChoice({ bikeProfile: profile }), [setRoutingChoice]) @@ -445,25 +430,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe invalidateDetailRequest() if (!routeKept) interruptRouting() draftRevisionRef.current += 1 - waypointsRef.current = entry.waypoints - difficultyRef.current = entry.difficulty - preferencesRef.current = entry.preferences - bikeProfileRef.current = entry.bikeProfile - runProfileRef.current = entry.runProfile - tourTypeRef.current = entry.tourType - setWaypoints(entry.waypoints) + const { waypoints: points, tourType: type, ...choices } = entry + waypointsRef.current = points + tourTypeRef.current = type + setWaypoints(points) setStageLoss(0) - setMaxHikingDifficultyState(entry.difficulty) - setHikingPreferencesState(entry.preferences) - setBikeProfileState(entry.bikeProfile) - setRunProfileState(entry.runProfile) - setTourTypeState(entry.tourType) + loadChoices(choices) + setTourTypeState(type) setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null) setHasUnsavedChanges(true) if (routeKept) return setStatus('dirty') setError(null) - }, [invalidateDetailRequest, interruptRouting, snapshot]) + }, [invalidateDetailRequest, interruptRouting, loadChoices, snapshot]) const { undo: undoStep, redo: redoStep } = history const undo = useCallback(() => { @@ -584,17 +563,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setPlannedDurationMinutesState(null) setBreakAdditionalMinutesState(null) setWaypoints([]) - setMaxHikingDifficultyState(2) setTourTypeState('hike') loadPaddle(NO_PADDLE_FIELDS) waypointsRef.current = [] - difficultyRef.current = 2 - preferencesRef.current = null - setHikingPreferencesState(null) - bikeProfileRef.current = 'touring' - setBikeProfileState('touring') - runProfileRef.current = 'running' - setRunProfileState('running') + loadChoices(DEFAULT_ROUTING_CHOICES) loadRunPace(null) setWinterActivityState(null) setWinterActivityChoice('winter_hike') @@ -620,7 +592,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setElevationProfileExpanded(true) setNewDraftGeneration(generation => generation + 1) setMapFocusKey(key => key + 1) - }, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadPaddle, loadRunPace, forgetDraft]) + }, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft]) const forgetDeletedTour = useCallback((placeId: number) => { deletedTourPlaceIdsRef.current.add(placeId) @@ -668,22 +640,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe tourTypeRef.current = type setTourTypeState(type) if (draft.paddle) loadPaddle(draft.paddle) - if (draft.bikeProfile) { - bikeProfileRef.current = draft.bikeProfile - setBikeProfileState(draft.bikeProfile) - } - if (draft.runProfile) { - runProfileRef.current = draft.runProfile - setRunProfileState(draft.runProfile) - } + loadChoices({ difficulty: draft.difficulty, ...(draft.bikeProfile ? { bikeProfile: draft.bikeProfile } : {}), + ...(draft.runProfile ? { runProfile: draft.runProfile } : {}) }) loadRunPace(draft.runPaceSecondsPerKm ?? null) setNameState(draft.name) setDescriptionState(draft.description ?? '') setWebsiteState(draft.website ?? '') setWaypoints(points) waypointsRef.current = points - setMaxHikingDifficultyState(draft.difficulty) - difficultyRef.current = draft.difficulty setSelectedWaypointId(points[0].id) if (draft.inPlace && draft.sourcePlaceId !== undefined) { setEditingPlaceId(draft.sourcePlaceId) @@ -696,7 +660,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setHasUnsavedChanges(true) return true }, - [adoptRevision, loadPaddle, loadRunPace, returnToNeutral]) + [adoptRevision, loadChoices, loadPaddle, loadRunPace, returnToNeutral]) const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), []) const toggleElevationProfile = useCallback(() => { @@ -765,17 +729,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setPlannedDurationMinutesState(opened.plannedDurationMinutes) setBreakAdditionalMinutesState(opened.breakAdditionalMinutes) setWaypoints(nextWaypoints) - setMaxHikingDifficultyState(opened.difficulty) setTourTypeState(opened.tourType) loadPaddle(paddleFieldsOf(result.tour)) waypointsRef.current = nextWaypoints - difficultyRef.current = opened.difficulty - preferencesRef.current = opened.preferences - setHikingPreferencesState(opened.preferences) - bikeProfileRef.current = opened.bikeProfile - setBikeProfileState(opened.bikeProfile) - runProfileRef.current = opened.runProfile - setRunProfileState(opened.runProfile) + loadChoices({ difficulty: opened.difficulty, preferences: opened.preferences, bikeProfile: opened.bikeProfile, + runProfile: opened.runProfile }) loadRunPace(opened.runPaceSecondsPerKm) setWinterActivityState(opened.winterActivity) setWinterActivityChoice(opened.winterActivityChoice) @@ -808,7 +766,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setOpeningTourId(null) } } - }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadPaddle, loadRunPace, forgetDraft, tripId]) + }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft, tripId]) const retry = useCallback(() => { if (waypointsRef.current.length < 2) return