mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a stay now waits behind the landing when it lies nearer the arrival than the departure, and its check-in only holds it when the ride lands before the room is ready. A time typed on the stop keeps its seat. With the stays at both ends of a day switched on: - a day with nothing but the next stay's check-in and a ride booked across it gets no drive from one hotel to the other, the same rule the day plan follows since #2476; - a stay is never joined by road to a terminal more than 2000 km away. The check moved to shared/roadtrip so Days and the road trip read one copy. A departure terminal's pin is dated: reaching it more than half a day late no longer moves the flight to the next day without a word, it is a late warning with the full minutes. A check-in evening also carries the stay's check-in time, as a label only. calculate_roadtrip runs the same code and its description says all of the above.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { withHotelBookends } from './RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
getAssignmentReservations, timedSlot, storedRideSlot,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
|
||||
@@ -1405,3 +1405,98 @@ describe('a booked night at both ends of its days', () => {
|
||||
expect(runs).toContainEqual(['driving', [HOTEL[0], P[4][0], P[5][0], HOTEL[0]]])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-ROUTES-062..063: a flight on the day the next stay begins.
|
||||
*
|
||||
* Trip 45 on the dev instance: a night in Hamburg, then LH 2078 to Munich at 15:15 on the day
|
||||
* the Munich hotel is checked into from 15:00. And a move between two stays by a flight saved
|
||||
* without its airports. What is pinned is the day the rail reads and what goes to the router.
|
||||
*/
|
||||
describe('a flight on the day the next stay begins', () => {
|
||||
const ATLANTIC: [number, number] = [53.5573, 10.0056]
|
||||
const BAYERISCHER_HOF: [number, number] = [48.1403, 11.5732]
|
||||
const HAM: [number, number] = [53.630402, 9.98823]
|
||||
const MUC: [number, number] = [48.353802, 11.7861]
|
||||
const GETAWAY: [number, number] = [-33.71, 150.31]
|
||||
const LOOKOUT: [number, number] = [-33.73, 150.35]
|
||||
const WALLINGA: [number, number] = [-34.1, 150.9]
|
||||
|
||||
const stayOf = (id: number, stopId: number, start: number, end: number, at: [number, number]) =>
|
||||
({
|
||||
id, trip_id: 7, place_id: stopId * 10, start_day_id: start, end_day_id: end,
|
||||
check_in: '15:00', check_out: '11:00', place_name: `Stay ${id}`, place_lat: at[0], place_lng: at[1],
|
||||
}) as unknown as Accommodation
|
||||
const lh2078 = (over: Record<string, unknown> = {}) => ({
|
||||
id: 77,
|
||||
trip_id: 7,
|
||||
title: 'LH 2078 HAM-MUC',
|
||||
type: 'flight',
|
||||
status: 'pending',
|
||||
day_id: 3,
|
||||
end_day_id: 3,
|
||||
reservation_time: '2026-11-04T15:15',
|
||||
reservation_end_time: '2026-11-04T17:20',
|
||||
day_plan_position: 0.5,
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hamburg (HAM)', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: 'Europe/Berlin', local_time: '15:15', local_date: '2026-11-04' },
|
||||
{ role: 'to', sequence: 1, name: 'Munich (MUC)', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: 'Europe/Berlin', local_time: '17:20', local_date: '2026-11-04' },
|
||||
],
|
||||
...over,
|
||||
}) as unknown as Reservation
|
||||
|
||||
const hourly = (points: { lat: number; lng: number }[]) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 100000 * (points.length - 1),
|
||||
duration: 3600 * (points.length - 1),
|
||||
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
|
||||
})
|
||||
const asked = () => calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
|
||||
const shape = (stops: RoadtripStop[]) =>
|
||||
stops.map(s => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.carrier ? s.carrier.role : s.name))
|
||||
const switchTo = (on: boolean) =>
|
||||
act(() => { useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_hotel_bookends: on } as never }) })
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
})
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-062: the Munich hotel waits behind the landing, and no road runs between the two cities, switch on or off', async () => {
|
||||
const days = [day(1, 1), day(2, 2), day(3, 3), day(4, 4), day(5, 5)]
|
||||
const assignments = { ...map(1, [{ id: 1, at: ATLANTIC, dwell: 60 }]), ...map(3, [{ id: 2, at: BAYERISCHER_HOF, dwell: 60 }]) } as AssignmentsMap
|
||||
const stays = [stayOf(33, 1, 1, 3, ATLANTIC), stayOf(34, 2, 3, 5, BAYERISCHER_HOF)]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [lh2078()]))
|
||||
const flightDay = () => result.current.days.find(d => d.dayId === 3)!
|
||||
// Only ever a road inside one city: every run asked for lies north of 53 or south of 49.
|
||||
const oneCity = () => asked().every(run => run.every(lat => lat > 53) || run.every(lat => lat < 49))
|
||||
|
||||
switchTo(false)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(shape(flightDay().stops)).toEqual(['departure', 'arrival', 'Stop 2'])
|
||||
expect(oneCity()).toBe(true)
|
||||
// At the airport by the check-in, and at the hotel from the landing, not late for 15:00.
|
||||
expect(flightDay().schedule.entries.map(e => e.arrival)).toEqual(['14:15', '17:20', '18:20'])
|
||||
expect(flightDay().schedule.warnings).toEqual([])
|
||||
|
||||
switchTo(true)
|
||||
await waitFor(() => expect(flightDay()?.stops[0]?.bookend?.phase).toBe('morning'))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(shape(flightDay().stops)).toEqual(['morning:33', 'departure', 'arrival', 'Stop 2'])
|
||||
expect(asked()).toContainEqual([ATLANTIC[0], HAM[0]])
|
||||
expect(oneCity()).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-063: a flight saved without its airports is the move between two stays, and no road is asked for it', async () => {
|
||||
switchTo(true)
|
||||
const days = [day(1, 1), day(2, 2), day(3, 3)]
|
||||
const assignments = { ...map(1, [{ id: 1, at: GETAWAY }, { id: 3, at: LOOKOUT }]), ...map(2, [{ id: 4, at: WALLINGA }]) } as AssignmentsMap
|
||||
const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
|
||||
const unlocated = lh2078({ day_id: 2, end_day_id: 2, endpoints: [] })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [unlocated]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.days.find(d => d.dayId === 2)!.stops.map(s => s.name)).toEqual(['Stop 4'])
|
||||
expect(asked().flat()).not.toContain(WALLINGA[0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -348,10 +348,11 @@ export function useRoadtripRoutes(
|
||||
// The stored days with the night before and the night after seated at their edges, by
|
||||
// the rule the server's calculate_roadtrip runs too. Before a day is asked whether it
|
||||
// stands as one: a day with a bookend always has two stops. A linked booking changes a
|
||||
// bookend's reservation and nothing `planKey` reads, so it asks the router nothing.
|
||||
// bookend's reservation and nothing `planKey` reads, so it asks the router nothing. The
|
||||
// bookings go in whole: a ride saved without its stations still moves its day.
|
||||
const seatedDays = useMemo<PlanDay[]>(
|
||||
() => (bookendsOn ? seatNightBookends(storedDays, days, stays) : storedDays),
|
||||
[bookendsOn, storedDays, days, stays],
|
||||
() => (bookendsOn ? seatNightBookends(storedDays, days, stays, reservations) : storedDays),
|
||||
[bookendsOn, storedDays, days, stays, reservations],
|
||||
)
|
||||
|
||||
const plan = useMemo<PlanDay[]>(() => seatedDays.filter(d => standsAsDay(d.stops)), [seatedDays])
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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'
|
||||
import { MAX_DRIVE_KM } from '@trek/shared/roadtrip'
|
||||
import { haversineKm } from '../utils/geo'
|
||||
|
||||
/**
|
||||
* Real road-network geometry for road-based transport bookings (car, bus, taxi,
|
||||
|
||||
+3
-20
@@ -15,32 +15,15 @@ export function haversineKm(a: { lat: number; lng: number }, b: { lat: number; l
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a day out of a hotel and back plausibly reaches, straight-line.
|
||||
*
|
||||
* Consulted where a stay records no check-in/check-out time and the shape of the day is
|
||||
* therefore a guess (#2157): a stop inside this radius is somewhere the hotel sent you out
|
||||
* to for the day, a stop outside it is where you travelled from or to. It has to be a
|
||||
* road-day radius rather than the booking-geometry MAX_DRIVE_KM above: 2000 km spans the
|
||||
* whole of Europe, so every car journey on the continent passed as a day trip and kept the
|
||||
* phantom hotel leg the issue reported.
|
||||
* road-day radius rather than the booking-geometry MAX_DRIVE_KM in @trek/shared: 2000 km
|
||||
* spans the whole of Europe, so every car journey on the continent passed as a day trip
|
||||
* and kept the phantom hotel leg the issue reported.
|
||||
*/
|
||||
export const MAX_DAY_TRIP_KM = 150
|
||||
|
||||
|
||||
Reference in New Issue
Block a user