diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 38858ae1..6e847c7d 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -6,6 +6,7 @@ import { calculateSegments, optimizeRoute, generateGoogleMapsUrl, + withHotelBookends, } from './RouteCalculator' const OSRM_BASE = 'https://router.project-osrm.org/route/v1' @@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => { expect(result).toContain('48.86,2.36') }) }) + +// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ──────── + +describe('withHotelBookends', () => { + const hotel = { lat: 1, lng: 1 } + const a = { lat: 2, lng: 2 } + const b = { lat: 3, lng: 3 } + const evening = { lat: 4, lng: 4 } + + it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => { + const runs = [[a, b]] + expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]]) + }) + + it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => { + const runs = [[a, b]] + expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([ + [hotel, a], + [a, b], + [b, evening], + ]) + }) + + it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => { + expect(withHotelBookends([], a, a, hotel, evening)).toEqual([ + [hotel, a], + [a, evening], + ]) + }) + + it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => { + const runs = [[a, b]] + expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]]) + }) + + it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => { + const runs = [[a, b]] + expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([ + [hotel, a], + [a, b], + ]) + }) +}) diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index e9a8b415..af86ac01 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -67,6 +67,27 @@ export async function calculateRoute( } } +/** + * Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the + * day's activity runs, so the drawn route starts and ends at the day's accommodation + * (matching the sidebar's hotel connectors). A bookend is only added when both its + * hotel and the first/last located waypoint exist; passing nulls leaves `runs` + * untouched. The shared first/last waypoint is repeated so the polylines join. + */ +export function withHotelBookends( + runs: Waypoint[][], + firstWay: Waypoint | undefined, + lastWay: Waypoint | undefined, + startHotel: Waypoint | null, + endHotel: Waypoint | null, +): Waypoint[][] { + const out: Waypoint[][] = [] + if (startHotel && firstWay) out.push([startHotel, firstWay]) + out.push(...runs) + if (endHotel && lastWay) out.push([lastWay, endHotel]) + return out +} + export function generateGoogleMapsUrl(places: Waypoint[]): string | null { const valid = places.filter((p) => p.lat && p.lng) if (valid.length === 0) return null diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 5897b851..f356b07f 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -1,9 +1,11 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useTripStore } from '../store/tripStore' -import { calculateRouteWithLegs } from '../components/Map/RouteCalculator' +import { useSettingsStore } from '../store/settingsStore' +import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator' import { getTransportRouteEndpoints } from '../utils/dayMerge' +import { getDayBookendHotels } from '../utils/dayOrder' import type { TripStoreState } from '../store/tripStore' -import type { RouteSegment, RouteResult } from '../types' +import type { RouteSegment, RouteResult, Accommodation } from '../types' const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] @@ -12,12 +14,15 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr * 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: 'driving' | 'walking' | 'cycling' = 'driving') { +export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = []) { const [route, setRoute] = useState<[number, number][][] | null>(null) const [routeInfo, setRouteInfo] = useState(null) const [routeSegments, setRouteSegments] = useState([]) const routeAbortRef = useRef(null) const reservationsForSignature = useTripStore((s) => s.reservations) + // 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) const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() @@ -93,10 +98,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu } if (currentRun.length >= 2) runs.push(currentRun) - const straightLines = (): [number, number][][] => - runs.map(r => r.map(p => [p.lat, p.lng] as [number, number])) + // 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 { morning: startHotel, evening: endHotel } = + day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {} + const flatPts: { lat: number; lng: number }[] = [] + for (const e of entries) { + if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng }) + else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) } + } + const hotelPt = (a?: Accommodation) => + a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null + const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel)) - if (runs.length === 0) { setRoute(null); setRouteSegments([]); return } + const straightLines = (): [number, number][][] => + runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number])) + + if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return } // Draw straight lines immediately for snappiness, then upgrade to the real // OSRM road geometry. @@ -107,7 +128,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu try { const polylines: [number, number][][] = [] const allLegs: RouteSegment[] = [] - for (const run of runs) { + for (const run of runsWithHotel) { try { const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile }) polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number])) @@ -123,7 +144,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines. if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([]) } - }, [enabled, profile]) + }, [enabled, profile, accommodations, optimizeFromAccommodation]) // 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. @@ -147,7 +168,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } updateRouteForDay(selectedDayId) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile]) + }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation]) return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 52d04cc9..f7fbe9bc 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -289,7 +289,7 @@ export function useTripPlanner() { }) }, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds]) - const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile) + const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations) const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => { const changed = dayId !== selectedDayId