fix(maps): stop the day route drawing drives to airports you flew from

A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
This commit is contained in:
Maurice
2026-08-29 11:57:05 +02:00
committed by Maurice
parent 3c865c9605
commit 9162f5d7ac
8 changed files with 328 additions and 38 deletions
@@ -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 }
}
+54 -17
View File
@@ -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
+4 -11
View File
@@ -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<string, 'driving' | 'cycling'> = {
// 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 || [])
+21
View File
@@ -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
+38
View File
@@ -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.
+19 -2
View File
@@ -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
+33
View File
@@ -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
}
@@ -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}`]);
});
});