diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 5e6a63a1..91ad626d 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -20,7 +20,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder' import { - TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, + TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, type MergedItem, } from '../../utils/dayMerge' @@ -383,10 +383,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (legsAbortRef.current) legsAbortRef.current.abort() if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return } const merged = mergedItemsMap[selectedDayId] || [] - const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { - const e = (r.endpoints || []).find((x: any) => x.role === role) - return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null - } const runs: { id: number; lat: number; lng: number }[][] = [] let cur: { id: number; lat: number; lng: number }[] = [] for (const it of merged) { @@ -394,7 +390,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng }) } else if (it.type === 'transport') { const r = it.data - const from = epLoc(r, 'from'), to = epLoc(r, 'to') + const { from, to } = getTransportRouteEndpoints(r, selectedDayId) if (from || to) { // Located transport: route to its departure point, break the run (the // flight/train itself isn't driven), and let its arrival start the next. diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 98c8118d..5897b851 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useTripStore } from '../store/tripStore' import { calculateRouteWithLegs } from '../components/Map/RouteCalculator' +import { getTransportRouteEndpoints } from '../utils/dayMerge' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult } from '../types' @@ -53,12 +54,6 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu return pos != null }) - // The departure/arrival coordinate of a transport, if its endpoints carry one. - const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { - const e = (r.endpoints || []).find((x: any) => x.role === role) - return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null - } - // Build a unified list of places + transports sorted by effective position. type Entry = | { kind: 'place'; lat: number; lng: number; pos: number } @@ -67,12 +62,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu ...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, })), - ...dayTransports.map(r => ({ - kind: 'transport' as const, - from: epLoc(r, 'from'), - to: epLoc(r, 'to'), - pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number, - })), + ...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. diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts index 3e53f9c0..bd21552c 100644 --- a/client/src/utils/dayMerge.test.ts +++ b/client/src/utils/dayMerge.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge' +import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge' describe('parseTimeToMinutes', () => { it('parses HH:MM string', () => { @@ -34,6 +34,38 @@ describe('getSpanPhase', () => { }) }) +describe('getTransportRouteEndpoints', () => { + const pickup = { role: 'from', lat: 48.1, lng: 11.5 } + const dropoff = { role: 'to', lat: 52.5, lng: 13.4 } + // A car rental spanning day 1 (pickup) through day 3 (drop-off). + const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] } + + it('routes to the pickup only on the start day of a multi-day rental', () => { + expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null }) + }) + + it('routes from the drop-off only on the end day', () => { + expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } }) + }) + + it('adds no waypoints on the days in between (regression for #1210)', () => { + expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null }) + }) + + it('uses both endpoints for a single-day transport', () => { + const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] } + expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({ + from: { lat: 48.1, lng: 11.5 }, + to: { lat: 52.5, lng: 13.4 }, + }) + }) + + it('returns nulls when the endpoints carry no coordinates', () => { + const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] } + expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null }) + }) +}) + describe('getDisplayTimeForDay', () => { const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' } diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts index cabd9bbd..8bdda0ff 100644 --- a/client/src/utils/dayMerge.ts +++ b/client/src/utils/dayMerge.ts @@ -29,6 +29,33 @@ export function getSpanPhase( return 'middle' } +/** + * The route waypoints a transport contributes on a given day, respecting multi-day spans. + * A car rental (or any reservation whose span covers several days) is only routed to on its + * pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on + * the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the + * route to those points. Single-day transports contribute both endpoints. + */ +export function getTransportRouteEndpoints( + r: any, + dayId: number +): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } { + const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => { + const e = (r.endpoints || []).find((x: any) => x.role === role) + return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null + } + switch (getSpanPhase(r, dayId)) { + case 'start': + return { from: ep('from'), to: null } + case 'end': + return { from: null, to: ep('to') } + case 'middle': + return { from: null, to: null } + default: + return { from: ep('from'), to: ep('to') } + } +} + export function getDisplayTimeForDay( r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null }, dayId: number