mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a stay now waits behind the landing when it lies nearer the arrival than the departure, and its check-in only holds it when the ride lands before the room is ready. A time typed on the stop keeps its seat. With the stays at both ends of a day switched on: - a day with nothing but the next stay's check-in and a ride booked across it gets no drive from one hotel to the other, the same rule the day plan follows since #2476; - a stay is never joined by road to a terminal more than 2000 km away. The check moved to shared/roadtrip so Days and the road trip read one copy. A departure terminal's pin is dated: reaching it more than half a day late no longer moves the flight to the next day without a word, it is a late warning with the full minutes. A check-in evening also carries the stay's check-in time, as a label only. calculate_roadtrip runs the same code and its description says all of the above.
This commit is contained in:
@@ -1,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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user