Files
TREK/client/src/components/Tours/planner/useTourPlanner.ts
T
Maurice 1d7c8a63e2 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.
2026-10-20 18:21:46 +02:00

946 lines
46 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { holdsHorseSection } from '../horse/horseLegs'
import { walkTimes } from './stationModel'
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts'
import { usePlannerHistory } from './usePlannerHistory'
import { useTourDraftStorage } from './useTourDraftStorage'
import { useTourRevisionGuard } from './useTourRevisionGuard'
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'
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
import { useRunPace } from '../run/useRunPace'
import { lostStageEnds } from '../stages/tourStages'
import { useTourStages } from '../stages/useTourStages'
/** What a tour planned from an import starts with. */
export interface DerivedTourDraft {
name: string
description?: string | null
website?: string | null
difficulty: TourMaxHikingDifficulty
/** The control points in order; a city walk's stop keeps the name it came with (CW-07). */
points: Array<{ lat: number; lng: number; name?: string | null }>
/** The import the points were taken from; absent for a link read from outside the trip (CW-07). */
sourcePlaceId?: number
/**
* Plan the import over in place, keeping its id, days and notes (the server
* keeps the recorded track to go back to), rather than as a new tour beside it.
*/
inPlace: boolean
/** P2C-03: the import's revision, which a save planned over in place names. */
revision?: number
/** A paddle import stays a paddle tour, with its boat and water, and a bike or run import its type with its profile. */
tourType?: PlannableTourType
paddle?: PaddleFieldValues
bikeProfile?: BikeProfile
runProfile?: RunProfile
/** RUN-01: a run planned over keeps the pace it was planned at, so the runner need not type it again. */
runPaceSecondsPerKm?: number | null
/** CW-07: where the points came from, said in the planner until the draft is saved. */
source?: DraftSource
}
export type TourPlannerStatus =
| 'empty'
| 'dirty'
| 'routing'
| 'routing-failed'
| 'enriching-elevation'
| 'elevation-failed'
| 'ready'
| 'saving'
| 'saved'
/** One step of the history: the route, and what the route depends on. */
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. */
function sameRoute(a: HistoryEntry, b: HistoryEntry): boolean {
const { waypoints: pointsA, ...choicesA } = a
const { waypoints: pointsB, ...choicesB } = b
return routeInputsKey(pointsA) === routeInputsKey(pointsB) && JSON.stringify(choicesA) === JSON.stringify(choicesB)
}
/** Where a new tour was planned from; an edit leaves it as it was saved. */
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
}
function trimmedOrNull(value: string): string | null {
return value.trim() || null
}
function parsedWebsiteOrNull(value: string) {
return value.trim() ? tourWebsiteSchema.parse(value) : null
}
export interface UseTourPlannerOptions extends TourPermissionProps {
tripId: number | string
active?: boolean
onSaved?: (result: TourCreateResponse) => void | Promise<void>
}
export type TourPlannerMode =
| { type: 'neutral' }
| { type: 'new-draft' }
| { type: 'edit-saved'; placeId: number }
| { type: 'view-gpx'; placeId: number; tour: TourListItem }
export function selectTourPlannerMode(state: {
readOnlyGpxTour: { tour: TourListItem } | null
editingPlaceId: number | null
hasUnsavedChanges: boolean
}): TourPlannerMode {
if (state.readOnlyGpxTour) {
return { type: 'view-gpx', placeId: state.readOnlyGpxTour.tour.place_id, tour: state.readOnlyGpxTour.tour }
}
if (state.editingPlaceId !== null) return { type: 'edit-saved', placeId: state.editingPlaceId }
if (state.hasUnsavedChanges) return { type: 'new-draft' }
return { type: 'neutral' }
}
export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPermission, canAssign: assignPermission }: UseTourPlannerOptions) {
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const editPermissionRef = useRef(canEdit)
editPermissionRef.current = canEdit
const draftStorage = useTourDraftStorage(tripId)
const { restored, keep: keepDraft, forget: forgetDraft } = draftStorage
const [name, setNameState] = useState(restored?.name ?? '')
const [description, setDescriptionState] = useState(restored?.description ?? '')
const [website, setWebsiteState] = useState(restored?.website ?? '')
const [plannedDurationMinutes, setPlannedDurationMinutesState] = useState<number | null>(restored?.plannedDurationMinutes ?? null)
const [breakAdditionalMinutes, setBreakAdditionalMinutesState] = useState<number | null>(restored?.breakAdditionalMinutes ?? null)
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
// 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<WinterActivity | null>(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<WinterActivity>(restored?.winterActivity ?? 'winter_hike')
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const history = usePlannerHistory<HistoryEntry>()
const { record: recordStep, clear: clearHistory } = history
const revision = useTourRevisionGuard(restored?.baseRevision ?? null)
const { adopt: adoptRevision, refuse: refuseRevision, baseRef: revisionBaseRef } = revision
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
const [error, setError] = useState<string | null>(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(Boolean(restored))
const [isSaving, setIsSaving] = useState(false)
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState<number | null>(restored?.derivedFromPlaceId ?? null)
const [draftSource, setDraftSource] = useState<DraftSource | null>(restored?.draftSource ?? null)
// CW-11: the open walk was saved with its stations only; its way is planned here and kept by the next save.
const [routePending, setRoutePending] = useState(false)
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
const [retryToken, setRetryToken] = useState(0)
const mapView = useTourMapView()
const [readOnlyGpxTour, setReadOnlyGpxTour] = useState<{ tour: TourListItem; routeGeometry: string | null } | null>(null)
const [newTourConfirmationOpen, setNewTourConfirmationOpen] = useState(false)
const [elevationProfileExpanded, setElevationProfileExpanded] = useState(true)
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
const [mapFocusKey, setMapFocusKey] = useState(0)
// MDT-01: stage ends the last change of the points could not keep. Said until the user's next change,
// undo or redo, or another draft, so a point the router lays in afterwards does not take the notice away.
const [stageLoss, setStageLoss] = useState(0)
const draftRevisionRef = useRef(0)
const saveRequestIdRef = useRef(0)
const saveInFlightRef = useRef(false)
const deletedTourPlaceIdsRef = useRef(new Set<number>())
const mountedRef = useRef(false)
const contextGenerationRef = useRef(0)
const detailRequestIdRef = useRef(0)
const detailAbortRef = useRef<AbortController | null>(null)
const detailPlaceIdRef = useRef<number | null>(null)
const activeRef = useRef(active)
const readOnlyGpxTourRef = useRef(readOnlyGpxTour)
const tripIdRef = useRef(tripId)
const editingPlaceIdRef = useRef(editingPlaceId)
activeRef.current = active
readOnlyGpxTourRef.current = readOnlyGpxTour
tripIdRef.current = tripId
editingPlaceIdRef.current = editingPlaceId
const waypointsRef = useRef(waypoints)
const tourTypeRef = useRef(tourType)
waypointsRef.current = waypoints
const snapshot = useCallback((): HistoryEntry => ({
waypoints: waypointsRef.current, ...choicesRef.current, tourType: tourTypeRef.current,
}), [choicesRef])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
saveRequestIdRef.current += 1
saveInFlightRef.current = false
detailRequestIdRef.current += 1
detailPlaceIdRef.current = null
detailAbortRef.current?.abort()
detailAbortRef.current = null
}
}, [])
const invalidateDetailRequest = useCallback(() => {
detailRequestIdRef.current += 1
detailPlaceIdRef.current = null
detailAbortRef.current?.abort()
detailAbortRef.current = null
if (mountedRef.current) setOpeningTourId(null)
}, [])
useEffect(() => {
if (!active) invalidateDetailRequest()
}, [active, invalidateDetailRequest])
useEffect(() => {
invalidateDetailRequest()
}, [tripId, invalidateDetailRequest])
/** A field other than the route changed: the draft is unsaved again, and a finished save is no longer current. */
const markEdited = useCallback(() => {
if (!editPermissionRef.current) return false
invalidateDetailRequest()
draftRevisionRef.current += 1
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus(current => current === 'saved' || current === 'saving' ? (waypointsRef.current.length < 2 ? 'dirty' : 'ready') : current)
return true
}, [invalidateDetailRequest])
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
const runPace = useRunPace(restored?.runPaceSecondsPerKm ?? null, markEdited)
const loadRunPace = runPace.load
const loadPaddle = paddle.load
const paddleBoat = paddle.boatType
// The router's own default where the tour names none, so a plan and its cache key agree.
const paddleLakeStyle = paddle.lakeStyle ?? 'direct'
// A point the router set (a paddle tour's) is laid in without a step of the history.
const patchWaypoints = useCallback((next: TourPlannerWaypoint[]) => {
waypointsRef.current = next
setWaypoints(next)
}, [])
const forgetFocus = useCallback(() => setRouteProfileFocus(null), [])
const routing = useTourRoute({
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, retryToken,
error, setStatus, setError, onCleared: forgetFocus, patchWaypoints,
})
const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds,
unreachable, interruptRouting, clearRoute } = routing
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
if (!editPermissionRef.current) return
const current = waypointsRef.current
const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
const paddle = tourTypeRef.current === 'kayak'
// A point the router set that the paddler moves or edits is theirs from then on.
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, tourTypeRef.current)
if (sameWaypointSnapshot(current, next)) return
recordStep(snapshot())
waypointsRef.current = next
setWaypoints(next)
const lost = lostStageEnds(current, changed, next)
setStageLoss(lost)
// CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was.
if (routeInputsKey(current) === routeInputsKey(next)) {
markEdited()
return
}
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [recordStep, invalidateDetailRequest, interruptRouting, markEdited, snapshot])
/** Whether the points may change now: the viewer may edit and no save is on its way. */
const mayEditPoints = useCallback(() => editPermissionRef.current && !saveInFlightRef.current, [])
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
const changed = change(waypointsRef.current)
if (changed === waypointsRef.current) return
interruptRouting()
draftRevisionRef.current += 1
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current)
setWaypoints(waypointsRef.current)
setHasUnsavedChanges(true)
setStatus('dirty')
}, [interruptRouting])
/**
* 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: Partial<RoutingChoices>) => {
if (!editPermissionRef.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())
loadChoices(next)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [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])
const setRunProfile = useCallback((profile: RunProfile) => setRoutingChoice({ runProfile: profile }), [setRoutingChoice])
const addWaypoint = useCallback((lat: number, lng: number) => {
if (!editPermissionRef.current) return
const id = randomId()
commitWaypoints(current => withPointAppended<TourPlannerWaypoint>(current, { id, lat, lng, role: 'end' }))
setSelectedWaypointId(id)
}, [commitWaypoints])
/**
* A fixed leg onto the end of the tour, as one step of the history: a
* waypoint at the line's start that carries it, and one at its end.
*/
const appendFixedLeg = useCallback((leg: TourFixedLeg) => {
if (!editPermissionRef.current) return
const first = leg.geometry[0]
const last = leg.geometry[leg.geometry.length - 1]
if (!first || !last) return
const startId = randomId()
const endId = randomId()
commitWaypoints(current => [
...current,
{ id: startId, lat: Number(first[0]), lng: Number(first[1]), role: 'via', fixedLeg: leg },
{ id: endId, lat: Number(last[0]), lng: Number(last[1]), role: 'end' },
])
setSelectedWaypointId(endId)
}, [commitWaypoints])
const setWaypointPosition = useCallback((id: string, lat: number, lng: number) => {
if (!editPermissionRef.current || saveInFlightRef.current || !Number.isFinite(lat) || !Number.isFinite(lng)) return false
const current = waypointsRef.current.find(point => point.id === id)
if (!current) return false
if (current.lat === lat && current.lng === lng) return true
const points = waypointsRef.current
// HOR-01: a ride's point at a horse route section stays where the route has it.
if (tourTypeRef.current === 'horse' && holdsHorseSection(points, id)) return false
// In a loop the start and the way back are one spot: moving either moves both.
const loopEnds = isClosedLoop(points) && (id === points[0].id || id === points[points.length - 1].id)
? new Set([points[0].id, points[points.length - 1].id])
: new Set([id])
// A point moved stands for no place any more (PR1-10): it is where the hand put it.
commitWaypoints(list => list.map(point => loopEnds.has(point.id) ? unlinkedPoint({ ...point, lat, lng }) : point))
return true
}, [commitWaypoints])
/** PR1-01: the tour taken back from its last point to its start, as one step. */
const returnToStart = useCallback(() => {
if (!editPermissionRef.current || saveInFlightRef.current) return
commitWaypoints(current => closeLoop(current, randomId()))
}, [commitWaypoints])
/** PR1-02: the tour the other way round, as one step. */
const reverse = useCallback(() => {
if (!editPermissionRef.current || saveInFlightRef.current) return
commitWaypoints(current => reverseRoute(current))
}, [commitWaypoints])
/** PAD-03/PAD-07: a paddler's change to the control points (a portage, a lock), as one step. */
const editPaddlePoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current !== 'kayak') return
commitWaypoints(change)
}, [commitWaypoints])
/** P2A-04: the leg after this waypoint drawn by hand, or routed again (a paddle tour's on the water). */
const toggleWaypointFreeLeg = useCallback((id: string) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
commitWaypoints(current => toggleFreeLeg(current, id))
}, [commitWaypoints])
const removeWaypoint = useCallback((id: string) => {
if (!editPermissionRef.current) return
commitWaypoints(current => current.filter(point => point.id !== id))
setSelectedWaypointId(current => current === id ? null : current)
}, [commitWaypoints])
const moveWaypoint = useCallback((id: string, direction: -1 | 1) => {
commitWaypoints(current => {
const index = current.findIndex(point => point.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= current.length) return current
const next = [...current]
;[next[index], next[target]] = [next[target], next[index]]
return next
})
}, [commitWaypoints])
const reorderWaypoint = useCallback(
(id: string, targetId: string, placement: 'before' | 'after') => {
if (!editPermissionRef.current || saveInFlightRef.current || id === targetId) return;
commitWaypoints((current) => {
const fromIndex = current.findIndex((point) => point.id === id);
const targetIndex = current.findIndex((point) => point.id === targetId);
if (fromIndex < 0 || targetIndex < 0) return current;
const next = [...current];
const [moved] = next.splice(fromIndex, 1);
const targetAfterRemoval = next.findIndex((point) => point.id === targetId);
const insertIndex = targetAfterRemoval + (placement === 'after' ? 1 : 0);
next.splice(insertIndex, 0, moved);
return next;
});
},
[commitWaypoints]
);
/** A snapshot of the history put back: the route and what it depends on. */
const restoreStep = useCallback((entry: HistoryEntry) => {
// CW-02: a step that changed a station's own fields only comes back without planning the route again.
const routeKept = sameRoute(entry, snapshot())
invalidateDetailRequest()
if (!routeKept) interruptRouting()
draftRevisionRef.current += 1
const { waypoints: points, tourType: type, ...choices } = entry
waypointsRef.current = points
tourTypeRef.current = type
setWaypoints(points)
setStageLoss(0)
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, loadChoices, snapshot])
const { undo: undoStep, redo: redoStep } = history
const undo = useCallback(() => {
if (!editPermissionRef.current) return
const previous = undoStep(snapshot())
if (previous) restoreStep(previous)
}, [restoreStep, snapshot, undoStep])
const redo = useCallback(() => {
if (!editPermissionRef.current) return
const next = redoStep(snapshot())
if (next) restoreStep(next)
}, [redoStep, restoreStep, snapshot])
const setName = useCallback((value: string) => {
if (value !== name && markEdited()) setNameState(value)
}, [markEdited, name])
const setDescription = useCallback((value: string) => {
if (value !== description && markEdited()) setDescriptionState(value)
}, [description, markEdited])
const setWebsite = useCallback((value: string) => {
if (value !== website && markEdited()) setWebsiteState(value)
}, [markEdited, website])
const setPlannedDurationMinutes = useCallback((value: number | null) => {
if (value !== plannedDurationMinutes && markEdited()) setPlannedDurationMinutesState(value)
}, [markEdited, plannedDurationMinutes])
const setBreakAdditionalMinutes = useCallback((value: number | null) => {
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
}, [breakAdditionalMinutes, markEdited])
/** HIK-01: the hike's winter label, a field like the description: it plans nothing again and is no step of the history. */
const setWinterActivity = useCallback((value: WinterActivity | null) => {
if (value === winterActivity || !markEdited()) return
setWinterActivityState(value)
if (value) setWinterActivityChoice(value)
}, [markEdited, winterActivity])
/**
* PAD-01, BIK-01, RUN-01, HOR-01: the type, as one step of the history with
* the route, since a paddle tour draws every leg by hand, neither a bike tour
* nor a run takes a via ferrata and a ride takes every leg from a horse route
* or by hand. Nothing is put on a day by the choice itself.
*/
const setTourType = useCallback((value: PlannableTourType) => {
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
recordStep(snapshot())
tourTypeRef.current = value
setTourTypeState(value)
setStageLoss(0)
const noFerrata = value === 'bike' || value === 'run'
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value)
waypointsRef.current = next
setWaypoints(next)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [recordStep, invalidateDetailRequest, interruptRouting, snapshot])
const routeAnalysis = useMemo(
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
[enrichedGeometry, route],
)
const isPaddle = tourType === 'kayak'
const paddleSpeedInvalid = paddle.speedKmh !== null && !paddleSpeedKmhSchema.safeParse(paddle.speedKmh).success
const { effectiveSpeedKmh, portageSpeedKmh, boatType, waterContext, speedKmh, lakeStyle } = paddle
// The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
const routedLegLengths = paddleRoute && routeCurrent ? paddleRoute.legLengths : undefined
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
// CW-02: a city walk's stays, what of them is known.
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
const runPaceSecondsPerKm = runPace.paceSecondsPerKm
// MDT-01: the stages, cut from the route while it is the one planned for these points.
const stagePaddle = useMemo(() => isPaddle
? { effectiveSpeedKmh, portageSpeedKmh, speedInvalid: paddleSpeedInvalid, legLengths: routedLegLengths }
: null, [effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths])
const stages = useTourStages({
waypoints, line: routeCurrent ? (enrichedGeometry ?? route) : null, tourType, routerSeconds: durationSeconds,
paddle: stagePaddle, runPaceSecondsPerKm, commit: commitWaypoints, mayEdit: mayEditPoints,
draftKey: `${editingPlaceId ?? 'new'}:${newDraftGeneration}`,
lost: stageLoss,
})
const stageCount = Math.max(1, stages.stages.length)
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm, website,
plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stageCount, stays, tourType, waterContext,
website, winterActivity])
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays,
runPaceMissing, runPaceInvalid } = facts
const returnToNeutral = useCallback(() => {
if (saveInFlightRef.current) return
invalidateDetailRequest()
interruptRouting()
contextGenerationRef.current += 1
draftRevisionRef.current += 1
setRouteProfileFocus(null)
forgetDraft()
setNameState('')
setDescriptionState('')
setWebsiteState('')
setPlannedDurationMinutesState(null)
setBreakAdditionalMinutesState(null)
setWaypoints([])
setTourTypeState('hike')
loadPaddle(NO_PADDLE_FIELDS)
waypointsRef.current = []
loadChoices(DEFAULT_ROUTING_CHOICES)
loadRunPace(null)
setWinterActivityState(null)
setWinterActivityChoice('winter_hike')
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
clearHistory()
setStageLoss(0)
setStatus('empty')
clearRoute()
setError(null)
setHasUnsavedChanges(false)
setDraftRestored(false)
setEditingPlaceId(null)
editingPlaceIdRef.current = null
setDerivedFromPlaceId(null)
setDraftSource(null)
setRoutePending(false)
adoptRevision(null)
setOpeningTourId(null)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
setNewTourConfirmationOpen(false)
setElevationProfileExpanded(true)
setNewDraftGeneration(generation => generation + 1)
setMapFocusKey(key => key + 1)
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft])
const forgetDeletedTour = useCallback((placeId: number) => {
deletedTourPlaceIdsRef.current.add(placeId)
const editorReferencesTour = editingPlaceIdRef.current === placeId
|| readOnlyGpxTourRef.current?.tour.place_id === placeId
|| detailPlaceIdRef.current === placeId
if (!editorReferencesTour) return
if (saveInFlightRef.current) {
saveRequestIdRef.current += 1
saveInFlightRef.current = false
setIsSaving(false)
}
returnToNeutral()
}, [returnToNeutral])
const startNewTour = useCallback((confirmed = false) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
const currentDraftIsEmpty = editingPlaceId === null && waypoints.length === 0 && name.trim().length === 0
if (!confirmed && hasUnsavedChanges && !currentDraftIsEmpty) {
setNewTourConfirmationOpen(true)
return
}
returnToNeutral()
setHasUnsavedChanges(true)
}, [editingPlaceId, hasUnsavedChanges, name, returnToNeutral, waypoints.length]
)
/**
* A draft through the control points taken from an import: a new tour that
* remembers the import, or the import itself planned over in place. Nothing
* is stored until the draft is saved like any other.
*/
const startDerivedTour = useCallback(
(draft: DerivedTourDraft) => {
if (!editPermissionRef.current || saveInFlightRef.current || draft.points.length < 2) return false
returnToNeutral()
const type = draft.tourType ?? 'hike'
const points = normalizePlannerWaypoints(
draft.points.map(({ lat, lng, name }) => ({
id: randomId(), lat, lng, role: 'via' as const, ...(name ? { name } : {}),
})),
type,
)
setDraftSource(draft.source ?? null)
tourTypeRef.current = type
setTourTypeState(type)
if (draft.paddle) loadPaddle(draft.paddle)
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
setSelectedWaypointId(points[0].id)
if (draft.inPlace && draft.sourcePlaceId !== undefined) {
setEditingPlaceId(draft.sourcePlaceId)
editingPlaceIdRef.current = draft.sourcePlaceId
adoptRevision(draft.revision)
} else {
setDerivedFromPlaceId(draft.sourcePlaceId ?? null)
}
setStatus('dirty')
setHasUnsavedChanges(true)
return true
},
[adoptRevision, loadChoices, loadPaddle, loadRunPace, returnToNeutral])
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
const toggleElevationProfile = useCallback(() => {
if (elevationProfileExpanded) setRouteProfileFocus(null)
setElevationProfileExpanded(expanded => !expanded)
}, [elevationProfileExpanded])
const discard = useCallback(() => returnToNeutral(), [returnToNeutral])
const viewGpxTour = useCallback((tour: TourListItem, routeGeometry: string | null) => {
if (saveInFlightRef.current) return
invalidateDetailRequest()
contextGenerationRef.current += 1
returnToNeutral()
setReadOnlyGpxTour({ tour, routeGeometry })
}, [invalidateDetailRequest, returnToNeutral])
const closeGpxTour = useCallback(() => {
invalidateDetailRequest()
contextGenerationRef.current += 1
setRouteProfileFocus(null)
setReadOnlyGpxTour(null)
}, [invalidateDetailRequest])
const openTour = useCallback(async (tour: Pick<TourListItem, 'place_id'> & Partial<TourListItem>) => {
if (!mountedRef.current || !activeRef.current || saveInFlightRef.current || deletedTourPlaceIdsRef.current.has(tour.place_id)) return false
detailAbortRef.current?.abort()
const controller = new AbortController()
detailAbortRef.current = controller
const requestId = ++detailRequestIdRef.current
const submittedTripId = String(tripId)
const submittedPlaceId = tour.place_id
const submittedContextGeneration = contextGenerationRef.current
const submittedRevision = draftRevisionRef.current
const submittedEditingPlaceId = editingPlaceIdRef.current
const submittedGpxPlaceId = readOnlyGpxTourRef.current?.tour.place_id ?? null
detailPlaceIdRef.current = submittedPlaceId
const requestIsCurrent = () => mountedRef.current
&& activeRef.current
&& requestId === detailRequestIdRef.current
&& detailPlaceIdRef.current === submittedPlaceId
&& String(tripIdRef.current) === submittedTripId
&& contextGenerationRef.current === submittedContextGeneration
&& draftRevisionRef.current === submittedRevision
&& editingPlaceIdRef.current === submittedEditingPlaceId
&& (readOnlyGpxTourRef.current?.tour.place_id ?? null) === submittedGpxPlaceId
setRouteProfileFocus(null)
setOpeningTourId(tour.place_id)
setError(null)
try {
const result = await tourRepo.detail(tripId, tour.place_id, controller.signal)
if (!requestIsCurrent() || saveInFlightRef.current) return false
if (result.waypoints.length < 2) {
setError('open')
return false
}
interruptRouting()
const opened = openedTourFields(result.tour)
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), opened.tourType)
forgetDraft()
contextGenerationRef.current += 1
draftRevisionRef.current += 1
setNameState(opened.name)
setDescriptionState(opened.description)
setWebsiteState(opened.website)
setPlannedDurationMinutesState(opened.plannedDurationMinutes)
setBreakAdditionalMinutesState(opened.breakAdditionalMinutes)
setWaypoints(nextWaypoints)
setTourTypeState(opened.tourType)
loadPaddle(paddleFieldsOf(result.tour))
waypointsRef.current = nextWaypoints
loadChoices({ difficulty: opened.difficulty, preferences: opened.preferences, bikeProfile: opened.bikeProfile,
runProfile: opened.runProfile })
loadRunPace(opened.runPaceSecondsPerKm)
setWinterActivityState(opened.winterActivity)
setWinterActivityChoice(opened.winterActivityChoice)
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
setStageLoss(0)
setStatus('dirty')
clearRoute()
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
const pending = result.tour.route_pending === true
setRoutePending(pending)
setHasUnsavedChanges(pending && editPermissionRef.current)
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
adoptRevision(result.tour.revision)
setMapFocusKey(key => key + 1)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
setDraftSource(null)
return true
} catch {
if (requestIsCurrent() && !saveInFlightRef.current) setError('open')
return false
} finally {
if (mountedRef.current && requestId === detailRequestIdRef.current) {
detailPlaceIdRef.current = null
if (detailAbortRef.current === controller) detailAbortRef.current = null
setOpeningTourId(null)
}
}
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft, tripId])
const retry = useCallback(() => {
if (waypointsRef.current.length < 2) return
interruptRouting()
setError(null)
setStatus('dirty')
setRetryToken(value => value + 1)
}, [interruptRouting])
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
const shaping = useRouteShaping({
commit: commitWaypoints, waypointsRef, retry, mayEdit: mayEditPoints,
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
})
// RTE-02: other ways for one routed section of a hike, compared and taken on purpose.
const legVariants = useLegVariants({
waypoints, route, enrichedGeometry, routeCurrent, tourType, maxHikingDifficulty, hikingPreferences, undo, mayEdit: mayEditPoints,
insertShapingPoints: shaping.insertShapingPoints,
})
// RTE-03: round hikes of about a wished length out of a stop, put in place of the plan on purpose.
const roundHikes = useRoundHikes({
waypoints, tourType, maxHikingDifficulty, hikingPreferences, replaceWaypoints: commitWaypoints, undo, mayEdit: mayEditPoints,
})
const readOnlyGpxAnalysis = useMemo(
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
[readOnlyGpxTour?.routeGeometry],
)
const mode = useMemo(() => selectTourPlannerMode({ readOnlyGpxTour, editingPlaceId, hasUnsavedChanges }), [editingPlaceId, hasUnsavedChanges, readOnlyGpxTour])
const focusOwner = mode.type === 'view-gpx' || mode.type === 'edit-saved' ? `${mode.type}:${mode.placeId}` : mode.type
useEffect(() => { setRouteProfileFocus(null) }, [focusOwner, routeAnalysis, readOnlyGpxAnalysis, readOnlyGpxTour])
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity,
runProfile, runPaceSecondsPerKm,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
}
keepDraft(draft)
}, [breakAdditionalMinutes,
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm])
useEffect(() => {
if (!hasUnsavedChanges) return
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [hasUnsavedChanges])
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeCurrent && hasCompleteElevation(enrichedGeometry)
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid
/**
* Saves the draft: over the tour it edits, naming the revision it was loaded
* at (P2C-03), or with `asNew` as a tour of its own under `name`, the way out
* of a refused save that keeps both versions.
*/
const save = useCallback(async (options: { asNew?: boolean; name?: string } = {}) => {
if (!mountedRef.current || saveInFlightRef.current || !editPermissionRef.current || !canSave || !hasCompleteElevation(enrichedGeometry)) return null
if (editingPlaceId !== null && deletedTourPlaceIdsRef.current.has(editingPlaceId)) return null
invalidateDetailRequest()
saveInFlightRef.current = true
const requestId = ++saveRequestIdRef.current
const submittedRevision = draftRevisionRef.current
const submittedContextGeneration = contextGenerationRef.current
const submittedTripId = String(tripId)
const submittedPlaceId = editingPlaceId
const targetPlaceId = options.asNew ? null : submittedPlaceId
const expectedRevision = targetPlaceId === null ? null : revisionBaseRef.current
const payload = {
name: (options.name ?? name).trim(),
description: trimmedOrNull(description),
website: parsedWebsiteOrNull(website),
...typeFields,
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: savedWaypointsOf(waypoints, tourType),
duration_seconds: savedDurationSeconds,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: savedBreakAdditionalMinutes,
...derivedFromField(targetPlaceId, derivedFromPlaceId),
...(expectedRevision !== null ? { expected_revision: expectedRevision } : {}),
} as const
setStatus('saving')
setIsSaving(true)
setError(null)
const stillCurrent = () => mountedRef.current
&& requestId === saveRequestIdRef.current
&& String(tripIdRef.current) === submittedTripId
&& editingPlaceIdRef.current === submittedPlaceId
&& contextGenerationRef.current === submittedContextGeneration
&& draftRevisionRef.current === submittedRevision
try {
const result = targetPlaceId === null
? await tourRepo.create(tripId, payload)
: await tourRepo.update(tripId, targetPlaceId, payload)
if (stillCurrent()) {
// CW-03: the keys the server gave new points stay with them, so the next save keeps their visits.
const keyed = withSavedKeys(waypointsRef.current, result.waypoints)
if (keyed !== waypointsRef.current) {
waypointsRef.current = keyed
setWaypoints(keyed)
}
forgetDraft()
adoptRevision(result.tour.revision)
if (options.name) setNameState(options.name.trim())
setHasUnsavedChanges(false)
setRoutePending(false)
setStatus('saved')
clearHistory()
setStageLoss(0)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setDraftSource(null)
setSaveOutcome(result.tour)
}
if (mountedRef.current && String(tripIdRef.current) === submittedTripId && !deletedTourPlaceIdsRef.current.has(result.tour.place_id)) {
try { await onSaved?.(result) } catch { return result }
}
return result
} catch (failure: unknown) {
if (stillCurrent()) {
setStatus('ready')
// A save refused because someone else saved the tour meanwhile keeps the draft and asks what to do.
setError(refuseRevision(failure) ? 'conflict' : 'save')
}
return null
} finally {
if (mountedRef.current && requestId === saveRequestIdRef.current) {
saveInFlightRef.current = false
setIsSaving(false)
}
}
}, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, savedBreakAdditionalMinutes, canSave,
derivedFromPlaceId,
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tourType, tripId, typeFields, waypoints, website])
return {
tripId, canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', winterActivity, setWinterActivity, winterActivityChoice,
runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid,
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, stationStays,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
...shaping, legVariants, roundHikes, paddleRoute, routeCurrent, stages,
undo, redo, canUndo: history.canUndo, canRedo: history.canRedo,
status, error, retry, isSaving,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, unreachable,
routeProfileFocus, setRouteProfileFocus,
...mapView,
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
startNewTour,
startDerivedTour,
derivedFromPlaceId,
draftSource,
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
editingPlaceId, openingTourId, openTour, routePending,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral, revision,
}
}
export type TourPlannerController = ReturnType<typeof useTourPlanner>