feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals

The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.

A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.

The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.

Closes #2428
This commit is contained in:
Maurice
2026-09-21 16:03:21 +02:00
parent 4be2314856
commit fa35966ab8
47 changed files with 1856 additions and 98 deletions
+4
View File
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'ستُزال ليلة المبيت في "{name}". تبقى المحطة على الرحلة كاستراحة.',
'roadtrip.stay.releaseBookedBody': 'ستُزال ليلة المبيت في "{name}" مع الحجز "{booking}" وأي مصروف مرتبط به. تبقى المحطة على الرحلة كاستراحة.',
'roadtrip.stay.releaseAction': 'اجعلها استراحة',
'roadtrip.ride.departure': 'المغادرة {time}',
'roadtrip.ride.arrival': 'الوصول {time}',
'roadtrip.ride.open': 'فتح الحجز',
'roadtrip.ride.beThere': 'الحضور قبل {time}',
};
export default roadtrip;
+4
View File
@@ -269,5 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'A noite em “{name}” será removida. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseBookedBody': 'A noite em “{name}” será removida juntamente com a reserva “{booking}” e qualquer despesa associada. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseAction': 'Tornar uma pausa',
'roadtrip.ride.departure': 'Loc\'hañ {time}',
'roadtrip.ride.arrival': 'Erruout {time}',
'roadtrip.ride.open': 'Digeriñ ar mirout',
'roadtrip.ride.beThere': 'Bezañ eno a-raok {time}',
};
export default roadtrip;
+4
View File
@@ -272,5 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Es traurà la nit a «{name}». La parada es manté a la ruta com a pausa.',
'roadtrip.stay.releaseBookedBody': 'Es traurà la nit a «{name}» juntament amb la reserva «{booking}» i qualsevol despesa vinculada. La parada es manté a la ruta com a pausa.',
'roadtrip.stay.releaseAction': 'Converteix en pausa',
'roadtrip.ride.departure': 'Sortida {time}',
'roadtrip.ride.arrival': 'Arribada {time}',
'roadtrip.ride.open': 'Obre la reserva',
'roadtrip.ride.beThere': 'Sigues-hi abans de les {time}',
};
export default roadtrip;
+4
View File
@@ -267,5 +267,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Noc v „{name}“ bude odstraněna. Zastávka zůstane na trase jako pauza.',
'roadtrip.stay.releaseBookedBody': 'Noc v „{name}“ bude odstraněna spolu s rezervací „{booking}“ a všemi navázanými výdaji. Zastávka zůstane na trase jako pauza.',
'roadtrip.stay.releaseAction': 'Změnit na pauzu',
'roadtrip.ride.departure': 'Odjezd {time}',
'roadtrip.ride.arrival': 'Příjezd {time}',
'roadtrip.ride.open': 'Otevřít rezervaci',
'roadtrip.ride.beThere': 'Být na místě do {time}',
};
export default roadtrip;
+4
View File
@@ -266,5 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Die Nacht in „{name}“ wird entfernt. Der Stopp bleibt als Pause auf der Fahrt.',
'roadtrip.stay.releaseBookedBody': 'Die Nacht in „{name}“ wird zusammen mit der Buchung „{booking}“ und jeder damit verknüpften Ausgabe entfernt. Der Stopp bleibt als Pause auf der Fahrt.',
'roadtrip.stay.releaseAction': 'Als Pause behalten',
'roadtrip.ride.departure': 'Abfahrt {time}',
'roadtrip.ride.arrival': 'Ankunft {time}',
'roadtrip.ride.open': 'Buchung öffnen',
'roadtrip.ride.beThere': 'Bis {time} da sein',
};
export default roadtrip;
+4
View File
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'The night at “{name}” will be removed. The stop stays on the drive as a pause.',
'roadtrip.stay.releaseBookedBody': 'The night at “{name}” will be removed together with the booking “{booking}” and any expense linked to it. The stop stays on the drive as a pause.',
'roadtrip.stay.releaseAction': 'Make it a pause',
'roadtrip.ride.departure': 'Departure {time}',
'roadtrip.ride.arrival': 'Arrival {time}',
'roadtrip.ride.open': 'Open booking',
'roadtrip.ride.beThere': 'Be there by {time}',
};
export default roadtrip;
+4
View File
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Se quitará la noche en «{name}». La parada se mantiene en la ruta como pausa.',
'roadtrip.stay.releaseBookedBody': 'Se quitará la noche en «{name}» junto con la reserva «{booking}» y cualquier gasto vinculado. La parada se mantiene en la ruta como pausa.',
'roadtrip.stay.releaseAction': 'Convertir en pausa',
'roadtrip.ride.departure': 'Salida {time}',
'roadtrip.ride.arrival': 'Llegada {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.ride.beThere': 'Estar allí antes de las {time}',
};
export default roadtrip;
+4
View File
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'La nuit à « {name} » sera supprimée. L’arrêt reste sur le trajet comme pause.',
'roadtrip.stay.releaseBookedBody': 'La nuit à « {name} » sera supprimée avec la réservation « {booking} » et toute dépense qui y est liée. L’arrêt reste sur le trajet comme pause.',
'roadtrip.stay.releaseAction': 'En faire une pause',
'roadtrip.ride.departure': 'Départ {time}',
'roadtrip.ride.arrival': 'Arrivée {time}',
'roadtrip.ride.open': 'Ouvrir la réservation',
'roadtrip.ride.beThere': 'Être sur place avant {time}',
};
export default roadtrip;
+4
View File
@@ -273,5 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Η διανυκτέρευση στο «{name}» θα αφαιρεθεί. Η στάση παραμένει στη διαδρομή ως παύση.',
'roadtrip.stay.releaseBookedBody': 'Η διανυκτέρευση στο «{name}» θα αφαιρεθεί μαζί με την κράτηση «{booking}» και κάθε συνδεδεμένο έξοδο. Η στάση παραμένει στη διαδρομή ως παύση.',
'roadtrip.stay.releaseAction': 'Κάνε το παύση',
'roadtrip.ride.departure': 'Αναχώρηση {time}',
'roadtrip.ride.arrival': 'Άφιξη {time}',
'roadtrip.ride.open': 'Άνοιγμα κράτησης',
'roadtrip.ride.beThere': 'Να είστε εκεί μέχρι τις {time}',
};
export default roadtrip;
+4
View File
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'A(z) „{name}” helyen töltött éjszaka törlődik. A megálló szünetként az útvonalon marad.',
'roadtrip.stay.releaseBookedBody': 'A(z) „{name}” helyen töltött éjszaka a(z) „{booking}” foglalással és a hozzá kapcsolt kiadásokkal együtt törlődik. A megálló szünetként az útvonalon marad.',
'roadtrip.stay.releaseAction': 'Legyen szünet',
'roadtrip.ride.departure': 'Indulás {time}',
'roadtrip.ride.arrival': 'Érkezés {time}',
'roadtrip.ride.open': 'Foglalás megnyitása',
'roadtrip.ride.beThere': 'Legyél ott {time}-ig',
};
export default roadtrip;
+4
View File
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Malam di “{name}” akan dihapus. Perhentian tetap ada di perjalanan sebagai jeda.',
'roadtrip.stay.releaseBookedBody': 'Malam di “{name}” akan dihapus bersama pemesanan “{booking}” dan pengeluaran yang terkait. Perhentian tetap ada di perjalanan sebagai jeda.',
'roadtrip.stay.releaseAction': 'Jadikan jeda',
'roadtrip.ride.departure': 'Berangkat {time}',
'roadtrip.ride.arrival': 'Tiba {time}',
'roadtrip.ride.open': 'Buka pemesanan',
'roadtrip.ride.beThere': 'Sudah di sana sebelum {time}',
};
export default roadtrip;
+4
View File
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'La notte a «{name}» verrà rimossa. La tappa resta sul percorso come pausa.',
'roadtrip.stay.releaseBookedBody': 'La notte a «{name}» verrà rimossa insieme alla prenotazione «{booking}» e a ogni spesa collegata. La tappa resta sul percorso come pausa.',
'roadtrip.stay.releaseAction': 'Rendi una pausa',
'roadtrip.ride.departure': 'Partenza {time}',
'roadtrip.ride.arrival': 'Arrivo {time}',
'roadtrip.ride.open': 'Apri prenotazione',
'roadtrip.ride.beThere': 'Essere lì entro le {time}',
};
export default roadtrip;
+4
View File
@@ -266,5 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '「{name}」での宿泊を取り消します。停車地は休憩として行程に残ります。',
'roadtrip.stay.releaseBookedBody': '「{name}」での宿泊を、予約「{booking}」と関連する支出とともに取り消します。停車地は休憩として行程に残ります。',
'roadtrip.stay.releaseAction': '休憩にする',
'roadtrip.ride.departure': '出発 {time}',
'roadtrip.ride.arrival': '到着 {time}',
'roadtrip.ride.open': '予約を開く',
'roadtrip.ride.beThere': '{time} までに到着',
};
export default roadtrip;
+4
View File
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '“{name}”에서의 숙박이 삭제됩니다. 이 정차지는 휴식으로 경로에 남습니다.',
'roadtrip.stay.releaseBookedBody': '“{name}”에서의 숙박이 예약 “{booking}” 및 연결된 지출과 함께 삭제됩니다. 이 정차지는 휴식으로 경로에 남습니다.',
'roadtrip.stay.releaseAction': '휴식으로 바꾸기',
'roadtrip.ride.departure': '출발 {time}',
'roadtrip.ride.arrival': '도착 {time}',
'roadtrip.ride.open': '예약 열기',
'roadtrip.ride.beThere': '{time}까지 도착',
};
export default roadtrip;
+4
View File
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'De nacht bij ‘{name}’ wordt verwijderd. De stop blijft als pauze op de route.',
'roadtrip.stay.releaseBookedBody': 'De nacht bij ‘{name}’ wordt verwijderd, samen met de boeking ‘{booking}’ en elke gekoppelde uitgave. De stop blijft als pauze op de route.',
'roadtrip.stay.releaseAction': 'Maak er een pauze van',
'roadtrip.ride.departure': 'Vertrek {time}',
'roadtrip.ride.arrival': 'Aankomst {time}',
'roadtrip.ride.open': 'Boeking openen',
'roadtrip.ride.beThere': 'Uiterlijk om {time} aanwezig zijn',
};
export default roadtrip;
+4
View File
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Nocleg w „{name}” zostanie usunięty. Przystanek pozostaje na trasie jako postój.',
'roadtrip.stay.releaseBookedBody': 'Nocleg w „{name}” zostanie usunięty razem z rezerwacją „{booking}” i powiązanymi wydatkami. Przystanek pozostaje na trasie jako postój.',
'roadtrip.stay.releaseAction': 'Zamień na postój',
'roadtrip.ride.departure': 'Odjazd {time}',
'roadtrip.ride.arrival': 'Przyjazd {time}',
'roadtrip.ride.open': 'Otwórz rezerwację',
'roadtrip.ride.beThere': 'Być na miejscu do {time}',
};
export default roadtrip;
+4
View File
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Ночёвка в «{name}» будет удалена. Остановка останется на маршруте как пауза.',
'roadtrip.stay.releaseBookedBody': 'Ночёвка в «{name}» будет удалена вместе с бронированием «{booking}» и связанными с ним расходами. Остановка останется на маршруте как пауза.',
'roadtrip.stay.releaseAction': 'Сделать паузой',
'roadtrip.ride.departure': 'Отправление {time}',
'roadtrip.ride.arrival': 'Прибытие {time}',
'roadtrip.ride.open': 'Открыть бронирование',
'roadtrip.ride.beThere': 'Быть на месте к {time}',
};
export default roadtrip;
+4
View File
@@ -267,5 +267,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Natten på ”{name}” tas bort. Stoppet ligger kvar på resan som en paus.',
'roadtrip.stay.releaseBookedBody': 'Natten på ”{name}” tas bort tillsammans med bokningen ”{booking}” och alla utgifter som hör till den. Stoppet ligger kvar på resan som en paus.',
'roadtrip.stay.releaseAction': 'Gör till en paus',
'roadtrip.ride.departure': 'Avgång {time}',
'roadtrip.ride.arrival': 'Ankomst {time}',
'roadtrip.ride.open': 'Öppna bokning',
'roadtrip.ride.beThere': 'Var på plats senast {time}',
};
export default roadtrip;
+4
View File
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '“{name}” konumundaki gece kaldırılacak. Durak, yolculukta mola olarak kalır.',
'roadtrip.stay.releaseBookedBody': '“{name}” konumundaki gece, “{booking}” rezervasyonu ve ona bağlı tüm harcamalarla birlikte kaldırılacak. Durak, yolculukta mola olarak kalır.',
'roadtrip.stay.releaseAction': 'Molaya çevir',
'roadtrip.ride.departure': 'Kalkış {time}',
'roadtrip.ride.arrival': 'Varış {time}',
'roadtrip.ride.open': 'Rezervasyonu aç',
'roadtrip.ride.beThere': '{time} itibarıyla orada olun',
};
export default roadtrip;
+4
View File
@@ -269,5 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Ночівлю в «{name}» буде вилучено. Зупинка залишиться на маршруті як пауза.',
'roadtrip.stay.releaseBookedBody': 'Ночівлю в «{name}» буде вилучено разом із бронюванням «{booking}» і пов’язаними витратами. Зупинка залишиться на маршруті як пауза.',
'roadtrip.stay.releaseAction': 'Зробити паузою',
'roadtrip.ride.departure': 'Відправлення {time}',
'roadtrip.ride.arrival': 'Прибуття {time}',
'roadtrip.ride.open': 'Відкрити бронювання',
'roadtrip.ride.beThere': 'Бути на місці до {time}',
};
export default roadtrip;
+4
View File
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Đêm nghỉ tại “{name}” sẽ bị xóa. Điểm dừng vẫn ở trên hành trình như một điểm nghỉ.',
'roadtrip.stay.releaseBookedBody': 'Đêm nghỉ tại “{name}” sẽ bị xóa cùng với đặt chỗ “{booking}” và mọi chi phí liên quan. Điểm dừng vẫn ở trên hành trình như một điểm nghỉ.',
'roadtrip.stay.releaseAction': 'Chuyển thành điểm nghỉ',
'roadtrip.ride.departure': 'Khởi hành {time}',
'roadtrip.ride.arrival': 'Đến nơi {time}',
'roadtrip.ride.open': 'Mở đặt chỗ',
'roadtrip.ride.beThere': 'Có mặt trước {time}',
};
export default roadtrip;
+4
View File
@@ -255,5 +255,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '將取消在「{name}」的過夜。該停靠點會作為休息保留在行程中。',
'roadtrip.stay.releaseBookedBody': '將取消在「{name}」的過夜,並同時刪除預訂「{booking}」及其關聯的費用。該停靠點會作為休息保留在行程中。',
'roadtrip.stay.releaseAction': '改為休息',
'roadtrip.ride.departure': '出發 {time}',
'roadtrip.ride.arrival': '抵達 {time}',
'roadtrip.ride.open': '開啟預訂',
'roadtrip.ride.beThere': '請於 {time} 前抵達',
};
export default roadtrip;
+4
View File
@@ -256,5 +256,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '将取消在“{name}”的过夜。该停靠点会作为休息保留在行程中。',
'roadtrip.stay.releaseBookedBody': '将取消在“{name}”的过夜,并同时删除预订“{booking}”及其关联的费用。该停靠点会作为休息保留在行程中。',
'roadtrip.stay.releaseAction': '改为休息',
'roadtrip.ride.departure': '出发 {time}',
'roadtrip.ride.arrival': '到达 {time}',
'roadtrip.ride.open': '打开预订',
'roadtrip.ride.beThere': '请于 {time} 前到达',
};
export default roadtrip;
+20 -5
View File
@@ -1,4 +1,5 @@
import { spurFor } from './accessSpur';
import { carriesTheCar, isCarrierMode } from './carriers';
import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow';
@@ -160,7 +161,8 @@ export function assembleRoadtrip({
const opening = chain.stops[i]?.automaticNight;
lineJoins.push(opening?.phase === 'start' && Number.isInteger(opening.position ?? 0));
}
segments.push(leg.seg);
// A ride has no road to label: the map draws the booking's own arc for it.
if (!isCarrierMode(leg.seg.mode)) segments.push(leg.seg);
}
const geometry: [number, number][] = [];
@@ -171,10 +173,15 @@ export function assembleRoadtrip({
}
const legs = routed.map((l) => l?.seg);
const inbound = [...inboundAt.values()].map((l) => l.seg);
// The day's figures are the drive's: a ride's hours belong to the booking, not to the
// wheel, and its distance was never measured (`carrierLeg` stores none).
const driven = (l: RouteSegment | undefined): boolean => !!l && !isCarrierMode(l.mode);
const distance =
legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0);
legs.filter(driven).reduce((sum, l) => sum + (l?.distance ?? 0), 0) +
inbound.filter(driven).reduce((sum, l) => sum + (l?.distance ?? 0), 0);
const duration =
legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0);
legs.filter(driven).reduce((sum, l) => sum + (l?.duration ?? 0), 0) +
inbound.filter(driven).reduce((sum, l) => sum + (l?.duration ?? 0), 0);
const schedule = chain.schedule;
const legVias = routed.map((l) => l?.vias ?? []);
const stops = chain.stops.map((s) => {
@@ -194,7 +201,11 @@ export function assembleRoadtrip({
let drivingSeconds = 0;
for (let i = 0; i < stops.length; i++) {
const incoming = inboundAt.get(i);
if (incoming?.seg) {
if (incoming?.seg && isCarrierMode(incoming.seg.mode)) {
// The night's ride into this day: no fuel spent, and after one the car could not
// take, the tank the drive goes on with is another car's.
if (!carriesTheCar(incoming.seg.mode!)) carryKm = 0;
} else if (incoming?.seg) {
// Slot 1 is the stop the leg ARRIVES at, which for an inbound leg is this
// one. Hard-coded false, the range warning could never be suppressed by
// the very charger or petrol station that resolves it.
@@ -216,6 +227,10 @@ export function assembleRoadtrip({
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
}
const leg = routed[i];
if (leg && isCarrierMode(leg.seg.mode)) {
if (!carriesTheCar(leg.seg.mode!)) carryKm = 0;
continue;
}
const outgoing = deriveDriveWarnings(
i < stops.length - 1 ? [leg?.seg] : [],
// Departure, then arrival: this leg leaves stop i and reaches stop i + 1,
@@ -313,7 +328,7 @@ export function assembleRoadtrip({
totalDuration: drives.reduce((s, d) => s + d.duration, 0),
totalStops: drives.reduce(
(s, d) => s + d.stops.filter((st) => !st.automaticNight && !isServiceStopType(st.stopType)).length,
(s, d) => s + d.stops.filter((st) => !st.automaticNight && !st.carrier && !isServiceStopType(st.stopType)).length,
0,
),
+399
View File
@@ -0,0 +1,399 @@
/**
* ROADTRIP-CARRIERS-001..014: a booking the traveller rides becomes a seam in the drive.
*
* The road ends at the terminal the ride leaves from and starts again at the one it
* lands at (#2428). Pinned here: which bookings count, where their terminals are seated
* among the day's stops, what the ride between them is worth to the chain, and that
* nothing about a terminal reads as a stored stop.
*/
import { assembleRoadtrip } from './assemble';
import {
CHECK_IN_MINUTES,
carrierClock,
carrierLeg,
carrierLegsFor,
carrierReservationIds,
carrierRideMinutes,
carrierSeam,
carriesTheCar,
isCarrierMode,
isCarrierStop,
seatCarrierStops,
terminalAssignmentId,
viasLeaving,
type CarrierBooking,
} from './carriers';
import { planDayWindow } from './dayWindow';
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
import { roadtripLegKey } from './routeRun';
import { describe, expect, it } from 'vitest';
const stop = (over: Partial<RoadtripStop> & { ownerIndex: number; ownerDayId?: number }): RoadtripStop => ({
assignmentId: 100 + over.ownerIndex,
ownerDayId: 1,
placeId: 200 + over.ownerIndex,
name: `Stop ${over.ownerIndex}`,
lat: 50 + over.ownerIndex * 0.1,
lng: 10,
time: null,
dwellMinutes: 30,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
});
const flight = (over: Partial<CarrierBooking> = {}): CarrierBooking => ({
id: 7,
type: 'flight',
title: 'LH 2020 MUC → HAM',
day_id: 1,
end_day_id: 1,
reservation_time: '2026-06-01T13:20',
reservation_end_time: '2026-06-01T14:30',
endpoints: [
{ role: 'from', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 },
{ role: 'to', sequence: 1, name: 'Hamburg Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
],
...over,
});
const road = (km: number): RoutedLeg => ({
seg: {
mid: [50, 10],
from: [50, 10],
to: [51, 10],
distance: km * 1000,
duration: km * 60,
walkingText: '',
drivingText: '',
distanceText: `${km} km`,
mode: 'driving',
},
line: [
[50, 10],
[51, 10],
],
vias: [],
});
describe('carrierSeam', () => {
it('ROADTRIP-CARRIERS-001: a flight with two located terminals is a seam with the timetable on both ends', () => {
const seam = carrierSeam(flight());
expect(seam).not.toBeNull();
expect(seam!.departure).toMatchObject({ dayId: 1, clock: '13:20', code: 'MUC', lat: 48.35 });
expect(seam!.arrival).toMatchObject({ dayId: 1, clock: '14:30', code: 'HAM', lng: 9.99 });
expect(seam!.departure.position).toBeNull();
});
it('ROADTRIP-CARRIERS-002: a hire car, a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => {
expect(carrierSeam(flight({ type: 'car' }))).toBeNull();
expect(carrierSeam(flight({ type: 'taxi' }))).toBeNull();
expect(carrierSeam(flight({ type: 'transit' }))).toBeNull();
expect(carrierSeam(flight({ day_id: null }))).toBeNull();
});
it('ROADTRIP-CARRIERS-003: a terminal without coordinates leaves no seam, because the drive cannot resume from nowhere', () => {
const half = flight({
endpoints: [
{ role: 'from', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 },
{ role: 'to', sequence: 1, name: 'Somewhere', code: null, lat: null, lng: null },
],
});
expect(carrierSeam(half)).toBeNull();
});
it('ROADTRIP-CARRIERS-004: a booking with a stopover is one seam from its first departure to its last arrival, on the legs’ own days and clocks', () => {
const legs = JSON.stringify({
legs: [
{ from: 'MUC', to: 'FRA', dep_day_id: 1, dep_time: '18:00', arr_day_id: 1, arr_time: '19:00' },
{
from: 'FRA',
to: 'JFK',
dep_day_id: 1,
dep_time: '22:00',
arr_day_id: 2,
arr_time: '01:30',
day_positions: { '2': 0 },
},
],
});
const seam = carrierSeam(
flight({
reservation_time: null,
reservation_end_time: null,
metadata: legs,
endpoints: [
{ role: 'from', sequence: 0, name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.78 },
{ role: 'stop', sequence: 1, name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.56 },
{ role: 'to', sequence: 2, name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
],
}),
);
expect(seam!.departure).toMatchObject({ dayId: 1, clock: '18:00', code: 'MUC' });
expect(seam!.arrival).toMatchObject({ dayId: 2, clock: '01:30', code: 'JFK', position: 0 });
});
it('ROADTRIP-CARRIERS-005: a position somebody dragged the booking to is read per day, the booking’s own slot as the fallback', () => {
const seam = carrierSeam(flight({ day_positions: { '1': 2 }, day_plan_position: 5 }));
expect(seam!.departure.position).toBe(2);
const legacy = carrierSeam(flight({ day_plan_position: 5 }));
expect(legacy!.departure.position).toBe(5);
});
it('ROADTRIP-CARRIERS-006: clocks come out as HH:mm whichever way the column spells them', () => {
expect(carrierClock('2026-06-01T13:20')).toBe('13:20');
expect(carrierClock('7:05')).toBe('07:05');
expect(carrierClock('13:20:00')).toBe('13:20');
expect(carrierClock(null)).toBeNull();
expect(carrierClock('noon')).toBeNull();
});
});
describe('carrierRideMinutes', () => {
it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => {
const same = carrierSeam(flight())!;
expect(carrierRideMinutes(same, 0)).toBe(70);
const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
expect(carrierRideMinutes(overnight, 1)).toBe(540);
// Westward across the date line the clock lands before it left; the chain cannot
// run backwards, so the ride counts as none rather than as a day.
const dateLine = carrierSeam(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }))!;
expect(carrierRideMinutes(dateLine, 0)).toBe(0);
expect(carrierRideMinutes(carrierSeam(flight({ reservation_end_time: null }))!, 0)).toBeNull();
});
});
describe('seatCarrierStops', () => {
const day = [
stop({ ownerIndex: 0, time: '09:00' }),
stop({ ownerIndex: 1, time: '10:00' }),
stop({ ownerIndex: 2 }),
stop({ ownerIndex: 3, time: '17:00' }),
];
it('ROADTRIP-CARRIERS-008: a same-day ride is seated behind the last timed stop at or before its departure, arrival right behind departure', () => {
const seam = carrierSeam(flight())!;
const seated = seatCarrierStops(1, day, [0, 1, 2, 3], [seam]);
// Behind Stop 1 (10:00), and the untimed Stop 2 follows the ride: the day plan reads
// an untimed item as standing behind the timed one before it.
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
'Stop 1',
'departure',
'arrival',
'Stop 2',
'Stop 3',
]);
const dep = seated[2]!;
const arr = seated[3]!;
// Pinned a check-in ahead of the timetable and left at the timetable's minute.
expect(dep.time).toBe('12:20');
expect(dep.leaveAt).toBe('13:20');
expect(dep.dwellMinutes).toBe(CHECK_IN_MINUTES.flight);
expect(arr.time).toBe('14:30');
expect(arr.dwellMinutes).toBe(0);
// The ride is the leg's mode, on the departure's way out and the arrival's way in.
expect(dep.legMode).toBe('flight');
expect(arr.incomingLegMode).toBe('flight');
// Neither is a stored stop: ids below every assignment and every automatic night,
// a negative place, and the index of the stop that follows them in the stored list.
expect(dep.assignmentId).toBe(terminalAssignmentId(7, 'departure'));
expect(arr.assignmentId).toBe(terminalAssignmentId(7, 'arrival'));
expect(dep.assignmentId).toBeLessThan(-2_000_000_000);
expect(dep.placeId).toBeLessThan(0);
expect(dep.ownerIndex).toBe(2);
expect(arr.ownerIndex).toBe(2);
expect(isCarrierStop(dep)).toBe(true);
expect(isCarrierStop(day[0])).toBe(false);
});
it('ROADTRIP-CARRIERS-009: a dragged position decides among untimed stops, and a ride before every timed stop opens the day', () => {
// Three stops without a clock: the ride would close the day, but somebody dragged
// the booking behind the first stop in the day plan, and that is where it sits.
const untimedDay = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })];
const dragged = carrierSeam(flight({ day_positions: { '1': 0 } }))!;
expect(seatCarrierStops(1, untimedDay, [0, 1, 2], [dragged]).map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
'departure',
'arrival',
'Stop 1',
'Stop 2',
]);
// Among timed stops the clock has the last word, as it has in the day plan: a booking
// dragged to the top of the day still reads after the stops that come before it in time.
expect(seatCarrierStops(1, day, [0, 1, 2, 3], [dragged]).map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
'Stop 1',
'Stop 2',
'departure',
'arrival',
'Stop 3',
]);
const early = carrierSeam(flight({ reservation_time: '06:00', reservation_end_time: '07:10' }))!;
expect(seatCarrierStops(1, day, [0, 1, 2, 3], [early]).map((s) => s.carrier?.role ?? s.name)).toEqual([
'departure',
'arrival',
'Stop 0',
'Stop 1',
'Stop 2',
'Stop 3',
]);
});
it('ROADTRIP-CARRIERS-010: a ride without a clock and without a position goes to the end of the day, as the day plan shows it', () => {
const untimed = carrierSeam(flight({ reservation_time: null, reservation_end_time: null }))!;
const seated = seatCarrierStops(1, day, [0, 1, 2, 3], [untimed]);
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
'Stop 1',
'Stop 2',
'Stop 3',
'departure',
'arrival',
]);
expect(seated[4]!.time).toBeNull();
expect(seated[4]!.dwellMinutes).toBe(0);
});
it('ROADTRIP-CARRIERS-011: a ride landing on a later day seats its arrival on that day, and only there', () => {
const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
const first = seatCarrierStops(1, day, [0, 1, 2, 3], [overnight]);
expect(first.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'Stop 1', 'Stop 2', 'Stop 3', 'departure']);
// The arrival opens its day even when nothing on that day has a clock.
const next = [stop({ ownerIndex: 0, ownerDayId: 2 })];
const second = seatCarrierStops(2, next, [0], [overnight]);
expect(second.map((s) => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 0']);
expect(second[0]!.ownerDayId).toBe(2);
expect(second[0]!.ownerIndex).toBe(0);
expect(seatCarrierStops(3, [], [], [overnight])).toEqual([]);
});
});
describe('the ride as a leg', () => {
it('ROADTRIP-CARRIERS-012: a ride is a leg with minutes, no road and no distance, in the booking’s mode', () => {
const seam = carrierSeam(flight())!;
const [dep, arr] = seatCarrierStops(1, [], [], [seam]);
const legs = carrierLegsFor([dep!, arr!], 'flight', () => 0, roadtripLegKey)!;
const ride = legs[roadtripLegKey(dep!, arr!)]!;
expect(ride.seg.mode).toBe('flight');
expect(ride.seg.duration).toBe(70 * 60);
expect(ride.seg.distance).toBe(0);
expect(ride.line).toEqual([]);
expect(ride.seg.durationText).toBe('1 h 10 min');
expect(isCarrierMode(ride.seg.mode)).toBe(true);
expect(carrierLegsFor([dep!, arr!], 'driving', () => 0, roadtripLegKey)).toBeNull();
expect(carrierLeg(dep!, arr!, null).seg.durationText).toBe('');
expect(carriesTheCar('ferry')).toBe(true);
expect(carriesTheCar('flight')).toBe(false);
});
it('ROADTRIP-CARRIERS-013: no via leaves a terminal, however many are filed at the index it stands in for', () => {
const vias = [{ day_id: 1, after_order_index: 2, sequence: 0, lat: 1, lng: 1 }];
const seam = carrierSeam(flight())!;
const seated = seatCarrierStops(
1,
[
stop({ ownerIndex: 0, time: '09:00' }),
stop({ ownerIndex: 1, time: '10:00' }),
stop({ ownerIndex: 2, time: '17:00' }),
],
[0, 1, 2],
[seam],
);
const arrival = seated.find((s) => s.carrier?.role === 'arrival')!;
expect(arrival.ownerIndex).toBe(2);
expect(viasLeaving(arrival, vias)).toEqual([]);
expect(viasLeaving(seated[4]!, vias)).toHaveLength(1);
expect(carrierReservationIds([{ stops: seated }])).toEqual([7]);
});
});
describe('the ride in the drive', () => {
const seam = carrierSeam(flight({ reservation_time: '13:20', reservation_end_time: '14:30' }))!;
const stops = seatCarrierStops(
1,
[stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }), stop({ ownerIndex: 1, time: '10:00', dwellMinutes: 0 })],
[0, 1],
[seam],
);
const [a, b, dep, arr] = stops as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop];
const allLegs: Record<string, RoutedLeg> = {
[roadtripLegKey(a, b)]: road(60),
[roadtripLegKey(b, dep)]: road(30),
...carrierLegsFor([dep, arr], 'flight', () => 0, roadtripLegKey)!,
};
const plan: PlanDay[] = [{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops }];
it('ROADTRIP-CARRIERS-014: the day counts its road and not its ride, and the terminals are not destinations', () => {
const routes = assembleRoadtrip({
plan,
quietDays: [],
window: null,
distanceUnit: 'metric',
allLegs,
snapByDay: {},
missedByDay: {},
loading: false,
limits: { legMinutes: null, dayMinutes: null, rangeKm: 50 },
vehicleKind: 'combustion',
connectDays: false,
boundaries: [],
labels: { start: 'go', end: 'stop' },
});
const day = routes.days[0]!;
expect(day.distance).toBe(90_000);
expect(day.duration).toBe(90 * 60);
expect(routes.totalStops).toBe(2);
// The ride is no line on the map and no labelled segment; the arc is the booking's.
expect(routes.lines).toHaveLength(2);
expect(routes.segments.map((s) => s.mode)).toEqual(['driving', 'driving']);
// The chain still runs through it: the terminal is reached a check-in ahead of the
// flight, the flight leaves on the timetable, the far end lands on the timetable.
expect(day.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['09:00', '09:00'],
['10:00', '10:00'],
['12:20', '13:20'],
['14:30', '14:30'],
]);
// A 50 km range and 90 km of road before the flight: the warning belongs to the
// drive, and after the flight the tank is another car's, so nothing carries over.
expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(true);
expect(day.dryPoints?.every((p) => p.legIndex < 2)).toBe(true);
});
it('ROADTRIP-CARRIERS-015: with a daily window, an overnight ride carries the clock to the day it lands on without a night marker on the flight', () => {
const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
const dayOne = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '18:00', dwellMinutes: 0 })], [0], [overnight]);
const dayTwo = seatCarrierStops(
2,
[stop({ ownerIndex: 0, ownerDayId: 2, dwellMinutes: 0, lat: 53.7, lng: 10.1 })],
[0],
[overnight],
);
const [origin, departure] = dayOne as [RoadtripStop, RoadtripStop];
const [arrival, hotel] = dayTwo as [RoadtripStop, RoadtripStop];
const legs: Record<string, RoutedLeg> = {
[roadtripLegKey(origin, departure)]: road(30),
[roadtripLegKey(arrival, hotel)]: road(20),
...carrierLegsFor([departure, arrival], 'flight', () => 1, roadtripLegKey)!,
};
const days: PlanDay[] = [
{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: dayOne },
{ dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: dayTwo },
];
const timed = planDayWindow(days, { start: 8 * 60, end: 20 * 60 }, (f, t) => legs[roadtripLegKey(f, t)], 'metric', {
start: 'go',
end: 'stop',
});
expect(timed.issue).toBeNull();
const chainOne = timed.chains.find((c) => c.dayNumber === 1)!;
const chainTwo = timed.chains.find((c) => c.dayNumber === 2)!;
expect(chainOne.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'departure']);
expect(chainOne.schedule.entries[1]).toMatchObject({ arrival: '21:00', departure: '22:00' });
expect(chainTwo.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 0']);
expect(chainTwo.schedule.entries[0]).toMatchObject({ arrival: '07:00' });
expect(chainTwo.stops.some((s) => s.automaticNight)).toBe(false);
});
});
+436
View File
@@ -0,0 +1,436 @@
import { chronoOrder } from '../day/chrono-order';
import type { CarrierTerminal, RoadtripStop, RoutedLeg } from './planning-types';
import { formatClock, formatDurationShort, parseClock } from './roadtripModel';
/**
* Bookings the traveller rides and then leaves behind, the same set the day plan calls a
* carrier (`isCarrierTransport` in the client): the vehicle carries them out of the day's
* geography and keeps going without them. A hire car, a taxi or a transit hop is not one
* of these, because the traveller stays in the drive's geography the whole way.
*
* On the road trip such a booking is a seam: the road ends at the terminal it leaves
* from and starts again at the one it lands at. Routing straight across it drew a
* two-thousand-kilometre drive where a flight was booked (#2428).
*/
export const CARRIER_TYPES = ['flight', 'train', 'ferry', 'cruise', 'bus'] as const;
export function isCarrierType(type: string | null | undefined): boolean {
return !!type && (CARRIER_TYPES as readonly string[]).includes(type);
}
/** A leg mode that is a ride rather than a road: the booking's own type is the mode. */
export function isCarrierMode(mode: string | null | undefined): boolean {
return isCarrierType(mode);
}
/**
* How long before the timetable's departure the traveller has to be at the terminal.
*
* The pin on the departure terminal is this much before the departure, not the departure
* itself: a drive that reaches the airport as the doors close is late, however the model
* counts it. The ride then leaves at the timetable's minute.
*/
export const CHECK_IN_MINUTES: Record<string, number> = {
flight: 60,
cruise: 60,
ferry: 30,
train: 10,
bus: 10,
};
/**
* Whether the car goes along. A range budget carries across a ride the car takes too
* (a car ferry, a motorail) and starts afresh after one it cannot (nobody flies a car),
* because the tank the drive continues on is a different tank.
*/
export function carriesTheCar(type: string): boolean {
return type === 'ferry' || type === 'train';
}
/** A booking as either side stores it: the row with its endpoints and positions joined on. */
export interface CarrierBooking {
id: number;
type: string;
title: string;
day_id?: number | null;
end_day_id?: number | null;
/** 'HH:mm' or 'YYYY-MM-DDTHH:mm', as the reservation form writes it. */
reservation_time?: string | null;
reservation_end_time?: string | null;
/** JSON as stored, or the parsed object; only `legs` is read here. */
metadata?: string | Record<string, unknown> | null;
endpoints?: readonly {
role: string;
sequence?: number | null;
name: string;
code?: string | null;
lat: number | null;
lng: number | null;
}[];
day_positions?: Record<string, number> | null;
day_plan_position?: number | null;
}
/** One end of a seam: where, when and on which day the drive stops or resumes. */
export interface CarrierEnd {
dayId: number;
/** Where the terminal sits among the day's stops, when somebody placed the booking by hand. */
position: number | null;
/** The timetable's clock at this terminal, 'HH:mm', null when the booking names none. */
clock: string | null;
name: string;
code: string | null;
lat: number;
lng: number;
}
export interface CarrierSeam {
reservationId: number;
type: string;
title: string;
departure: CarrierEnd;
arrival: CarrierEnd;
}
interface LegRecord {
dep_day_id?: number | null;
dep_time?: string | null;
arr_day_id?: number | null;
arr_time?: string | null;
day_positions?: Record<string, number> | null;
}
function parseMetadata(raw: CarrierBooking['metadata']): Record<string, unknown> {
if (!raw) return {};
if (typeof raw !== 'string') return raw;
try {
let parsed: unknown = JSON.parse(raw);
// A booking saved by an earlier bug carries its JSON encoded twice; the day plan
// unwraps it once more on read and so does this.
if (typeof parsed === 'string') parsed = JSON.parse(parsed);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** The legs of a booking with a stopover, in order; none for a direct one. */
function legsOf(booking: CarrierBooking): LegRecord[] {
const legs = parseMetadata(booking.metadata).legs;
return Array.isArray(legs) ? (legs as LegRecord[]) : [];
}
/** 'HH:mm' out of either form the reservation columns hold, null for anything else. */
export function carrierClock(value: string | null | undefined): string | null {
if (!value) return null;
const text = value.includes('T') ? value.slice(value.indexOf('T') + 1) : value;
const minutes = parseClock(text);
return minutes === null ? null : formatClock(minutes);
}
function positionOn(positions: Record<string, number> | null | undefined, dayId: number): number | null {
const value = positions?.[String(dayId)] ?? (positions as Record<number, number> | null | undefined)?.[dayId];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* The seam a booking makes, or null when it makes none: a booking on no day is not on the
* plan, and one without a located terminal at BOTH ends cannot say where the drive stops
* or where it goes on, so it is left to the day plan.
*
* A booking with a stopover is one seam from its first departure to its last arrival. The
* change of planes happens inside the ride, and the drive has no say in it.
*/
export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
if (!isCarrierType(booking.type)) return null;
const located = (booking.endpoints ?? [])
.filter(
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
)
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const from = located.find((e) => e.role === 'from') ?? located[0];
const to = [...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1];
if (!from || !to || from === to) return null;
const legs = legsOf(booking);
const first = legs[0];
const last = legs[legs.length - 1];
const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
if (depDayId == null) return null;
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? depDayId;
const depClock = carrierClock(first?.dep_time ?? booking.reservation_time);
const arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time);
const bookingPosition = (dayId: number): number | null =>
positionOn(booking.day_positions, dayId) ??
(typeof booking.day_plan_position === 'number' ? booking.day_plan_position : null);
return {
reservationId: booking.id,
type: booking.type,
title: booking.title,
departure: {
dayId: depDayId,
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
clock: depClock,
name: from.name,
code: from.code ?? null,
lat: from.lat!,
lng: from.lng!,
},
arrival: {
dayId: arrDayId,
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
clock: arrClock,
name: to.name,
code: to.code ?? null,
lat: to.lat!,
lng: to.lng!,
},
};
}
/**
* Minutes of the ride as the clocks tell them, across the days the booking spans.
*
* Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after
* landing the day goes on in the destination's time. A ride that lands earlier on the
* clock than it left (westward across the date line) cannot be told from a ride of no
* length this way and counts as one. Null when either clock is missing.
*/
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
const dep = parseClock(seam.departure.clock);
const arr = parseClock(seam.arrival.clock);
if (dep === null || arr === null) return null;
return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep);
}
/** Below the automatic nights (-2e9 and down): a terminal is no assignment and no night. */
const TERMINAL_ID_BASE = -3_000_000_000;
export function terminalAssignmentId(reservationId: number, role: CarrierTerminal['role']): number {
return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' ? 1 : 0);
}
/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
export function isCarrierStop(stop: Pick<RoadtripStop, 'carrier'> | null | undefined): boolean {
return !!stop?.carrier;
}
function terminalStop(
seam: CarrierSeam,
role: CarrierTerminal['role'],
dayId: number,
ownerIndex: number,
): RoadtripStop {
const end = role === 'departure' ? seam.departure : seam.arrival;
const checkIn = CHECK_IN_MINUTES[seam.type] ?? 0;
const depart = parseClock(end.clock);
// The departure terminal is pinned a check-in ahead of the timetable and left at the
// timetable's minute; the arrival one is pinned at the timetable's minute and left at
// once. Neither has a stay of its own.
const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
return {
carrier: {
reservationId: seam.reservationId,
type: seam.type,
role,
title: seam.title,
code: end.code,
at: end.clock,
},
assignmentId: terminalAssignmentId(seam.reservationId, role),
ownerDayId: dayId,
ownerIndex,
placeId: -seam.reservationId,
name: end.name,
lat: end.lat,
lng: end.lng,
time,
leaveAt: role === 'departure' ? end.clock : null,
dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0,
checkInTime: null,
night: false,
endDay: false,
legMode: role === 'departure' ? seam.type : null,
incomingLegMode: role === 'arrival' ? seam.type : null,
stopType: null,
fillPercent: null,
};
}
/**
* The day's stops with the terminals of its rides seated among them.
*
* Each terminal takes the slot the day plan shows the booking in, worked out by the same
* rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise
* it goes behind the last stop whose time is at or before its own, otherwise at the end,
* and the whole list is then read in clock order. 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.
*
* 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
* arrival on that day, where it opens the drive.
*
* `orderIndex` is each stop's stored `order_index`, which is what a dragged position is
* measured against; `clockOf` its time in minutes, or null.
*/
export function seatCarrierStops(
dayId: number,
stops: RoadtripStop[],
orderIndex: number[],
seams: CarrierSeam[],
): RoadtripStop[] {
const departing = seams.filter((s) => s.departure.dayId === dayId);
const arriving = seams.filter((s) => s.arrival.dayId === dayId && s.departure.dayId !== dayId);
if (!departing.length && !arriving.length) return stops;
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 ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [
...departing.map((seam) => ({ seam, role: 'departure' as const, end: seam.departure })),
...arriving.map((seam) => ({ seam, role: 'arrival' as const, end: seam.arrival })),
];
const items: Item[] = [...base];
const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
const firstKey = base.length ? Math.min(...base.map((b) => b.key)) : 0;
ends
.map((e) => ({ ...e, minutes: parseClock(e.end.clock) }))
.sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
.forEach((e, ti) => {
let key = e.end.position;
if (key === null) {
const minutes = e.minutes ?? 0;
let after = -Infinity;
for (const item of items)
if (item.minutes !== null && item.minutes <= minutes) after = Math.max(after, item.key);
// With no timed stop ahead of it, a departure closes the day, the way the day plan
// lists it. An arrival that landed overnight OPENS the day it lands on: the day
// plan happens to file it last there too, but read as a drive that puts the
// morning's stops on the road before the traveller has landed.
const opens = e.role === 'arrival';
key =
after === -Infinity
? opens
? firstKey - 0.5 - ti * 0.01
: lastKey + 0.5 + ti * 0.01
: after + 0.01 + ti * 0.001;
}
items.push({ kind: 'end', seam: e.seam, role: e.role, key, minutes: e.minutes });
});
items.sort((a, b) => a.key - b.key || (a.kind === 'stop' ? -1 : 1));
// Untimed stops keep their place behind the timed item before them, as in the day plan.
// An arrival opening the day is timed and first, so the untimed stops behind it follow
// it rather than sorting ahead of it on a clock they never had.
const read = chronoOrder(items, (item) => item.minutes);
// Where each terminal lands: behind the stop before it in the day plan's reading, in
// the stored order. Two terminals behind the same stop keep their reading order.
const slots: { seam: CarrierSeam; role: CarrierTerminal['role']; after: number }[] = [];
let lastStop = -1;
for (const item of read) {
if (item.kind === 'stop') lastStop = item.index;
else slots.push({ seam: item.seam, role: item.role, after: lastStop });
}
const out: RoadtripStop[] = [];
const emit = (slot: (typeof slots)[number]): void => {
const insertAt = out.filter((s) => !s.carrier).length;
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 slot of slots.filter((s) => s.after < 0)) emit(slot);
stops.forEach((stop, index) => {
out.push(stop);
for (const slot of slots.filter((s) => s.after === index)) emit(slot);
});
return out;
}
/**
* The ride between a departure terminal and its arrival, in the shape of a routed leg.
*
* Its mode is the booking's type, so every reader that tells driving from walking tells
* a ride apart the same way. No line and no distance: the map draws the booking's own
* arc for it, and the line here is what the corridor and the via points measure along,
* which a flight is not. The minutes are what the chain needs.
*/
export function carrierLeg(from: RoadtripStop, to: RoadtripStop, minutes: number | null): RoutedLeg {
const seconds = (minutes ?? 0) * 60;
const a: [number, number] = [from.lat, from.lng];
const b: [number, number] = [to.lat, to.lng];
const durationText = minutes === null ? '' : formatDurationShort(seconds);
return {
seg: {
from: a,
to: b,
mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
distance: 0,
duration: seconds,
mode: from.carrier?.type ?? to.carrier?.type ?? 'flight',
distanceText: '',
durationText,
drivingText: durationText,
walkingText: durationText,
},
line: [],
vias: [],
};
}
/**
* The seams a run of stops asks for, when the run is a ride and not a road: one leg per
* consecutive pair, each the ride between them. Null for a run that is a road.
*/
export function carrierLegsFor(
run: RoadtripStop[],
mode: string,
dayDelta: (from: RoadtripStop, to: RoadtripStop) => number,
key: (from: RoadtripStop, to: RoadtripStop) => string,
): Record<string, RoutedLeg> | null {
if (!isCarrierMode(mode)) return null;
const legs: Record<string, RoutedLeg> = {};
for (let i = 0; i < run.length - 1; i++) {
const from = run[i]!;
const to = run[i + 1]!;
const dep = parseClock(from.carrier?.at);
const arr = parseClock(to.carrier?.at);
const minutes =
from.carrier && to.carrier && dep !== null && arr !== null
? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep)
: null;
legs[key(from, to)] = carrierLeg(from, to, minutes);
}
return legs;
}
/**
* The via points shaping the drive that leaves a stop. None leave a terminal: a via is
* filed by the position of a stored stop, and a terminal stands in for none, so the
* points filed at its index belong to the stop that really has that index.
*/
export function viasLeaving<V extends { day_id: number; after_order_index: number; sequence: number }>(
stop: Pick<RoadtripStop, 'carrier' | 'ownerDayId' | 'ownerIndex'>,
vias: readonly V[],
): V[] {
if (stop.carrier) return [];
return vias
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
.sort((a, b) => a.sequence - b.sequence);
}
/** The bookings whose ride the days draw, for the map to show their arcs beside the roads. */
export function carrierReservationIds(days: readonly { stops: readonly Pick<RoadtripStop, 'carrier'>[] }[]): number[] {
const ids = new Set<number>();
for (const day of days) for (const stop of day.stops) if (stop.carrier) ids.add(stop.carrier.reservationId);
return [...ids];
}
+32 -1
View File
@@ -178,6 +178,8 @@ export function planDayWindow(
const last = chain.stops[chain.stops.length - 1]!;
const end: RoadtripStop = {
...at,
// A marker placed where a terminal stands is a marker, not the terminal.
carrier: undefined,
assignmentId: -2000000000 - number * 2,
name: labels.end,
time: null,
@@ -215,6 +217,33 @@ export function planDayWindow(
// When the drive into this stop set out, on this day's clock: for one a night broke
// up, the morning it went on.
let setOut: number | null = null;
// A ride from its departure terminal to its arrival runs on the booking's clock and
// through the night if it must: no day end is placed on a flight and no morning
// resumes on one. The clock follows the ride, and the day counter follows the clock,
// to the day the booking lands on. A road leg gets the window's treatment below.
if (
previous?.carrier?.role === 'departure' &&
stop.carrier?.role === 'arrival' &&
previous.carrier.reservationId === stop.carrier.reservationId
) {
const leg = legs[i - 1]!;
clock += leg.seg.duration / 60;
while (number < day.dayNumber && clock >= 1440) {
clock -= 1440;
number += 1;
}
// The booking's days are what it says they are, whatever its clocks add up to.
if (number !== day.dayNumber) {
number = day.dayNumber;
clock = ((clock % 1440) + 1440) % 1440;
}
if (pin !== null) clock = pin;
putLeg(previous, stop, leg);
position = i;
append(stop, clock);
previous = stop;
continue;
}
if (previous && number < day.dayNumber && !targets.has(number)) {
if (window.endMode !== 'stop') clock = Math.max(clock, window.end);
previous = night(previous);
@@ -312,7 +341,9 @@ export function planDayWindow(
}
let dwell = left ? left.departure - clock : Math.max(0, stop.dwellMinutes ?? 0);
while (dwell > 0) {
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
// The wait at a terminal is the booking's, not the window's: a check-in that runs
// past the travel hours is not a day ending at the airport.
const remaining = targets.has(number) || stop.carrier ? dwell : Math.max(0, window.end - clock);
const spend = Math.min(dwell, remaining);
clock += spend;
dwell -= spend;
+23
View File
@@ -4,8 +4,31 @@ import type { AutomaticNight } from './dayWindow';
import type { SpillMark } from './nightSpill';
import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel';
/**
* A terminal of a booking the traveller rides rather than drives: the airport, station or
* port a flight, train, ferry, cruise or bus leaves from or lands at.
*
* The drive ends at the departure terminal and starts again at the arrival one. What
* happens in between is the booking's business, not the road's: the leg between the
* two carries the ride's minutes and nothing else, and no road is ever asked for it.
*/
export interface CarrierTerminal {
reservationId: number;
/** flight | train | ferry | cruise | bus, the booking's own type. */
type: string;
role: 'departure' | 'arrival';
/** The booking's title, which is what the rail prints on the ride. */
title: string;
/** IATA code or station code, when the booking carries one. */
code: string | null;
/** The timetable's clock at this terminal, 'HH:mm' local, null when the booking names none. */
at: string | null;
}
export interface RoadtripStop {
automaticNight?: AutomaticNight;
/** Set on the two ends of a carrier ride. Such a stop is no place and belongs to no assignment. */
carrier?: CarrierTerminal;
assignmentId: number;
ownerDayId: number;
+1
View File
@@ -7,3 +7,4 @@ export * from './units';
export * from './vehicleRange';
export * from './assemble';
export * from './routeRun';
export * from './carriers';