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:
Maurice
2026-09-24 10:12:01 +02:00
parent 3062df153f
commit e64ade010f
18 changed files with 784 additions and 52 deletions
+135 -2
View File
@@ -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> = {}): 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> = {}): 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',
]);
});
});
+65 -8
View File
@@ -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<number, CarrierSeam[]> {
const behind = new Map<number, CarrierSeam[]>();
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<CarrierSeam, number[]>();
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;
+14
View File
@@ -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 };
+16
View File
@@ -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;
}
+226 -3
View File
@@ -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<CarrierBooking> = {}) => ({ 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<string, RoutedLeg> = {};
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' });
});
});
+43 -7
View File
@@ -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<CarrierBooking, 'type' | 'day_id' | 'end_day_id'>[] = [],
): 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;
+2
View File
@@ -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 {
+83
View File
@@ -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 });
});
});
+16 -3
View File
@@ -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<RoadtripStop, 'time' | 'checkInTime' | 'dwellMinutes' | 'leaveAt'>,
stop: Pick<RoadtripStop, 'time' | 'checkInTime' | 'dwellMinutes' | 'leaveAt' | 'carrier'>,
): 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) {