diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts index 4b0402988..567446c10 100644 --- a/client/src/components/Map/dayRoutePlan.ts +++ b/client/src/components/Map/dayRoutePlan.ts @@ -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'] diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 085f5768c..110d1f9b2 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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' diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 9a4768684..4f8de1291 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -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 = {}) => ({ + 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]) + }) +}) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index fe1ff1527..8ff23ba9d 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -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( - () => (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(() => seatedDays.filter(d => standsAsDay(d.stops)), [seatedDays]) diff --git a/client/src/hooks/useTransportRoutes.ts b/client/src/hooks/useTransportRoutes.ts index 74a79422d..02f7ea7a4 100644 --- a/client/src/hooks/useTransportRoutes.ts +++ b/client/src/hooks/useTransportRoutes.ts @@ -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, diff --git a/client/src/utils/geo.ts b/client/src/utils/geo.ts index e1eafbd7e..fb8b00732 100644 --- a/client/src/utils/geo.ts +++ b/client/src/utils/geo.ts @@ -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 diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index cecb0d276..216e22273 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -263,8 +263,11 @@ export class RoadtripPlanService { }); // A day after a booked night starts at the stay and a day before one ends there, by // the rule the planner runs in the browser, and only while the trip (or the preview's - // own settings) has it switched on. Everything below reads the seated days. - const plan = hotelBookendsOn(preferences) ? seatNightBookends(stored, context.days, context.stays) : stored; + // own settings) has it switched on. The rides go in with or without their stations, + // the way the browser hands over its bookings. Everything below reads the seated days. + const plan = hotelBookendsOn(preferences) + ? seatNightBookends(stored, context.days, context.stays, context.carriers) + : stored; const allLegs: Record = {}; const snapByDay: Record> = {}; const missedByDay: Record = {}; diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts index 066477649..0f22aec69 100644 --- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -49,7 +49,7 @@ export class RoadtripPlanningMcp { @Tool({ name: 'calculate_roadtrip', description: - 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out is a label, never a departure time. A day between two nights in the same stay with no visit has no drive. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', + 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. The departure terminal is pinned a check-in ahead of the timetable departure (an hour for a flight); reaching it after that is a late warning with the full minutes, however many, and the ride keeps its own day, so the drive after the arrival terminal goes on from the timetable arrival. On a day a ride leaves and lands on, the stop of a stay checked into that day comes after the arrival terminal when the stay lies nearer to it than to the departure terminal: a check-in is the earliest arrival, not a time that seats the stay ahead of the ride, and it pins the stay only when the ride lands before it. A time set on the stop itself keeps its seat. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut, and checkIn on the evening a stay begins when it has a check-in time}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out and check-in are labels, never a departure or arrival time. A day between two nights in the same stay with no visit has no drive. Nor does a day with nothing on it but the check-in of the next stay when a flight, train, ferry, cruise or bus is booked on that day, with or without located terminals: the booking is the move between the two stays. A stay is never joined by road to a terminal more than 2000 km away in a straight line. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', inputSchema: roadtripPlanRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, diff --git a/server/tests/unit/nest/roadtrip-plan.service.test.ts b/server/tests/unit/nest/roadtrip-plan.service.test.ts index bd0da8238..d0b5f2801 100644 --- a/server/tests/unit/nest/roadtrip-plan.service.test.ts +++ b/server/tests/unit/nest/roadtrip-plan.service.test.ts @@ -458,6 +458,7 @@ describe('a booked night at both ends of its days', () => { checkingOut: false, checkingIn: true, checkOut: null, + checkIn: '14:00', }); expect(back).toMatchObject({ name: 'Getaway', lat: GETAWAY.lat, lng: GETAWAY.lng, stopType: 'hotel', time: null }); expect(back.assignmentId).toBe(bookendAssignmentId(days[0].id, 'evening')); @@ -591,4 +592,75 @@ describe('a booked night at both ends of its days', () => { }); expect(seated.failures[99].toAssignmentId).toBeLessThan(-6_000_000_000); }); + + /** + * 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, the booking's slot seeded behind it. + */ + function trip45(settings?: RoadtripPreferences) { + const t = trip(settings); + const days = [1, 2, 3, 4, 5].map(() => createDay(db, t.trip.id)); + const atlantic = visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 }); + const munich = visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 }); + const stays = { check_in: '15:00', check_out: '11:00' }; + const hamburgStay = createDayAccommodation(db, t.trip.id, atlantic.place.id, days[0].id, days[2].id, stays); + createDayAccommodation(db, t.trip.id, munich.place.id, days[2].id, days[4].id, stays); + const flight = Number( + db + .prepare( + `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time, day_plan_position) + VALUES (?, 'LH 2078 HAM-MUC', 'flight', ?, ?, '2026-11-04T15:15', '2026-11-04T17:20', 0.5)`, + ) + .run(t.trip.id, days[2].id, days[2].id).lastInsertRowid, + ); + const endpoint = db.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + endpoint.run(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823); + endpoint.run(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861); + return { ...t, flightDay: days[2], hamburgStay }; + } + + it('flies the day of the Munich check-in out of Hamburg and ends it at the Munich hotel, with no road between the cities (trip 45)', async () => { + // Every run the router is asked for lies north of 53 or south of 49. + const oneCity = (router: ReturnType) => + lats(router).every((run) => run.every((lat: number) => lat > 53) || run.every((lat: number) => lat < 49)); + + const off = trip45({}); + const stored = await off.plans.calculate(off.trip.id, off.user.id); + const offDay = stored.calculated.days.find((d) => d.dayId === off.flightDay.id)!; + expect(shape(offDay.stops)).toEqual(['Hamburg (HAM)', 'Munich (MUC)', 'Hotel Bayerischer Hof']); + // At the airport by the check-in, at the hotel an hour after landing, and late for nothing. + expect(offDay.schedule.entries.map((e) => e.arrival)).toEqual(['14:15', '17:20', '18:20']); + expect(offDay.schedule.warnings).toEqual([]); + expect(oneCity(off.router)).toBe(true); + + const on = trip45(); + const seated = await on.plans.calculate(on.trip.id, on.user.id); + const onDay = seated.calculated.days.find((d) => d.dayId === on.flightDay.id)!; + expect(shape(onDay.stops)).toEqual([ + `morning:${on.hamburgStay.id}`, + 'Hamburg (HAM)', + 'Munich (MUC)', + 'Hotel Bayerischer Hof', + ]); + expect(oneCity(on.router)).toBe(true); + expect(seated.failures).toEqual([]); + }); + + it('drives no road between two stays on a day a flight saved without its airports moves the traveller (#2476)', async () => { + const { user, trip: created, router, plans, days, stayA } = cam(); + db.prepare( + "INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time) VALUES (?, 'Flight', 'flight', ?, ?, '12:00')", + ).run(created.id, days[1].id, days[1].id); + + const { calculated, failures } = await plans.calculate(created.id, user.id); + + expect(calculated.days.map((d) => shape(d.stops))).toEqual([ + ['Getaway', 'Lookout', 'Falls', `evening:${stayA.id}`], + ['Wallinga'], + ]); + expect(lats(router)).toEqual([[GETAWAY.lat, -33.73, -33.65, GETAWAY.lat]]); + expect(failures).toEqual([]); + }); }); diff --git a/shared/src/roadtrip/carriers.spec.ts b/shared/src/roadtrip/carriers.spec.ts index 93ca9524a..b94ce66b0 100644 --- a/shared/src/roadtrip/carriers.spec.ts +++ b/shared/src/roadtrip/carriers.spec.ts @@ -1,5 +1,5 @@ /** - * ROADTRIP-CARRIERS-001..031: a booking the traveller rides becomes a seam in the drive, + * ROADTRIP-CARRIERS-001..035: a booking the traveller rides becomes a seam in the drive, * and a hire car puts its desks on it. * * The road ends at the terminal the ride leaves from and starts again at the one it @@ -11,7 +11,8 @@ * adds the least road, as far as the clock leaves a choice (#2461). From 028 on: a ride * that lands on a later day is not seated there by the slot seeded on the day it left. * From 030 on: a booked night at a day's edge takes no via, and a terminal says which - * edge of the day it holds. + * edge of the day it holds. From 032 on: tonight's stay at the far end of a ride within + * one day waits behind its landing. */ import { assembleRoadtrip } from './assemble'; import { @@ -868,3 +869,135 @@ describe('a booked night at the edge of a day', () => { ]); }); }); + +/** + * The flight day behind the report: the hotel in Munich is booked for the night from 15:00, + * and LH 2078 leaves Hamburg at 15:15. The day plan seeded the booking's slot behind the + * hotel (0.5), and the hotel's own stop is the only one the day stores. + */ +const HAM_AIRPORT = { lat: 53.630402, lng: 9.98823 }; +const MUC_AIRPORT = { lat: 48.353802, lng: 11.7861 }; +const MUNICH_HOTEL = { lat: 48.1403, lng: 11.5732 }; +const SPEICHERSTADT = { lat: 53.5436, lng: 9.9885 }; +const PINAKOTHEK = { lat: 48.1482, lng: 11.57 }; + +const lh2078 = (over: Partial = {}): CarrierBooking => ({ + id: 77, + type: 'flight', + title: 'LH 2078 HAM-MUC', + day_id: 1, + end_day_id: 1, + 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', ...HAM_AIRPORT }, + { role: 'to', sequence: 1, name: 'Munich (MUC)', code: 'MUC', ...MUC_AIRPORT }, + ], + ...over, +}); + +/** Tonight's stay on its check-in day, the way both planners read the booking's own stop. */ +const munichHotel = (ownerIndex: number, over: Partial = {}): RoadtripStop => + stop({ + ownerIndex, + name: 'Munich hotel', + ...MUNICH_HOTEL, + night: true, + checkInTime: '15:00', + dwellMinutes: 60, + stopType: 'hotel', + ...over, + }); +const speicherstadt = (ownerIndex: number, time: string | null = null): RoadtripStop => + stop({ ownerIndex, name: 'Speicherstadt', ...SPEICHERSTADT, time }); + +describe('tonight’s stay at the far end of a ride within one day', () => { + it('ROADTRIP-CARRIERS-032: waits behind the landing, whatever its check-in or the booking’s slot says', () => { + // Read by its check-in, the hotel sat ahead of the 15:15 departure: the drive went from + // the Munich hotel to Hamburg airport, only to fly back to Munich. + const seam = carrierSeam(lh2078())!; + expect(order(seatCarrierStops(1, [munichHotel(0)], [0], [seam]))).toEqual(['departure', 'arrival', 'Munich hotel']); + const unslotted = carrierSeam(lh2078({ day_plan_position: null }))!; + expect(order(seatCarrierStops(1, [munichHotel(0)], [0], [unslotted]))).toEqual([ + 'departure', + 'arrival', + 'Munich hotel', + ]); + // The morning in Hamburg stays ahead of the flight, timed or not, whichever of the two + // is stored first. + expect(order(seatCarrierStops(1, [speicherstadt(0, '10:00'), munichHotel(1)], [0, 1], [seam]))).toEqual([ + 'Speicherstadt', + 'departure', + 'arrival', + 'Munich hotel', + ]); + const hotelFirst = seatCarrierStops(1, [munichHotel(0), speicherstadt(1)], [0, 1], [unslotted]); + expect(order(hotelFirst)).toEqual(['Speicherstadt', 'departure', 'arrival', 'Munich hotel']); + // The terminals stand for the stored stop behind the one they follow, the hotel keeps + // its own index. + expect(hotelFirst.map((s) => s.ownerIndex)).toEqual([1, 2, 2, 0]); + // An evening in Munich comes after the hotel. + const museum = stop({ ownerIndex: 1, name: 'Pinakothek', ...PINAKOTHEK, time: '19:00' }); + expect(order(seatCarrierStops(1, [munichHotel(0), museum], [0, 1], [seam]))).toEqual([ + 'departure', + 'arrival', + 'Munich hotel', + 'Pinakothek', + ]); + }); + + it('ROADTRIP-CARRIERS-033: a check-in the ride lands after holds the stay to nothing, one it lands before still does', () => { + // Checked in from 15:00 and reached from a landing at 17:20: held as a pin, the hotel + // read 15:00 behind the landing and late by the drive from the airport. + const [, , afternoon] = seatCarrierStops(1, [munichHotel(0)], [0], [carrierSeam(lh2078())!]); + expect(afternoon).toMatchObject({ name: 'Munich hotel', checkInTime: null, night: true, ownerIndex: 0 }); + const morning = carrierSeam( + lh2078({ reservation_time: '2026-11-04T07:00', reservation_end_time: '2026-11-04T08:05' }), + )!; + const [, , waited] = seatCarrierStops(1, [munichHotel(0)], [0], [morning]); + expect(waited).toMatchObject({ name: 'Munich hotel', checkInTime: '15:00' }); + }); + + it('ROADTRIP-CARRIERS-034: a stay nearer the departure, one with a time of its own and a ride landing tomorrow keep the clock’s seat', () => { + const seam = carrierSeam(lh2078())!; + const hamburgHotel = munichHotel(0, { name: 'Hamburg hotel', ...SPEICHERSTADT }); + expect(order(seatCarrierStops(1, [hamburgHotel], [0], [seam]))).toEqual(['Hamburg hotel', 'departure', 'arrival']); + // A time somebody typed on the stop is theirs, not the booking's. + expect(order(seatCarrierStops(1, [munichHotel(0, { time: '12:00' })], [0], [seam]))).toEqual([ + 'Munich hotel', + 'departure', + 'arrival', + ]); + const overnight = carrierSeam(lh2078({ end_day_id: 2, reservation_end_time: '2026-11-05T07:00' }))!; + expect(order(seatCarrierStops(1, [munichHotel(0)], [0], [overnight]))).toEqual(['Munich hotel', 'departure']); + }); + + it('ROADTRIP-CARRIERS-035: on a day out and back, the stay waits behind the ride that brings the traveller back', () => { + const out = carrierSeam( + lh2078({ day_plan_position: null, reservation_time: '08:00', reservation_end_time: '09:05' }), + )!; + const back = carrierSeam( + lh2078({ + id: 78, + title: 'LH 2079 MUC-HAM', + day_plan_position: null, + reservation_time: '18:00', + reservation_end_time: '19:05', + endpoints: [ + { role: 'from', sequence: 0, name: 'Munich (MUC)', code: 'MUC', ...MUC_AIRPORT }, + { role: 'to', sequence: 1, name: 'Hamburg (HAM)', code: 'HAM', ...HAM_AIRPORT }, + ], + }), + )!; + const hamburgHotel = munichHotel(0, { name: 'Hamburg hotel', ...SPEICHERSTADT }); + const seated = seatCarrierStops(1, [hamburgHotel], [0], [out, back]); + expect(seated.map((s) => (s.carrier ? `${s.carrier.reservationId}:${s.carrier.role}` : s.name))).toEqual([ + '77:departure', + '77:arrival', + '78:departure', + '78:arrival', + 'Hamburg hotel', + ]); + }); +}); diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts index e52a7eda2..5547e24ff 100644 --- a/shared/src/roadtrip/carriers.ts +++ b/shared/src/roadtrip/carriers.ts @@ -474,6 +474,40 @@ function terminalStop( }; } +/** + * Tonight's stays that wait behind a ride of their check-in day, by their index among the + * day's stops, each with the rides that land nearer it than they leave. + * + * A booked night's stop carries its check-in as its clock, and the clock seats every stop + * ahead of a ride that leaves after it. But the check-in is the earliest the room is + * ready, not an hour anybody keeps: a hotel in Munich checked into from three sat ahead of + * a flight out of Hamburg at a quarter past, and the drive went from the hotel to Hamburg + * airport to fly back to Munich. The day plan never met this, because it does not list the + * stay's stop at all. So a stay nearer where a ride of the day lands than where it leaves + * goes behind the landing, whatever its check-in or the booking's slot says. A stop with a + * time of its own keeps the seat that time gives it: that hour somebody chose. + */ +function staysBehindRides(stops: readonly RoadtripStop[], rides: readonly CarrierSeam[]): Map { + const behind = new Map(); + stops.forEach((stop, index) => { + if (!stop.night || parseClock(stop.time) !== null) return; + const landing = rides.filter((ride) => haversineKm(stop, ride.arrival!) < haversineKm(stop, ride.departure)); + if (landing.length) behind.set(index, landing); + }); + return behind; +} + +/** + * The stay as the ride it waits behind reaches it. Its check-in still holds it when the + * ride lands before the room is ready. One that opened while the traveller was on the way + * pins nothing: held, it read the hotel at 15:00 behind a landing at 17:20, and late. + */ +function reachedFrom(stay: RoadtripStop, ride: CarrierSeam): RoadtripStop { + const lands = parseClock(ride.arrival?.clock); + const checkIn = parseClock(stay.checkInTime); + return lands !== null && checkIn !== null && checkIn < lands ? { ...stay, checkInTime: null } : stay; +} + /** Whether a terminal opens a day it has no timed stop before it on: an arrival or a pick-up. */ export function opensTheDay(role: CarrierTerminal['role'] | undefined): boolean { return role === 'arrival' || role === 'pickup'; @@ -496,6 +530,8 @@ export function closesTheDay(role: CarrierTerminal['role'] | undefined): boolean * (`rideSeatAfter`). Only the terminal moves, though. The stops keep the order they are * stored in, which is the order the road trip has always driven them in, and the * terminal is slotted in behind the stop that precedes it in the day plan's reading. + * The one stop that moves is tonight's stay at the far end of such a ride, which waits + * behind the landing (`staysBehindRides`). * * A ride that leaves and lands on the same day seats its arrival right behind its * departure: nothing is visited in between. One that lands on a later day seats the @@ -521,16 +557,29 @@ export function seatCarrierStops( (s) => s.arrival?.dayId === dayId && (s.departure.dayId !== dayId || s.kind === 'rental'), ); if (!departing.length && !arriving.length) return stops; + // Tonight's stays that wait behind a ride are left out of the reading, so neither a + // check-in nor the booking's slot puts them back ahead of it, and the ride is seated + // among the other stops alone. + const behind = staysBehindRides( + stops, + departing.filter((s) => s.kind === 'ride' && s.arrival?.dayId === dayId), + ); type Item = | { kind: 'stop'; index: number; key: number; minutes: number | null } | { kind: 'end'; seam: CarrierSeam; role: CarrierTerminal['role']; key: number; minutes: number | null }; - const base: Item[] = stops.map((stop, index) => ({ - kind: 'stop', - index, - key: orderIndex[index] ?? index, - minutes: parseClock(stop.time ?? stop.checkInTime ?? null), - })); + const base: Item[] = stops.flatMap((stop, index): Item[] => + behind.has(index) + ? [] + : [ + { + kind: 'stop', + index, + key: orderIndex[index] ?? index, + minutes: parseClock(stop.time ?? stop.checkInTime ?? null), + }, + ], + ); const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [ ...departing.map((seam) => ({ seam, role: rolesOf(seam).start, end: seam.departure })), ...arriving.map((seam) => ({ seam, role: rolesOf(seam).end, end: seam.arrival! })), @@ -589,17 +638,25 @@ export function seatCarrierStops( if (item.kind === 'stop') lastStop = item.index; else slots.push({ seam: item.seam, role: item.role, after: lastStop }); } + // A stay waits behind the last of its rides to leave, on a day out and back the one home. + const waiting = new Map(); + for (const [index, rides] of behind) { + const ride = slots.filter((s) => s.role === 'departure' && rides.includes(s.seam)).pop()!.seam; + waiting.set(ride, [...(waiting.get(ride) ?? []), index]); + } const out: RoadtripStop[] = []; const emit = (slot: (typeof slots)[number]): void => { - const insertAt = out.filter((s) => !s.carrier).length; + // The index of the stored stop that follows the one the terminal is seated behind. + const insertAt = slot.after + 1; out.push(terminalStop(slot.seam, slot.role, dayId, insertAt)); if (slot.role === 'departure' && slot.seam.arrival?.dayId === dayId) { out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt)); + for (const index of waiting.get(slot.seam) ?? []) out.push(reachedFrom(stops[index]!, slot.seam)); } }; for (const slot of slots.filter((s) => s.after < 0)) emit(slot); stops.forEach((stop, index) => { - out.push(stop); + if (!behind.has(index)) out.push(stop); for (const slot of slots.filter((s) => s.after === index)) emit(slot); }); return out; diff --git a/shared/src/roadtrip/corridor.spec.ts b/shared/src/roadtrip/corridor.spec.ts index f98386400..c23cddd24 100644 --- a/shared/src/roadtrip/corridor.spec.ts +++ b/shared/src/roadtrip/corridor.spec.ts @@ -5,12 +5,14 @@ import { drivenPieces, haversineKm, inRiddenRange, + MAX_DRIVE_KM, pointAtMeters, projectOntoRoute, rideGaps, riddenRanges, sliceAtMeters, simplifyLine, + withinDriveRange, type LatLng, } from './corridor'; import type { RoadtripStop } from './planning-types'; @@ -33,6 +35,18 @@ describe('haversineKm', () => { }); }); +describe('withinDriveRange', () => { + it('joins by road what lies up to MAX_DRIVE_KM apart as the crow flies, and nothing further', () => { + expect(MAX_DRIVE_KM).toBe(2000); + expect(withinDriveRange(BERLIN, PRAGUE)).toBe(true); + // Paris to New York is a flight, not a leg of anybody's day. + expect(withinDriveRange({ lat: 48.8566, lng: 2.3522 }, { lat: 40.6413, lng: -73.7781 })).toBe(false); + // A degree of latitude is about 111 km: 17.9 of them fall short of the limit, 18.1 pass it. + expect(withinDriveRange(BERLIN, { lat: BERLIN.lat + 17.9, lng: BERLIN.lng })).toBe(true); + expect(withinDriveRange(BERLIN, { lat: BERLIN.lat + 18.1, lng: BERLIN.lng })).toBe(false); + }); +}); + describe('distanceToSegmentKm', () => { const a: LatLng = { lat: 52, lng: 13 }; const b: LatLng = { lat: 52, lng: 14 }; diff --git a/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts index 605bc89b1..bf9191079 100644 --- a/shared/src/roadtrip/corridor.ts +++ b/shared/src/roadtrip/corridor.ts @@ -26,6 +26,22 @@ export function haversineKm(a: LatLng, b: LatLng): number { return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } +/** + * Beyond this straight-line distance a leg is not a drive anyone makes between two stops: + * it is a booking's terminal that landed next to a local stop or a hotel, and the road + * router answers such a pair with NoRoute (#2133). + * + * Only ever applied to a leg that touches a booking's terminal. Two real places 2000 km + * apart are a long drive somebody planned; an airport 2000 km from the stop before it + * never is. The day plan and the road trip both read it from here. + */ +export const MAX_DRIVE_KM = 2000; + +/** Whether two points are close enough to be joined by a road leg at all. */ +export function withinDriveRange(a: LatLng, b: LatLng): boolean { + return haversineKm(a, b) <= MAX_DRIVE_KM; +} + export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number { return projectOnSegment(p, a, b).distanceKm; } diff --git a/shared/src/roadtrip/nightBookends.spec.ts b/shared/src/roadtrip/nightBookends.spec.ts index 8e4355b39..f0d7a015a 100644 --- a/shared/src/roadtrip/nightBookends.spec.ts +++ b/shared/src/roadtrip/nightBookends.spec.ts @@ -1,13 +1,17 @@ /** - * ROADTRIP-BOOKENDS-001..028: a booked night stands at both ends of the days around it. + * ROADTRIP-BOOKENDS-001..032: a booked night stands at both ends of the days around it. * * Pinned here: which hotel a day wakes up in and which it sleeps in, where the two are * seated and where they are not (the hotel already there, a landing, a departure, a hire * car's desk, a day with nothing of its own), what a bookend carries and what it never * does (a clock), that the choice does not depend on the order the stays come in, and - * that a night spent at one hotel is a leg going nowhere. + * that a night spent at one hotel is a leg going nowhere. From 029 on: a day moved by a + * ride the road does not see, a terminal out of a drive's reach, and the flight day of + * the report read from the stored day to its nights. */ -import { terminalAssignmentId } from './carriers'; +import { assembleRoadtrip } from './assemble'; +import { carrierLegsFor, carrierSeam, seatCarrierStops, terminalAssignmentId, type CarrierBooking } from './carriers'; +import { haversineKm } from './corridor'; import { bookendAssignmentId, bookendStaysOf, @@ -19,6 +23,7 @@ import { } from './nightBookends'; import type { CarrierTerminal, PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; import { splitIntoRuns, standsAsDay } from './roadtripModel'; +import { roadtripLegKey } from './routeRun'; import { describe, expect, it } from 'vitest'; @@ -138,6 +143,15 @@ describe('a check-in with places, then a transfer day with nothing of its own', expect(seated[1]!.stops).toHaveLength(2); }); + it('ROADTRIP-BOOKENDS-032: the check-in evening carries the hour the room is ready as a label, and nothing else does', () => { + const ready = seatNightBookends(plan, days, [{ ...getaway, check_in: '15:00' }, wallinga]); + const back = ready[0]!.stops[3]!; + expect(back.bookend).toMatchObject({ checkingIn: true, checkIn: '15:00' }); + // A label: the row may say it, the chain never waits for it. + expect(back).toMatchObject({ time: null, checkInTime: null }); + expect(ready[1]!.stops[0]!.bookend).not.toHaveProperty('checkIn'); + }); + it('ROADTRIP-BOOKENDS-003: a day that is only a check-out gets no drive, and keeps its object', () => { expect(seated[2]!.stops).toEqual([]); expect(seated[2]).toBe(plan[2]); @@ -537,3 +551,212 @@ describe('the drive out of the morning hotel', () => { expect(plain!.stops[0]!.legMode).toBeNull(); }); }); + +describe('a transfer day moved by a ride the road does not see', () => { + const a = stay(1, D1, D2, GETAWAY); + const b = stay(2, D2, D3, WALLINGA); + const ride = (over: Partial = {}) => ({ type: 'flight', day_id: D2, end_day_id: D2, ...over }); + const next = () => stayStop(D2, 0, WALLINGA, { name: 'Wallinga', checkInTime: '15:00' }); + + it('ROADTRIP-BOOKENDS-029: a flight, train, ferry, cruise or bus on the day joins the two stays, not a road (#2476)', () => { + // Saved without both stations, the booking seats no terminal and the day was the drive + // from one stay to the next, across the sea if need be. The day plan draws no line + // for it, and neither does this: the day is the check-in it was with the switch off. + const plan = [planDay(D2, [next()])]; + const seated = seatNightBookends(plan, days, [a, b], [ride()]); + expect(shape(seated[0])).toEqual(['Wallinga']); + expect(seated).toBe(plan); + // With the stay's own stop gone, and with the booking spanning the day on either side. + const spanning = ride({ type: 'train', day_id: D1, end_day_id: D3 }); + const [empty] = seatNightBookends([planDay(D2, [])], days, [a, b], [spanning]); + expect(empty!.stops).toEqual([]); + // A booking on another day, a hire car, a taxi, or a place of the day's own leave the drive. + for (const other of [ride({ day_id: D3, end_day_id: D3 }), ride({ type: 'car' }), ride({ type: 'taxi' })]) + expect(shape(seatNightBookends(plan, days, [a, b], [other])[0])).toEqual(['morning:1', 'Wallinga']); + const withPlace = [planDay(D2, [visit(D2, 0, { name: 'P' }), next()])]; + expect(shape(seatNightBookends(withPlace, days, [a, b], [ride()])[0])).toEqual(['morning:1', 'P', 'Wallinga']); + }); +}); + +describe('a terminal beyond a drive from the hotel', () => { + const PARIS: [number, number] = [48.8566, 2.3522]; + const CDG = { lat: 49.0097, lng: 2.5479 }; + const JFK = { lat: 40.6413, lng: -73.7781 }; + const paris = stay(7, D1, D4, PARIS); + const at = (role: CarrierTerminal['role'], where: { lat: number; lng: number }) => ({ + ...terminal(D2, 0, role), + ...where, + }); + + it('ROADTRIP-BOOKENDS-030: is not joined to the hotel by road, the way the day plan draws no leg to it (#2133)', () => { + // A one-way flight to New York while the Paris stay still runs: the evening drove back + // from JFK to Paris. + const [away] = seatNightBookends([planDay(D2, [at('departure', CDG), at('arrival', JFK)])], days, [paris]); + expect(shape(away)).toEqual(['morning:7', 'departure', 'arrival']); + // And the morning drove from Paris to JFK for the flight back. + const [home] = seatNightBookends([planDay(D2, [at('departure', JFK), at('arrival', CDG)])], days, [paris]); + expect(shape(home)).toEqual(['departure', 'arrival', 'evening:7']); + }); +}); + +/** + * Trip 45 on the dev instance, day 3, the flight day behind the report, with the rows the + * API returned: the Munich hotel's own stop (order 0, checked into from 15:00), and LH 2078 + * leaving Hamburg at 15:15 with the slot the day plan seeded behind the hotel. Read the way + * both planners read it, from the stored day through the seated terminals to the nights at + * its edges, and timed the way the rail times it. + */ +describe('the flight from Hamburg to the Munich hotel (trip 45, day 3)', () => { + const tripDays = [1070, 1071, 1072, 1073, 1074].map((id, i) => ({ id, day_number: i + 1 })); + const ATLANTIC = { lat: 53.5573, lng: 10.0056 }; + const BAYERISCHER_HOF = { lat: 48.1403, lng: 11.5732 }; + const trip45Stays = bookendStaysOf( + [ + { + id: 33, + place_id: 238, + start_day_id: 1070, + end_day_id: 1072, + place_lat: ATLANTIC.lat, + place_lng: ATLANTIC.lng, + place_name: 'Hotel Atlantic Hamburg', + check_out: '11:00', + }, + { + id: 34, + place_id: 239, + start_day_id: 1072, + end_day_id: 1074, + place_lat: BAYERISCHER_HOF.lat, + place_lng: BAYERISCHER_HOF.lng, + place_name: 'Hotel Bayerischer Hof', + check_out: '11:00', + }, + ], + [ + { id: 75, accommodation_id: 33 }, + { id: 76, accommodation_id: 34 }, + ], + ); + const lh2078: CarrierBooking = { + id: 77, + type: 'flight', + title: 'LH 2078 HAM-MUC', + day_id: 1072, + end_day_id: 1072, + reservation_time: '2026-11-04T15:15', + reservation_end_time: '2026-11-04T17:20', + metadata: + '{"departure_airport":"HAM","departure_timezone":"Europe/Berlin","arrival_airport":"MUC","arrival_timezone":"Europe/Berlin"}', + day_plan_position: 0.5, + day_positions: null, + endpoints: [ + { role: 'from', sequence: 0, name: 'Hamburg (HAM)', code: 'HAM', lat: 53.630402, lng: 9.98823 }, + { role: 'to', sequence: 1, name: 'Munich (MUC)', code: 'MUC', lat: 48.353802, lng: 11.7861 }, + ], + }; + // Assignment 221. + const hotelStop: RoadtripStop = { + assignmentId: 221, + ownerDayId: 1072, + ownerIndex: 0, + placeId: 239, + name: 'Hotel Bayerischer Hof', + ...BAYERISCHER_HOF, + time: null, + leaveAt: null, + checkInTime: '15:00', + night: true, + dwellMinutes: 60, + endDay: false, + legMode: null, + incomingLegMode: null, + stopType: 'hotel', + fillPercent: null, + }; + const stored: PlanDay = { + dayId: 1072, + dayNumber: 3, + date: '2026-11-04', + title: null, + stops: seatCarrierStops(1072, [hotelStop], [0], [carrierSeam(lh2078)!]), + }; + const read = (day: PlanDay | undefined) => + (day?.stops ?? []).map((s) => (s.bookend ? `${s.bookend.phase}:${s.name}` : (s.carrier?.code ?? s.name))); + /** Each road half an hour, the ride its timetable, and the day timed the way the rail times it. */ + const timed = (day: PlanDay) => { + const legs: Record = {}; + day.stops.slice(0, -1).forEach((from, i) => { + const to = day.stops[i + 1]!; + const ride = carrierLegsFor([from, to], from.legMode ?? 'driving', () => 0, roadtripLegKey); + Object.assign( + legs, + ride ?? { + [roadtripLegKey(from, to)]: { + seg: { + from: [from.lat, from.lng], + to: [to.lat, to.lng], + mid: [from.lat, from.lng], + distance: 20_000, + duration: 30 * 60, + mode: 'driving', + distanceText: '', + drivingText: '', + walkingText: '', + }, + line: [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + vias: [], + }, + }, + ); + }); + return assembleRoadtrip({ + plan: [day], + quietDays: [], + window: null, + distanceUnit: 'metric', + allLegs: legs, + snapByDay: {}, + missedByDay: {}, + loading: false, + limits: { legMinutes: null, dayMinutes: null, rangeKm: null }, + vehicleKind: null, + connectDays: false, + boundaries: [], + labels: { start: 'go', end: 'stop' }, + }).days[0]!; + }; + /** Every pair of neighbours joined by road, as the straight line between them. */ + const roadKm = (day: PlanDay) => + day.stops + .slice(0, -1) + .map((from, i) => [from, day.stops[i + 1]!] as const) + .filter(([from, to]) => !(from.carrier?.role === 'departure' && to.carrier?.role === 'arrival')) + .map(([from, to]) => haversineKm(from, to)); + + it('ROADTRIP-BOOKENDS-031: flies from Hamburg and ends at the Munich hotel, with no road between the cities, switch on or off', () => { + // Off: the stored day with the flight seated. Read by its check-in, the hotel sat ahead + // of the flight and the day drove 490 miles from Munich to Hamburg airport. + expect(read(stored)).toEqual(['HAM', 'MUC', 'Hotel Bayerischer Hof']); + // On: out of the Hamburg hotel that morning, and nothing seated behind the Munich one. + const [seated] = seatNightBookends([stored], tripDays, trip45Stays, [lh2078]); + expect(read(seated)).toEqual(['morning:Hotel Atlantic Hamburg', 'HAM', 'MUC', 'Hotel Bayerischer Hof']); + + for (const day of [stored, seated!]) { + expect(Math.max(...roadKm(day))).toBeLessThan(50); + const { schedule } = timed(day); + // At the airport by the check-in, off on the timetable, and at the hotel from the + // landing: nothing is late, and the hotel is not held to a check-in long past. + expect(schedule.warnings).toEqual([]); + expect(schedule.entries.slice(-3).map((e) => [e.arrival, e.departure, e.anchored])).toEqual([ + ['14:15', '15:15', true], + ['17:20', '17:20', true], + ['17:50', '18:50', false], + ]); + } + expect(timed(seated!).schedule.entries[0]).toMatchObject({ arrival: '13:45', departure: '13:45' }); + }); +}); diff --git a/shared/src/roadtrip/nightBookends.ts b/shared/src/roadtrip/nightBookends.ts index 89507c1e5..5764dd7b1 100644 --- a/shared/src/roadtrip/nightBookends.ts +++ b/shared/src/roadtrip/nightBookends.ts @@ -1,5 +1,12 @@ -import { getDayBookendHotels, getDayOrder, hotelIsTheStop, type DayRef } from '../day/stay-bookends'; -import { closesTheDay, opensTheDay } from './carriers'; +import { + getDayBookendHotels, + getDayOrder, + hotelIsTheStop, + isDayInAccommodationRange, + type DayRef, +} from '../day/stay-bookends'; +import { closesTheDay, isCarrierType, opensTheDay, type CarrierBooking } from './carriers'; +import { withinDriveRange } from './corridor'; import { stationary } from './dayWindow'; import type { BookendPhase, PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; @@ -30,6 +37,7 @@ export interface BookendStay { place_lat?: number | null; place_lng?: number | null; place_name?: string | null; + check_in?: string | null; check_out?: string | null; reservation_id?: number | null; } @@ -118,14 +126,16 @@ function bookendStop( legMode: string | null, ): RoadtripStop { const checkingOut = phase === 'morning' && stay.end_day_id === day.dayId; + const checkingIn = phase === 'evening' && stay.start_day_id === day.dayId; return { bookend: { phase, accommodationId: stay.id, reservationId: stay.reservation_id ?? null, checkingOut, - checkingIn: phase === 'evening' && stay.start_day_id === day.dayId, + checkingIn, checkOut: checkingOut ? (stay.check_out ?? null) : null, + ...(checkingIn && stay.check_in ? { checkIn: stay.check_in } : {}), }, assignmentId: bookendAssignmentId(day.dayId, phase), ownerDayId: day.dayId, @@ -153,6 +163,14 @@ function bookendStop( const sameSpot = (a: BookendStay | undefined, b: BookendStay | undefined): boolean => !!a && !!b && a.place_lat === b.place_lat && a.place_lng === b.place_lng; +/** + * Whether the hotel is joined by road to the stop beside it. Not to a terminal more than a + * drive away: the traveller flew there, and the router answers the pair with no route. The + * day plan draws no hotel leg to such a terminal either (#2133). + */ +const withinReach = (hotel: BookendStay, stop: RoadtripStop | undefined): boolean => + !stop?.carrier || withinDriveRange({ lat: hotel.place_lat!, lng: hotel.place_lng! }, stop); + /** * The plan's days with the night before and the night after seated at their edges. * @@ -168,11 +186,23 @@ const sameSpot = (a: BookendStay | undefined, b: BookendStay | undefined): boole * morning at one hotel and an evening at another is the drive between them, while a day * between two nights in one hotel, or a day that is only its check-out, is not driven. * + * Neither is seated beside a terminal more than a drive away (`withinReach`). And a day + * with nothing of its own but tonight's stay gets neither when a flight, train, ferry, + * cruise or bus is booked across it: that booking is the move from one stay to the other. + * It seats no terminal on the day, most often because it was saved without both of them, + * so the road from one hotel to the next is the stretch nobody drove, and the day plan + * draws no line for it either (#2476). `bookings` are the trip's, read for those rides. + * * So a day with a bookend always has two stops or more, and stands as a day for that * reason and no other. Days that change nothing come back as they were, and so does the * plan when no day changes. */ -export function seatNightBookends(plan: PlanDay[], days: readonly DayRef[], stays: readonly BookendStay[]): PlanDay[] { +export function seatNightBookends( + plan: PlanDay[], + days: readonly DayRef[], + stays: readonly BookendStay[], + bookings: readonly Pick[] = [], +): PlanDay[] { const dayOf = (id: number): DayRef | undefined => days.find((d) => d.id === id); const realNight = (stay: BookendStay): boolean => { const start = dayOf(stay.start_day_id); @@ -181,23 +211,29 @@ export function seatNightBookends(plan: PlanDay[], days: readonly DayRef[], stay }; const nights = stays.filter((stay) => stay.place_id != null && realNight(stay)).sort((a, b) => a.id - b.id); if (!nights.length) return plan; + const rides = bookings.filter((booking) => isCarrierType(booking.type) && booking.day_id != null); + const ridden = (day: DayRef): boolean => + rides.some((ride) => isDayInAccommodationRange(day, ride.day_id!, ride.end_day_id ?? ride.day_id!, days)); let changed = false; const out = plan.map((day) => { const ref = dayOf(day.dayId); if (!ref) return day; - const hotels = getDayBookendHotels(ref, days, nights); const stops = day.stops; + if (stops.every((stop) => stop.night) && ridden(ref)) return day; + const hotels = getDayBookendHotels(ref, days, nights); const first = stops[0]; const last = stops[stops.length - 1]; let head = !!hotels.morning && !!hotels.morningIsSleptHere && - !(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role))); + !(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role))) && + withinReach(hotels.morning, first); let tail = !!hotels.evening && !!hotels.eveningIsOvernight && - !(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role))); + !(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role))) && + withinReach(hotels.evening, last); if (!stops.length) head = tail = head && tail && !sameSpot(hotels.morning, hotels.evening); if (!head && !tail) return day; changed = true; diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index 49b8a58cb..109e98a88 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -53,6 +53,8 @@ export interface NightBookend { * never a time the drive leaves at (#2357). */ checkOut: string | null; + /** From when the room is ready, on the evening the stay begins. A label like `checkOut`. */ + checkIn?: string | null; } export interface RoadtripStop { diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index 077eaf60f..9db93dcb3 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1268,3 +1268,86 @@ describe('isStoredStop', () => { ).toBe(false); }); }); + +/** + * A ride's departure terminal is pinned a check-in ahead of a dated timetable. Read as a + * time of day like any other pin, a drive more than half a day behind it caught the same + * flight a day later without a word, and one that got there half a day early read the + * pin as yesterday's and came out late. + */ +describe('a departure terminal on its timetable’s day', () => { + const terminal = (role: 'departure' | 'arrival' | 'pickup', time: string, leaveAt: string | null = null) => ({ + time, + leaveAt, + checkInTime: null, + dwellMinutes: role === 'departure' ? 60 : 0, + carrier: { + reservationId: 77, + type: role === 'pickup' ? 'car' : 'flight', + role, + title: 'LH 2078', + code: null, + at: null, + }, + }); + const minutes = (m: number): number => m * 60; + + it('FE-ROADTRIP-MODEL-112: only a ride’s departure terminal is held to its day', () => { + expect(scheduleStopOf(terminal('departure', '14:15', '15:15'))).toEqual({ + anchor: '14:15', + dwellMinutes: 60, + departureAt: 915, + dated: true, + }); + expect(scheduleStopOf(terminal('arrival', '17:20'))).not.toHaveProperty('dated'); + expect(scheduleStopOf(terminal('pickup', '09:00'))).not.toHaveProperty('dated'); + expect(scheduleStopOf({ time: '14:15', dwellMinutes: 0 })).not.toHaveProperty('dated'); + }); + + it('FE-ROADTRIP-MODEL-113: reached more than half a day after its check-in, it is late by all of it, and the ride still flies that day', () => { + // Left at eight with eighteen and a half hours of road ahead: at the airport at half past + // two the next morning for a check-in at 14:15. Read as a clock, that was the next + // day's 14:15 and nothing was said. + const { entries, warnings } = computeSchedule( + [ + { anchor: '08:00', dwellMinutes: 0 }, + scheduleStopOf(terminal('departure', '14:15', '15:15')), + scheduleStopOf(terminal('arrival', '17:20')), + { anchor: null, dwellMinutes: 0 }, + ], + [minutes(18 * 60 + 30), minutes(125), minutes(30)], + ); + expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 735 }]); + // The drive after the landing runs on from the timetable, as it always has. + expect(entries.map((e) => [e.arrival, e.departure, e.dayOffset])).toEqual([ + ['08:00', '08:00', 0], + ['14:15', '15:15', 0], + ['17:20', '17:20', 0], + ['17:50', '17:50', 0], + ]); + }); + + it('FE-ROADTRIP-MODEL-114: reached long before its check-in, it is waited for on its day, not read as the day before', () => { + const { entries, warnings } = computeSchedule( + [{ anchor: '00:30', dwellMinutes: 0 }, scheduleStopOf(terminal('departure', '14:15', '15:15'))], + [minutes(10)], + ); + expect(warnings).toEqual([]); + expect(entries.map((e) => [e.arrival, e.departure, e.dayOffset])).toEqual([ + ['00:30', '00:30', 0], + ['14:15', '15:15', 0], + ]); + // An arrival terminal keeps the reading nearest the drive: a flight booked on one day + // that lands after midnight is on time the next morning. + const lateLanding = computeSchedule( + [ + { anchor: '21:00', dwellMinutes: 0 }, + scheduleStopOf(terminal('departure', '22:00', '23:00')), + scheduleStopOf(terminal('arrival', '01:00')), + ], + [minutes(30), minutes(120)], + ); + expect(lateLanding.warnings.filter((w) => w.code === 'late')).toEqual([]); + expect(lateLanding.entries[2]).toMatchObject({ arrival: '01:00', dayOffset: 1 }); + }); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index b5b2262d5..7220575a1 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -85,6 +85,11 @@ export interface ScheduleStop { /** A time somebody fixed this stop to. The chain restarts from it, and arriving * after it is being late. A booked night's check-in is one of these. */ anchor: string | null; + /** + * The anchor is a timetable's, on this stop's own day: a ride's departure terminal. Any + * other anchor is a time of day and counts at its passing nearest the drive. + */ + dated?: boolean; dwellMinutes: number | null; } @@ -161,12 +166,15 @@ function resolveArrival( anchor: number | null, cursor: number | null, dayOffset: number, + dated = false, ): { arrival: number | null; lateBy: number | null } { if (anchor === null) return { arrival: cursor, lateBy: null }; if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }; - const k = Math.round((cursor - anchor) / DAY_MINUTES); + // A dated anchor stays on its day: a flight is not caught by reaching the airport a day + // late, and one reached at dawn for the afternoon is waited for, not read as yesterday's. + const k = dated ? 0 : Math.round((cursor - anchor) / DAY_MINUTES); const anchorAt = anchor + k * DAY_MINUTES; return { arrival: anchorAt, @@ -225,15 +233,20 @@ export function leaveAfter( * A visit's end time is when the drive leaves it. It is the traveller's own statement * about this visit, unlike the check-out that used to feed `departureAt` (the LATEST a * room has to be handed back, which is why #2357 took it out of the drive). + * + * A ride's departure terminal is pinned on the day its timetable names (`dated`). The + * arrival terminal is not: the chain reaches it from the departure, on the ride's own + * minutes, and a landing booked on the day it left but past midnight is the next morning. */ export function scheduleStopOf( - stop: Pick, + stop: Pick, ): ScheduleStop { const leave = parseClock(stop.leaveAt); return { anchor: stop.time ?? stop.checkInTime ?? null, dwellMinutes: stop.dwellMinutes, ...(leave === null ? {} : { departureAt: leave }), + ...(stop.carrier?.role === 'departure' ? { dated: true } : {}), }; } @@ -266,7 +279,7 @@ export function computeSchedule( for (let i = 0; i < stops.length; i++) { const stop = stops[i]!; const anchor = parseClock(stop.anchor); - const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset); + const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset, stop.dated); if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }); if (arrival === null) {