mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
52d1a0720f
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates, currency and country codes, setting keys, route names, OAuth scopes, zip entry names — where that order is already the intended one. Each says so explicitly now, with a comparator that reproduces it exactly rather than a localeCompare that would quietly reorder things. That mattered more than it sounds. Several of these feed something other than a display: useAtlas joins the sorted codes into the signature that decides whether to rebuild the region layer, daysSlice re-pins sorted dates positionally onto reordered days, and three timeline views pick a day's colour by indexOf into the sorted array. Switching any of them to a locale collator would have been a silent regression. Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal pass the index as a second argument, which placeBtn declares and uses for its React key — correct already, now written out as .map((p, i) => placeBtn(p, i)). And budgetSlice's optimistic paid-toggle stored the raw boolean behind `paid as unknown as number`, while the schema declares z.number() and both the REST response and the WebSocket event carry 0/1. So the optimistic state and the confirmed state disagreed on type until a refresh. It normalises now, and the two tests that asserted `true` assert the contract. Four `| 0` truncations are listed in sonar-project.properties instead of changed. Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two look like plain truncation of a row id, but `| 0` is also coercing undefined to 0 there — the settlement flows the API returns carry no user_id, so that is the normal path, and Math.trunc returns NaN and crashes the lookup.
266 lines
16 KiB
TypeScript
266 lines
16 KiB
TypeScript
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
|
import { useTripStore } from '../store/tripStore'
|
|
import { useSettingsStore } from '../store/settingsStore'
|
|
import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
|
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder'
|
|
import { resolveLegMode } from '../components/Planner/legMode'
|
|
import type { TripStoreState } from '../store/tripStore'
|
|
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
|
|
|
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
|
|
|
const NO_ACCOMMODATIONS: Accommodation[] = []
|
|
|
|
/**
|
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
|
*/
|
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
|
// Charging stops / rest areas a plugin route places on the drawn line.
|
|
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
|
|
const routeAbortRef = useRef<AbortController | null>(null)
|
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
|
// Recompute when the selected day's whole-day default mode changes (#1281) —
|
|
// including a remote collaborator's change, which arrives as day:updated and only
|
|
// touches state.days (otherwise not an effect dependency, so the map/mobile
|
|
// connectors would stay in the old mode while the sidebar already switched).
|
|
const selectedDayDefaultMode = useTripStore((s) => (selectedDayId ? s.days?.find(d => d.id === selectedDayId)?.default_transport_mode ?? null : null))
|
|
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
|
|
// hotel) unless the user turned the setting off — same gate as the sidebar.
|
|
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
|
|
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
|
|
// refresh instead of showing stale cached text (#1300).
|
|
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
|
|
|
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
|
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
|
|
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
|
// Read directly from store (not a render-phase ref) so callers after optimistic
|
|
// updates or non-optimistic deletes always see the latest assignments.
|
|
const currentAssignments = useTripStore.getState().assignments || {}
|
|
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
|
const allReservations = useTripStore.getState().reservations || []
|
|
const allDays = useTripStore.getState().days || []
|
|
const dayOrder = (id: number | null | undefined): number | null => {
|
|
if (id == null) return null
|
|
const d = allDays.find(x => x.id === id)
|
|
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
|
|
}
|
|
const thisOrder = dayOrder(dayId)
|
|
|
|
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
|
|
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
|
|
if (!TRANSPORT_TYPES.includes(r.type)) return false
|
|
const startId = r.day_id
|
|
if (startId == null) return false
|
|
const endId = r.end_day_id ?? startId
|
|
if (startId === endId) {
|
|
if (startId !== dayId) return false
|
|
} else {
|
|
const startOrder = dayOrder(startId)
|
|
const endOrder = dayOrder(endId)
|
|
if (startOrder == null || endOrder == null) return false
|
|
if (thisOrder < startOrder || thisOrder > endOrder) return false
|
|
}
|
|
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
|
|
return pos != null
|
|
})
|
|
|
|
// Build a unified list of places + transports sorted by effective position.
|
|
type Entry =
|
|
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
|
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
|
const entries: Entry[] = [
|
|
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
|
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index, time: a.place?.place_time ?? null,
|
|
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
|
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
|
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
|
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
|
|
})),
|
|
...dayTransports.map(r => {
|
|
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
|
return {
|
|
kind: 'transport' as const,
|
|
from,
|
|
to,
|
|
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
|
}
|
|
}),
|
|
].sort((a, b) => a.pos - b.pos)
|
|
|
|
// Group located places into driving runs.
|
|
// - A transport WITH a location anchors the route to its departure point (you
|
|
// travel there), then breaks the run (you don't drive the flight/train); its
|
|
// arrival point starts the next run.
|
|
// - A transport WITHOUT a location is ignored entirely — the places around it
|
|
// connect directly, as if the booking weren't there.
|
|
// A run is only a real drive when it contains at least one actual place. Two
|
|
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
|
|
// first's arrival point with the second's departure point into a phantom
|
|
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
|
|
type RunPoint = { lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }
|
|
const runs: RunPoint[][] = []
|
|
let currentRun: RunPoint[] = []
|
|
let runHasPlace = false
|
|
for (const entry of entries) {
|
|
if (entry.kind === 'place') {
|
|
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
|
runHasPlace = true
|
|
} else if (entry.from || entry.to) {
|
|
if (entry.from) currentRun.push({ ...entry.from, isPlace: false })
|
|
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
|
currentRun = []
|
|
runHasPlace = false
|
|
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
|
|
}
|
|
}
|
|
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
|
|
|
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
|
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
|
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
|
// transfer day) and already filters to accommodations that have coordinates.
|
|
const day = allDays.find(d => d.id === dayId)
|
|
const bookends = day && optimizeFromAccommodation !== false
|
|
? getDayBookendHotels(day, allDays, accommodations)
|
|
: null
|
|
const flatPts: RunPoint[] = []
|
|
for (const e of entries) {
|
|
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
|
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
|
}
|
|
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
|
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
|
// carries its own override.
|
|
const hotelPt = (a?: Accommodation): RunPoint | null =>
|
|
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
|
// Only draw a hotel bookend when the leg is a real drive. You start/end the day at a hotel
|
|
// when you slept there / sleep there tonight; on the hotel's own check-in or check-out day
|
|
// the leg holds only when the edge stop is a PLACE timed after check-in / before check-out
|
|
// (you dropped bags first, or swung back before checking out). A place before check-in (an
|
|
// airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a
|
|
// transport endpoint on an arrival/departure day (#1321, S7) all draw no bookend.
|
|
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
|
const firstStop = entries.find(contributes)
|
|
const lastStop = [...entries].reverse().find(contributes)
|
|
const edgeInfo = (e?: Entry) =>
|
|
e ? { isPlace: e.kind === 'place', time: e.kind === 'place' ? e.time : null } : undefined
|
|
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop))
|
|
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop))
|
|
const runsWithHotel: RunPoint[][] = withHotelBookends(
|
|
runs,
|
|
flatPts[0],
|
|
flatPts[flatPts.length - 1],
|
|
drawMorning ? hotelPt(bookends?.morning) : null,
|
|
drawEvening ? hotelPt(bookends?.evening) : null,
|
|
)
|
|
|
|
// Transfer day with no activities: you check out of one accommodation and into
|
|
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
|
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
|
|
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
|
|
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
|
|
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
|
const m = hotelPt(bookends?.morning)
|
|
const e = hotelPt(bookends?.evening)
|
|
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
|
}
|
|
|
|
const straightLines = (): [number, number][][] =>
|
|
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
|
|
|
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
|
|
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
|
// OSRM (or plugin-provided) road geometry.
|
|
setRoute(straightLines())
|
|
|
|
const tripId = useTripStore.getState().trip?.id ?? null
|
|
const controller = new AbortController()
|
|
routeAbortRef.current = controller
|
|
// Per-leg routing (#1281): each leg uses its origin's saved mode, else the
|
|
// day's default, else the live picker profile. Legs are concatenated back into
|
|
// one polyline per run, and each segment is tagged with the mode it drew in so
|
|
// the connector shows the matching icon and duration.
|
|
const dayDefaultMode = day?.default_transport_mode || profile
|
|
try {
|
|
const polylines: [number, number][][] = []
|
|
const allLegs: RouteSegment[] = []
|
|
const allVias: RouteVia[] = []
|
|
for (const run of runsWithHotel) {
|
|
const polyline: [number, number][] = []
|
|
// Append a leg's coordinates, dropping the point shared with the previous
|
|
// leg so concatenated legs don't leave a duplicate at each junction.
|
|
const pushCoords = (coords: [number, number][]) => {
|
|
for (const c of coords) {
|
|
const last = polyline[polyline.length - 1]
|
|
if (last && last[0] === c[0] && last[1] === c[1]) continue
|
|
polyline.push(c)
|
|
}
|
|
}
|
|
// Neighbouring legs that resolve to the SAME mode travel as one
|
|
// multi-waypoint request — the router answers with one leg per pair, so
|
|
// the per-connector segments stay exactly as they were, and a day without
|
|
// per-leg overrides is back to a single call per run.
|
|
let i = 0
|
|
while (i < run.length - 1) {
|
|
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
|
let end = i + 1
|
|
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
|
const chunk = run.slice(i, end + 1)
|
|
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
|
|
try {
|
|
const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId })
|
|
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight())
|
|
for (const leg of r.legs) allLegs.push({ ...leg, mode })
|
|
if (r.vias) allVias.push(...r.vias)
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'AbortError') throw err
|
|
// Routing failed for these legs — fall back to straight lines, no times.
|
|
pushCoords(straight())
|
|
}
|
|
i = end
|
|
}
|
|
if (polyline.length >= 2) polylines.push(polyline)
|
|
}
|
|
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs); setRouteVias(allVias) }
|
|
} catch (err: unknown) {
|
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
|
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
|
|
}
|
|
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
|
|
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
|
const transportSignature = useMemo(() => {
|
|
if (!selectedDayId) return ''
|
|
return reservationsForSignature
|
|
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
|
.map(r => {
|
|
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
|
// Include endpoints so adding/moving a departure/arrival location re-routes.
|
|
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
|
|
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
|
|
})
|
|
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
.join('|')
|
|
}, [reservationsForSignature, selectedDayId])
|
|
|
|
// Recalculate when assignments or transport positions for the SELECTED day change
|
|
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
|
useEffect(() => {
|
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
|
updateRouteForDay(selectedDayId)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
|
|
|
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
|
}
|