diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 91b410e08..e77f19765 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -27,12 +27,13 @@ import { usePluginStore } from '../../store/pluginStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' import { useTranslation } from '../../i18n' -import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder' +import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder' import { TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints, - getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, + getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, type MergedItem, } from '../../utils/dayMerge' +import { withinDriveRange } from '../../utils/geo' import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import { useExchangeRates } from '../../hooks/useExchangeRates' @@ -520,6 +521,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { let curHasPlace = false for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { + // Mirror of the guard below: the open run may hold nothing but a far-away + // arrival endpoint, which is no more drivable in this direction (#2133). + const prev = cur[cur.length - 1] + if (prev && !prev.isPlace && !withinDriveRange(prev, { lat: it.data.place.lat, lng: it.data.place.lng })) { + if (cur.length >= 2 && curHasPlace) runs.push(cur) + cur = [] + curHasPlace = false + } cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) curHasPlace = true } else if (it.type === 'transport') { @@ -528,7 +537,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { 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. - if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng, isPlace: false }) + // ...but only when you could have driven there from the stop before it. A + // long-haul departure airport that sorted in after the day's local stops is + // not the end of a drive, and pairing them produces a phantom connector with + // an ocean-crossing distance (#2133). + const prev = cur[cur.length - 1] + if (from && (!prev || withinDriveRange(prev, from))) cur.push({ id: r.id, lat: from.lat, lng: from.lng, isPlace: false }) if (cur.length >= 2 && curHasPlace) runs.push(cur) cur = [] curHasPlace = false @@ -559,20 +573,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // whether each is a place and its time so the bookend decision can drop a leg that isn't // real: a check-in hotel never drove to a departure airport (#1321), and a place timed before // check-in / after check-out means you weren't at the hotel then (#1465). - const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = [] + // A carrier endpoint (flight/train/ferry/cruise/coach — not a hire car you keep + // driving) also records WHICH end it is, so the hotel leg can be dropped when it + // is one you flew out of or landed at rather than drove between (#2133). + const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, dayId) - if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false, time: null }) - if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false, time: null }) + const carrier = isCarrierTransport(it.data) + if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false, time: null, carrierEdge: carrier ? 'departure' : null }) + if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false, time: null, carrierEdge: carrier ? 'arrival' : null }) } } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] - const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) - const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) + const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) => + !h || !w || w.isPlace || h.place_lat == null || h.place_lng == null + || withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w) + const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) && reachable(startHotel, firstWay) + const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) && reachable(endHotel, lastWay) return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } } diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 67de7a8eb..702beaf2f 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -2,8 +2,9 @@ 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, getTransportForDay, getMergedItems } from '../utils/dayMerge' -import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder' +import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport } from '../utils/dayMerge' +import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../utils/dayOrder' +import { withinDriveRange } from '../utils/geo' import { resolveLegMode } from '../components/Planner/legMode' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types' @@ -76,7 +77,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // 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 } + | { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean } const entries: Entry[] = merged.flatMap((item): Entry[] => { if (item.type === 'place') { const a = item.data @@ -91,7 +92,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu } if (item.type !== 'transport') return [] const { from, to } = getTransportRouteEndpoints(item.data, dayId) - return [{ kind: 'transport', from, to, pos: item.sortKey }] + return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }] }) // Group located places into driving runs. @@ -104,23 +105,40 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // 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). + // + // A booking endpoint also has to be REACHABLE from the stop before it. The day's + // order is a wall clock with no timezone behind it, so a long-haul arrival whose + // departure clock reads late sorts after the day's local stops — and its departure + // airport, an ocean away, then gets stapled to the last of them. The router answers + // that pair with NoRoute and the whole chunk falls back to a straight line, which is + // the long ray the map draws (#2133). Distance is only ever consulted for a leg + // touching a transport endpoint: two real places far apart are a drive someone + // planned, an airport that far from the stop before it never is. 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 + const closeRun = () => { + if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun) + currentRun = [] + runHasPlace = false + } for (const entry of entries) { if (entry.kind === 'place') { + const prev = currentRun[currentRun.length - 1] + // The open run may be nothing but a far-away arrival endpoint — break rather + // than draw the ocean. + if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun() 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 + const prev = currentRun[currentRun.length - 1] + if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false }) + closeRun() if (entry.to) currentRun.push({ ...entry.to, isPlace: false }) } } - if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun) + closeRun() // 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. @@ -146,19 +164,38 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // (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. + // A carrier endpoint at an edge additionally kills its own leg outright, however + // near the hotel it sits: you flew out of that airport, so nothing drove back from + // it tonight, and you flew into that one, so nothing drove to it this morning + // (#2133). Which of a transport's two points sits at the edge depends on its span — + // an overnight flight contributes only its departure on the day it leaves. 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 edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => { + if (!e) return undefined + if (e.kind === 'place') return { isPlace: true, time: e.time } + const role: CarrierEdge = side === 'first' + ? (e.from ? 'departure' : 'arrival') + : (e.to ? 'arrival' : 'departure') + return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null } + } + const firstWay = flatPts[0] + const lastWay = flatPts[flatPts.length - 1] + const morningHotel = hotelPt(bookends?.morning) + const eveningHotel = hotelPt(bookends?.evening) + // Same reachability test as the run builder: a hotel is not joined to a point no + // one could have driven between. + const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first')) + && (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay)) + const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last')) + && (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay)) const runsWithHotel: RunPoint[][] = withHotelBookends( runs, - flatPts[0], - flatPts[flatPts.length - 1], - drawMorning ? hotelPt(bookends?.morning) : null, - drawEvening ? hotelPt(bookends?.evening) : null, + firstWay, + lastWay, + drawMorning ? morningHotel : null, + drawEvening ? eveningHotel : null, ) // Transfer day with no activities: you check out of one accommodation and into diff --git a/client/src/hooks/useTransportRoutes.ts b/client/src/hooks/useTransportRoutes.ts index afe76b763..d31f718dc 100644 --- a/client/src/hooks/useTransportRoutes.ts +++ b/client/src/hooks/useTransportRoutes.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { Reservation, ReservationEndpoint } from '../types' import { calculateRouteWithLegs } from '../components/Map/RouteCalculator' +import { haversineKm, MAX_DRIVE_KM } from '../utils/geo' /** * Real road-network geometry for road-based transport bookings (car, bus, taxi, @@ -23,17 +24,9 @@ const ROAD_PROFILE: Record = { // Beyond this straight-line distance a car/taxi/bike (or even coach) booking is // almost always a data quirk or an inter-continental hop the road router can't // resolve — keep the straight line and don't hammer the public OSRM demo. -const MAX_ROUTE_KM = 2000 - -function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { - const R = 6371 - const dLat = (b.lat - a.lat) * Math.PI / 180 - const dLng = (b.lng - a.lng) * Math.PI / 180 - const la1 = a.lat * Math.PI / 180 - const la2 = b.lat * Math.PI / 180 - const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2 - return 2 * R * Math.asin(Math.sqrt(h)) -} +// Shared with the day-route builder, which draws the same conclusion about a +// booking endpoint that landed next to a local stop (#2133). +const MAX_ROUTE_KM = MAX_DRIVE_KM function orderedWaypoints(r: Reservation): ReservationEndpoint[] { return (r.endpoints || []) diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts index d8b5dbb5a..457340e8b 100644 --- a/client/src/utils/dayMerge.ts +++ b/client/src/utils/dayMerge.ts @@ -54,6 +54,27 @@ export function hidesOnMiddleDay( return !!r.type && MIDDLE_DAY_HIDDEN_TYPES.has(r.type) && getSpanPhase(r, dayId) === 'middle' } +/** + * Bookings you ride and then leave behind: the vehicle carries you out of the day's + * geography and keeps going without you. Their endpoints are therefore not the ends of + * a drive — you did not drive to tonight's hotel from the airport you took off from, and + * you did not drive to the airport you landed at from this morning's hotel (#2133). + * + * A hire car, a parked car or a hired bike is the exact opposite and must stay out of + * this set: you collect the vehicle and keep driving it, so a multi-day rental's pickup + * point IS where a drive to the hotel starts, and its drop-off point IS where a drive + * from the hotel ends (both pinned by the car-rental span rules above). + * + * `taxi`, `transit` and `transport_other` are left out on purpose: they are same-day + * hops, so both their endpoints land on the day anyway and the distinction never fires. + * Keeping them out means this set cannot change any behaviour they have today. + */ +const CARRIER_TRANSPORT_TYPES = new Set(['flight', 'train', 'ferry', 'cruise', 'bus']) + +export function isCarrierTransport(r: { type?: string | null }): boolean { + return !!r.type && CARRIER_TRANSPORT_TYPES.has(r.type) +} + /** * 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 diff --git a/client/src/utils/dayOrder.test.ts b/client/src/utils/dayOrder.test.ts index 4d1cd416c..494ff72a7 100644 --- a/client/src/utils/dayOrder.test.ts +++ b/client/src/utils/dayOrder.test.ts @@ -166,6 +166,25 @@ describe('shouldDrawMorningLeg', () => { expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: true, time: '06:00' })).toBe(true) }) + it('does NOT draw when the day opens on an airport you landed at (#2133)', () => { + // You flew in. Even on a night you provably slept in this hotel, nobody drove out + // of it to the airport that set you down. + const bookends = { morning: into(), morningIsSleptHere: true } + expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: false, carrierEdge: 'arrival' })).toBe(false) + }) + + it('still draws when the day opens on an airport you departed from (#2133)', () => { + // The legitimate mirror: you woke in the hotel and drove to your outbound airport. + const bookends = { morning: into(), morningIsSleptHere: true } + expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: false, carrierEdge: 'departure' })).toBe(true) + }) + + it('still draws for a hire-car drop-off point, which carries no carrierEdge (#2133)', () => { + // A car you keep driving is not a carrier: the hotel → drop-off depot drive is real. + const bookends = { morning: into(), morningIsSleptHere: true } + expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: false, carrierEdge: null })).toBe(true) + }) + it('does NOT draw on a check-in day when the first place is before check-in (#1465)', () => { const bookends = { morning: into({ check_in: '15:00' }), morningIsSleptHere: false } // Airport place at 10:00, check-in 15:00 — you have not reached the hotel yet. @@ -218,6 +237,25 @@ describe('shouldDrawEveningLeg', () => { expect(shouldDrawEveningLeg(bookends, checkOutDay, { isPlace: true, time: '23:00' })).toBe(true) }) + it('does NOT draw when the day ends at an airport you took off from (#2133)', () => { + // The reported bug: an overnight flight leaves only its departure airport on the + // day it leaves, and that airport was being joined to tonight's hotel. + const bookends = { evening: out(), eveningIsOvernight: true } + expect(shouldDrawEveningLeg(bookends, checkOutDay, { isPlace: false, carrierEdge: 'departure' })).toBe(false) + }) + + it('still draws when the day ends at an airport you landed at (#2133)', () => { + // The legitimate case the fix must not eat: you land, then drive to the hotel. + const bookends = { evening: out(), eveningIsOvernight: true } + expect(shouldDrawEveningLeg(bookends, checkOutDay, { isPlace: false, carrierEdge: 'arrival' })).toBe(true) + }) + + it('still draws for a hire-car pickup point, which carries no carrierEdge (#2133)', () => { + // You collect the car at 18:00 and drive it to tonight's hotel — a real leg. + const bookends = { evening: out(), eveningIsOvernight: true } + expect(shouldDrawEveningLeg(bookends, checkOutDay, { isPlace: false, carrierEdge: null })).toBe(true) + }) + it('does NOT draw on a check-out day when the last place is after check-out (#1465)', () => { const bookends = { evening: out({ check_out: '11:00' }), eveningIsOvernight: false } // "home" place at 18:00, check-out 11:00 — you have already left the hotel. diff --git a/client/src/utils/dayOrder.ts b/client/src/utils/dayOrder.ts index 9ebe6cf84..209f9e429 100644 --- a/client/src/utils/dayOrder.ts +++ b/client/src/utils/dayOrder.ts @@ -1,6 +1,16 @@ import type { Day, Accommodation, RouteAnchors } from '../types' import { parseTimeToMinutes } from './dayMerge' +/** + * Set on an edge waypoint that is the endpoint of a booking which CARRIES you out of + * the day's geography (see `isCarrierTransport`): 'departure' = you left from this + * point, 'arrival' = you were set down at it. A hire car — a vehicle you keep driving — + * leaves this unset, so its pickup/drop-off points keep the hotel legs they have always + * drawn. Undefined always means "no opinion", which is what keeps every existing caller + * and the activity-free transfer day behaving exactly as before. + */ +export type CarrierEdge = 'departure' | 'arrival' | null + export const getDayOrder = (day: Day, days: Day[]): number => day.day_number ?? days.indexOf(day) @@ -67,8 +77,12 @@ export const getAccommodationAnchors = ( export const shouldDrawMorningLeg = ( bookends: { morning?: Accommodation; morningIsSleptHere?: boolean }, day: Day, - firstStop?: { isPlace: boolean; time?: string | null }, + firstStop?: { isPlace: boolean; time?: string | null; carrierEdge?: CarrierEdge }, ): boolean => { + // You landed here. Whatever hotel the day belongs to, nobody drove out of it to the + // airport they arrived at — so there is no morning leg, not even on a night you + // provably slept in that hotel (#2133). + if (firstStop?.carrierEdge === 'arrival') return false if (bookends.morningIsSleptHere) return true const m = bookends.morning if (!m || m.start_day_id !== day.id || !firstStop?.isPlace) return false @@ -91,8 +105,11 @@ export const shouldDrawMorningLeg = ( export const shouldDrawEveningLeg = ( bookends: { evening?: Accommodation; eveningIsOvernight?: boolean }, day: Day, - lastStop?: { isPlace: boolean; time?: string | null }, + lastStop?: { isPlace: boolean; time?: string | null; carrierEdge?: CarrierEdge }, ): boolean => { + // Mirror: you took off from here, so no drive leads from it back to tonight's hotel — + // the reported "flight starting airport connected to the accommodation" (#2133). + if (lastStop?.carrierEdge === 'departure') return false if (bookends.eveningIsOvernight) return true const e = bookends.evening if (!e || e.end_day_id !== day.id || !lastStop?.isPlace) return false diff --git a/client/src/utils/geo.ts b/client/src/utils/geo.ts new file mode 100644 index 000000000..8efd5d2d3 --- /dev/null +++ b/client/src/utils/geo.ts @@ -0,0 +1,33 @@ +/** + * Straight-line distance between two coordinates, in kilometres. + * + * Lifted out of useTransportRoutes so the day-route builder can share the one + * implementation instead of adding a fourth copy. The map overlays keep their own + * `[lat, lng]`-tuple variants — different call shape, and they only sum arc lengths. + */ +export function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { + const R = 6371 + const dLat = (b.lat - a.lat) * Math.PI / 180 + const dLng = (b.lng - a.lng) * Math.PI / 180 + const la1 = a.lat * Math.PI / 180 + const la2 = b.lat * Math.PI / 180 + const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2 + return 2 * R * Math.asin(Math.sqrt(h)) +} + +/** + * Beyond this straight-line distance a leg of a day's route is not a drive anyone + * makes between two stops — it is a booking endpoint that landed next to a local + * stop, and the road router answers such a pair with NoRoute (#2133). The same + * 2000 km useTransportRoutes has always used for booking geometry. + * + * Deliberately only ever applied to a leg that touches a TRANSPORT endpoint. Two + * real places 2000 km apart are a long drive someone planned; an airport 2000 km + * from the stop before it never is. + */ +export const MAX_DRIVE_KM = 2000 + +/** Whether two points are close enough to plausibly be joined by a road leg. */ +export function withinDriveRange(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean { + return haversineKm(a, b) <= MAX_DRIVE_KM +} diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index d8d7c7b0c..f131003b6 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -675,4 +675,134 @@ describe('useRouteCalculation', () => { expect(result.current.route).toBeNull(); expect(result.current.routeSegments).toEqual([]); }); + + it('FE-HOOK-ROUTE-026: an overnight flight draws no departure-airport → hotel leg (#2133)', async () => { + // The reporter's literal repro: add a flight on a day, add an accommodation that + // checks in tonight. The flight leaves today and lands tomorrow, so the day + // contributes ONLY its departure airport — and that airport was being joined to + // the hotel by a straight line, as if you had driven back from it after taking off. + const icn = { lat: 37.46, lng: 126.44 }; // Seoul Incheon + const hotel = { lat: 21.28, lng: -157.83 }; // Waikiki + const flight = { + id: 300, type: 'flight', day_id: 1, end_day_id: 2, day_positions: { 1: 0 }, + endpoints: [ + { role: 'from', lat: icn.lat, lng: icn.lng }, + { role: 'to', lat: 21.32, lng: -157.92 }, + ], + }; + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }]; + const store = { assignments: { '1': [] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [flight], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving', accommodations as any) + ); + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).not.toContainEqual([`${icn.lat},${icn.lng}`, `${hotel.lat},${hotel.lng}`]); + expect(legs).toEqual([]); + }); + + it('FE-HOOK-ROUTE-027: a far-away airport is never stapled to the local stops of a day (#2133)', async () => { + // The screenshot: a Honolulu day with local stops and an inbound long-haul whose + // DEPARTURE clock (18:30 in Seoul) sorts it after them. Its Seoul departure airport + // was being appended to the run of Waikiki stops, and the road router answers that + // pair with NoRoute, so the whole run fell back to one straight trans-Pacific line. + const waikiki = buildPlace({ lat: 21.28, lng: -157.83, place_time: '12:00' }); + const diamond = buildPlace({ lat: 21.26, lng: -157.80, place_time: '15:00' }); + const icn = { lat: 37.46, lng: 126.44 }; + const hnl = { lat: 21.32, lng: -157.92 }; + const a1 = buildAssignment({ day_id: 1, order_index: 0, place: waikiki }); + const a2 = buildAssignment({ day_id: 1, order_index: 1, place: diamond }); + const flight = { + id: 301, type: 'flight', day_id: 1, end_day_id: 1, reservation_time: '18:30', + endpoints: [ + { role: 'from', lat: icn.lat, lng: icn.lng }, + { role: 'to', lat: hnl.lat, lng: hnl.lng }, + ], + }; + const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [flight], + days: [{ id: 1, day_number: 1 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving') + ); + await act(async () => {}); + + const flat = (result.current.route ?? []).flat().map(p => `${p[0]},${p[1]}`); + // Seoul never appears in a driving run drawn over Oahu. + expect(flat).not.toContain(`${icn.lat},${icn.lng}`); + // The genuine local drive between the two Waikiki stops survives. + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).toContainEqual([`${waikiki.lat},${waikiki.lng}`, `${diamond.lat},${diamond.lng}`]); + }); + + it('FE-HOOK-ROUTE-028: a hire car pickup still draws its leg to the hotel tonight (#2133)', async () => { + // The counter-case the fix must not eat. A multi-day rental contributes only its + // PICKUP point on the collection day — the same "one endpoint" shape as the + // overnight flight above — but you keep driving the car, so the drive from the + // depot to tonight's hotel is real and must stay. + const depot = { lat: 48.35, lng: 11.78 }; // Munich airport depot + const hotel = { lat: 48.14, lng: 11.58 }; // Munich city hotel + const rental = { + id: 302, type: 'car', day_id: 1, end_day_id: 3, day_positions: { 1: 0 }, + endpoints: [ + { role: 'from', lat: depot.lat, lng: depot.lng }, + { role: 'to', lat: 52.5, lng: 13.4 }, + ], + }; + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }]; + const store = { assignments: { '1': [] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [rental], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving', accommodations as any) + ); + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).toContainEqual([`${depot.lat},${depot.lng}`, `${hotel.lat},${hotel.lng}`]); + }); + + it('FE-HOOK-ROUTE-029: you still drive from the hotel to your outbound airport (#2133)', async () => { + // The legitimate morning mirror. A same-day flight contributes both endpoints, so + // the day opens on its DEPARTURE airport — a drive you really make. + const hotel = { lat: 48.14, lng: 11.58 }; + const muc = { lat: 48.35, lng: 11.78 }; + const flight = { + id: 303, type: 'flight', day_id: 3, end_day_id: 3, day_positions: { 3: 0 }, + endpoints: [ + { role: 'from', lat: muc.lat, lng: muc.lng }, + { role: 'to', lat: 41.80, lng: 12.25 }, + ], + }; + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }]; + const store = { assignments: { '3': [] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [flight], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 3, true, 'driving', accommodations as any) + ); + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${muc.lat},${muc.lng}`]); + }); });