feat(roadtrip): hire car desks, bookings under stops, the ride as one block

A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
This commit is contained in:
Maurice
2026-09-21 17:05:42 +02:00
parent 0b4230ff4c
commit bdb031a1e9
43 changed files with 1069 additions and 189 deletions
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'اجعلها استراحة',
'roadtrip.ride.departure': 'المغادرة {time}',
'roadtrip.ride.arrival': 'الوصول {time}',
'roadtrip.ride.pickup': 'الاستلام {time}',
'roadtrip.ride.return': 'الإرجاع {time}',
'roadtrip.ride.open': 'فتح الحجز',
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
};
export default roadtrip;
+6 -3
View File
@@ -269,8 +269,11 @@ 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.departure': 'Partida {time}',
'roadtrip.ride.arrival': 'Chegada {time}',
'roadtrip.ride.pickup': 'Retirada {time}',
'roadtrip.ride.return': 'Devolução {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.bookings.loose': 'Também reservado neste dia',
};
export default roadtrip;
+3
View File
@@ -274,6 +274,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Converteix en pausa',
'roadtrip.ride.departure': 'Sortida {time}',
'roadtrip.ride.arrival': 'Arribada {time}',
'roadtrip.ride.pickup': 'Recollida {time}',
'roadtrip.ride.return': 'Devolució {time}',
'roadtrip.ride.open': 'Obre la reserva',
'roadtrip.bookings.loose': 'També reservat aquest dia',
};
export default roadtrip;
+3
View File
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Změnit na pauzu',
'roadtrip.ride.departure': 'Odjezd {time}',
'roadtrip.ride.arrival': 'Příjezd {time}',
'roadtrip.ride.pickup': 'Vyzvednutí {time}',
'roadtrip.ride.return': 'Vrácení {time}',
'roadtrip.ride.open': 'Otevřít rezervaci',
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
};
export default roadtrip;
+3
View File
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Als Pause behalten',
'roadtrip.ride.departure': 'Abfahrt {time}',
'roadtrip.ride.arrival': 'Ankunft {time}',
'roadtrip.ride.pickup': 'Abholung {time}',
'roadtrip.ride.return': 'Rückgabe {time}',
'roadtrip.ride.open': 'Buchung öffnen',
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
};
export default roadtrip;
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Make it a pause',
'roadtrip.ride.departure': 'Departure {time}',
'roadtrip.ride.arrival': 'Arrival {time}',
'roadtrip.ride.pickup': 'Pick-up {time}',
'roadtrip.ride.return': 'Return {time}',
'roadtrip.ride.open': 'Open booking',
'roadtrip.bookings.loose': 'Also booked this day',
};
export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Convertir en pausa',
'roadtrip.ride.departure': 'Salida {time}',
'roadtrip.ride.arrival': 'Llegada {time}',
'roadtrip.ride.pickup': 'Recogida {time}',
'roadtrip.ride.return': 'Devolución {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.bookings.loose': 'También reservado este día',
};
export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'En faire une pause',
'roadtrip.ride.departure': 'Départ {time}',
'roadtrip.ride.arrival': 'Arrivée {time}',
'roadtrip.ride.pickup': 'Prise en charge {time}',
'roadtrip.ride.return': 'Restitution {time}',
'roadtrip.ride.open': 'Ouvrir la réservation',
'roadtrip.bookings.loose': 'Également réservé ce jour',
};
export default roadtrip;
+3
View File
@@ -275,6 +275,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Κάνε το παύση',
'roadtrip.ride.departure': 'Αναχώρηση {time}',
'roadtrip.ride.arrival': 'Άφιξη {time}',
'roadtrip.ride.pickup': 'Παραλαβή {time}',
'roadtrip.ride.return': 'Επιστροφή {time}',
'roadtrip.ride.open': 'Άνοιγμα κράτησης',
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
};
export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Legyen szünet',
'roadtrip.ride.departure': 'Indulás {time}',
'roadtrip.ride.arrival': 'Érkezés {time}',
'roadtrip.ride.pickup': 'Felvétel {time}',
'roadtrip.ride.return': 'Visszaadás {time}',
'roadtrip.ride.open': 'Foglalás megnyitása',
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
};
export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Jadikan jeda',
'roadtrip.ride.departure': 'Berangkat {time}',
'roadtrip.ride.arrival': 'Tiba {time}',
'roadtrip.ride.pickup': 'Pengambilan {time}',
'roadtrip.ride.return': 'Pengembalian {time}',
'roadtrip.ride.open': 'Buka pemesanan',
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
};
export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Rendi una pausa',
'roadtrip.ride.departure': 'Partenza {time}',
'roadtrip.ride.arrival': 'Arrivo {time}',
'roadtrip.ride.pickup': 'Ritiro {time}',
'roadtrip.ride.return': 'Riconsegna {time}',
'roadtrip.ride.open': 'Apri prenotazione',
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
};
export default roadtrip;
+3
View File
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '休憩にする',
'roadtrip.ride.departure': '出発 {time}',
'roadtrip.ride.arrival': '到着 {time}',
'roadtrip.ride.pickup': '受取 {time}',
'roadtrip.ride.return': '返却 {time}',
'roadtrip.ride.open': '予約を開く',
'roadtrip.bookings.loose': 'この日のその他の予約',
};
export default roadtrip;
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '휴식으로 바꾸기',
'roadtrip.ride.departure': '출발 {time}',
'roadtrip.ride.arrival': '도착 {time}',
'roadtrip.ride.pickup': '픽업 {time}',
'roadtrip.ride.return': '반납 {time}',
'roadtrip.ride.open': '예약 열기',
'roadtrip.bookings.loose': '이날의 다른 예약',
};
export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Maak er een pauze van',
'roadtrip.ride.departure': 'Vertrek {time}',
'roadtrip.ride.arrival': 'Aankomst {time}',
'roadtrip.ride.pickup': 'Ophalen {time}',
'roadtrip.ride.return': 'Inleveren {time}',
'roadtrip.ride.open': 'Boeking openen',
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
};
export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Zamień na postój',
'roadtrip.ride.departure': 'Odjazd {time}',
'roadtrip.ride.arrival': 'Przyjazd {time}',
'roadtrip.ride.pickup': 'Odbiór {time}',
'roadtrip.ride.return': 'Zwrot {time}',
'roadtrip.ride.open': 'Otwórz rezerwację',
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
};
export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Сделать паузой',
'roadtrip.ride.departure': 'Отправление {time}',
'roadtrip.ride.arrival': 'Прибытие {time}',
'roadtrip.ride.pickup': 'Получение {time}',
'roadtrip.ride.return': 'Возврат {time}',
'roadtrip.ride.open': 'Открыть бронирование',
'roadtrip.bookings.loose': 'Также забронировано в этот день',
};
export default roadtrip;
+3
View File
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Gör till en paus',
'roadtrip.ride.departure': 'Avgång {time}',
'roadtrip.ride.arrival': 'Ankomst {time}',
'roadtrip.ride.pickup': 'Upphämtning {time}',
'roadtrip.ride.return': 'Återlämning {time}',
'roadtrip.ride.open': 'Öppna bokning',
'roadtrip.bookings.loose': 'Även bokat denna dag',
};
export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Molaya çevir',
'roadtrip.ride.departure': 'Kalkış {time}',
'roadtrip.ride.arrival': 'Varış {time}',
'roadtrip.ride.pickup': 'Alış {time}',
'roadtrip.ride.return': 'İade {time}',
'roadtrip.ride.open': 'Rezervasyonu aç',
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
};
export default roadtrip;
+3
View File
@@ -271,6 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Зробити паузою',
'roadtrip.ride.departure': 'Відправлення {time}',
'roadtrip.ride.arrival': 'Прибуття {time}',
'roadtrip.ride.pickup': 'Отримання {time}',
'roadtrip.ride.return': 'Повернення {time}',
'roadtrip.ride.open': 'Відкрити бронювання',
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
};
export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'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.pickup': 'Nhận xe {time}',
'roadtrip.ride.return': 'Trả xe {time}',
'roadtrip.ride.open': 'Mở đặt chỗ',
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
};
export default roadtrip;
+3
View File
@@ -257,6 +257,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '改為休息',
'roadtrip.ride.departure': '出發 {time}',
'roadtrip.ride.arrival': '抵達 {time}',
'roadtrip.ride.pickup': '取車 {time}',
'roadtrip.ride.return': '還車 {time}',
'roadtrip.ride.open': '開啟預訂',
'roadtrip.bookings.loose': '當天的其他預訂',
};
export default roadtrip;
+3
View File
@@ -258,6 +258,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '改为休息',
'roadtrip.ride.departure': '出发 {time}',
'roadtrip.ride.arrival': '到达 {time}',
'roadtrip.ride.pickup': '取车 {time}',
'roadtrip.ride.return': '还车 {time}',
'roadtrip.ride.open': '打开预订',
'roadtrip.bookings.loose': '当天的其他预订',
};
export default roadtrip;
+3 -1
View File
@@ -1,5 +1,5 @@
import { spurFor } from './accessSpur';
import { carriesTheCar, isCarrierMode } from './carriers';
import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers';
import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow';
@@ -226,6 +226,8 @@ export function assembleRoadtrip({
}
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
}
// A hire car is picked up full: whatever the drive had spent before, this tank is new.
if (isPickupStop(stops[i])) carryKm = 0;
const leg = routed[i];
if (leg && isCarrierMode(leg.seg.mode)) {
if (!carriesTheCar(leg.seg.mode!)) carryKm = 0;
+139 -5
View File
@@ -1,10 +1,12 @@
/**
* ROADTRIP-CARRIERS-001..014: a booking the traveller rides becomes a seam in the drive.
* ROADTRIP-CARRIERS-001..019: 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
* 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.
* among the day's stops, what the ride between them is worth to the chain, that
* nothing about a terminal reads as a stored stop, and that a hire car's pick-up and
* return are points the road runs through rather than a seam in it.
*/
import { assembleRoadtrip } from './assemble';
import {
@@ -18,6 +20,7 @@ import {
carriesTheCar,
isCarrierMode,
isCarrierStop,
isPickupStop,
seatCarrierStops,
terminalAssignmentId,
viasLeaving,
@@ -87,8 +90,7 @@ describe('carrierSeam', () => {
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();
it('ROADTRIP-CARRIERS-002: a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => {
expect(carrierSeam(flight({ type: 'taxi' }))).toBeNull();
expect(carrierSeam(flight({ type: 'transit' }))).toBeNull();
expect(carrierSeam(flight({ day_id: null }))).toBeNull();
@@ -151,6 +153,138 @@ describe('carrierSeam', () => {
});
});
describe('a hire car', () => {
const rental = (over: Partial<CarrierBooking> = {}): CarrierBooking =>
flight({
id: 9,
type: 'car',
title: 'Sixt Hamburg',
day_id: 1,
end_day_id: 3,
reservation_time: '2026-06-01T09:00',
reservation_end_time: '2026-06-03T11:30',
endpoints: [
{ role: 'from', sequence: 0, name: 'Sixt Hauptbahnhof', code: null, lat: 53.55, lng: 10.0 },
{ role: 'to', sequence: 1, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
],
...over,
});
it('ROADTRIP-CARRIERS-016: a hire car is a rental, not a ride: its desks are its ends, and the same desk twice is fine', () => {
const seam = carrierSeam(rental())!;
expect(seam.kind).toBe('rental');
expect(seam.departure).toMatchObject({ dayId: 1, clock: '09:00', name: 'Sixt Hauptbahnhof' });
expect(seam.arrival).toMatchObject({ dayId: 3, clock: '11:30', code: 'HAM' });
const sameDesk = carrierSeam(
rental({
endpoints: [
{ role: 'from', sequence: 0, name: 'Europcar MUC', code: 'MUC', lat: 48.35, lng: 11.78 },
{ role: 'to', sequence: 1, name: 'Europcar MUC', code: 'MUC', lat: 48.35, lng: 11.78 },
],
}),
)!;
expect(sameDesk.arrival).toMatchObject({ name: 'Europcar MUC' });
});
it('ROADTRIP-CARRIERS-017: a hire car without a return day or a return desk stands on the road at its pick-up only, nothing is guessed', () => {
const noDay = carrierSeam(rental({ end_day_id: null }))!;
expect(noDay.arrival).toBeNull();
const noDesk = carrierSeam(
rental({
endpoints: [{ role: 'from', sequence: 0, name: 'Sixt Hauptbahnhof', code: null, lat: 53.55, lng: 10.0 }],
}),
)!;
expect(noDesk.arrival).toBeNull();
expect(carrierSeam(rental({ endpoints: [] }))).toBeNull();
expect(carrierRideMinutes(noDay, 0)).toBeNull();
const seated = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '10:00' })], [0], [noDay]);
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0']);
expect(seatCarrierStops(3, [stop({ ownerIndex: 0 })], [0], [noDay]).map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
]);
});
it('ROADTRIP-CARRIERS-018: the pick-up opens its day when nothing timed comes first, the return closes its day, and the road runs through both', () => {
const seam = carrierSeam(rental())!;
const first = seatCarrierStops(
1,
[stop({ ownerIndex: 0, time: '10:00' }), stop({ ownerIndex: 1 })],
[0, 1],
[seam],
);
expect(first.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0', 'Stop 1']);
const pickup = first[0]!;
expect(pickup).toMatchObject({
time: '09:00',
leaveAt: null,
dwellMinutes: 0,
legMode: null,
incomingLegMode: null,
});
expect(pickup.assignmentId).toBe(terminalAssignmentId(9, 'pickup'));
expect(isCarrierStop(pickup)).toBe(true);
expect(isPickupStop(pickup)).toBe(true);
// No ride leaves a pick-up desk: the leg out of it is a road like any other.
expect(carrierLegsFor([pickup, first[1]!], 'driving', () => 0, roadtripLegKey)).toBeNull();
const last = seatCarrierStops(
3,
[stop({ ownerIndex: 0, ownerDayId: 3, time: '09:30' }), stop({ ownerIndex: 1, ownerDayId: 3, time: '10:30' })],
[0, 1],
[seam],
);
expect(last.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'Stop 1', 'return']);
expect(last[2]).toMatchObject({ time: '11:30', dwellMinutes: 0, legMode: null, incomingLegMode: null });
expect(last[2]!.assignmentId).toBe(terminalAssignmentId(9, 'return'));
expect(isPickupStop(last[2])).toBe(false);
// Nothing of it on the day in between, and no arc on the map: its line is the road.
expect(seatCarrierStops(2, [stop({ ownerIndex: 0, ownerDayId: 2 })], [0], [seam])).toHaveLength(1);
expect(carrierReservationIds([{ stops: first }, { stops: last }])).toEqual([]);
});
it('ROADTRIP-CARRIERS-019: a car picked up and handed back on the same day seats both desks on their own, and the tank is full at the pick-up', () => {
const oneDay = carrierSeam(rental({ end_day_id: 1, reservation_end_time: '2026-06-01T18:00' }))!;
const seated = seatCarrierStops(
1,
[stop({ ownerIndex: 0, time: '10:00' }), stop({ ownerIndex: 1, time: '15:00' })],
[0, 1],
[oneDay],
);
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0', 'Stop 1', 'return']);
const [pickup, a, b, back] = seated as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop];
const before = stop({ ownerIndex: 5, lat: 49, time: '07:00' });
const stops = [before, pickup, a, b, back];
const legs: Record<string, RoutedLeg> = {
[roadtripLegKey(before, pickup)]: road(40),
[roadtripLegKey(pickup, a)]: road(30),
[roadtripLegKey(a, b)]: road(30),
[roadtripLegKey(b, back)]: road(30),
};
const routes = assembleRoadtrip({
plan: [{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops }],
quietDays: [],
window: null,
distanceUnit: 'metric',
allLegs: legs,
snapByDay: {},
missedByDay: {},
loading: false,
limits: { legMinutes: null, dayMinutes: null, rangeKm: 100 },
vehicleKind: 'combustion',
connectDays: false,
boundaries: [],
labels: { start: 'go', end: 'stop' },
});
const day = routes.days[0]!;
// 130 km of road on a 100 km range, but the 40 km before the desk were another
// car's: the hire car runs 90 km from a full tank and never runs dry.
expect(day.distance).toBe(130_000);
expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(false);
expect(routes.totalStops).toBe(3);
});
});
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())!;
+93 -27
View File
@@ -18,6 +18,15 @@ export function isCarrierType(type: string | null | undefined): boolean {
return !!type && (CARRIER_TYPES as readonly string[]).includes(type);
}
/**
* A hire car: the traveller drives it, so its booking is no seam. Its pick-up and return
* desks are still where the drive starts and ends, and the day plan routes to them the
* same way, so they take their place on the road as points of their own.
*/
export function isRentalType(type: string | null | undefined): boolean {
return type === 'car';
}
/** 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);
@@ -88,8 +97,15 @@ export interface CarrierSeam {
reservationId: number;
type: string;
title: string;
/** A ride seams the drive; a rental only puts its two desks on it. */
kind: 'ride' | 'rental';
departure: CarrierEnd;
arrival: CarrierEnd;
/**
* A ride always has both ends. A hire car has a return only when the booking says
* when and where: without a return day or a located return desk, nothing is guessed
* and only the pick-up stands on the road.
*/
arrival: CarrierEnd | null;
}
interface LegRecord {
@@ -142,22 +158,37 @@ function positionOn(positions: Record<string, number> | null | undefined, dayId:
* 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 kind: CarrierSeam['kind'] | null = isCarrierType(booking.type)
? 'ride'
: isRentalType(booking.type)
? 'rental'
: null;
if (!kind) 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;
// A ride needs both ends to say where the drive stops and where it resumes, and takes
// the last located stop for its arrival when no endpoint is marked as one. A hire car's
// return is only ever the desk marked as such: guessing one would put a stop on the
// road nobody booked. The same desk twice is fine for a car, and no seam for a ride.
const to =
kind === 'ride'
? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1])
: [...located].reverse().find((e) => e.role === 'to');
if (!from) return null;
if (kind === 'ride' && (!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;
// A ride lands on the day it left unless the booking says otherwise. A hire car is
// handed back on the day the booking names, and on no day when it names none.
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? (kind === 'ride' ? depDayId : null);
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 =>
@@ -168,6 +199,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
reservationId: booking.id,
type: booking.type,
title: booking.title,
kind,
departure: {
dayId: depDayId,
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
@@ -177,15 +209,18 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | 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!,
},
arrival:
to && arrDayId != null
? {
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!,
}
: null,
};
}
@@ -199,7 +234,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
*/
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
const dep = parseClock(seam.departure.clock);
const arr = parseClock(seam.arrival.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);
}
@@ -208,7 +243,12 @@ export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number
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);
return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' || role === 'return' ? 1 : 0);
}
/** The two roles of a seam, by which end of it a terminal stands at. */
function rolesOf(seam: CarrierSeam): { start: CarrierTerminal['role']; end: CarrierTerminal['role'] } {
return seam.kind === 'rental' ? { start: 'pickup', end: 'return' } : { start: 'departure', end: 'arrival' };
}
/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
@@ -222,12 +262,14 @@ function terminalStop(
dayId: number,
ownerIndex: number,
): RoadtripStop {
const end = role === 'departure' ? seam.departure : seam.arrival;
// The callers only ask for an arrival or a return the seam has.
const end = role === 'departure' || role === 'pickup' ? 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.
// once. Neither has a stay of its own. A hire car's desks are pinned at the booking's
// own clock, and the drive goes on from them without a stay.
const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
return {
carrier: {
@@ -258,8 +300,14 @@ function terminalStop(
};
}
/** Whether a terminal opens a day it has no timed stop before it on: an arrival or a pick-up. */
function opensTheDay(role: CarrierTerminal['role']): boolean {
return role === 'arrival' || role === 'pickup';
}
/**
* The day's stops with the terminals of its rides seated among them.
* The day's stops with the terminals of its rides, and the desks of its hire cars, 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
@@ -271,7 +319,10 @@ function terminalStop(
*
* 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.
* arrival on that day, where it opens the drive. A hire car's pick-up opens the day the
* same way when nothing timed comes before it, and its return closes the day it is
* handed back on; the two are seated on their own, because the drive itself runs
* between them.
*
* `orderIndex` is each stop's stored `order_index`, which is what a dragged position is
* measured against; `clockOf` its time in minutes, or null.
@@ -283,7 +334,12 @@ export function seatCarrierStops(
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);
// A ride landing on the day it left seats its arrival behind its departure below. A
// hire car handed back on the day it was picked up has two desks with nothing tying
// them together, so both are seated on their own.
const arriving = seams.filter(
(s) => s.arrival?.dayId === dayId && (s.departure.dayId !== dayId || s.kind === 'rental'),
);
if (!departing.length && !arriving.length) return stops;
type Item =
@@ -296,8 +352,8 @@ export function seatCarrierStops(
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 })),
...departing.map((seam) => ({ seam, role: rolesOf(seam).start, end: seam.departure })),
...arriving.map((seam) => ({ seam, role: rolesOf(seam).end, end: seam.arrival! })),
];
const items: Item[] = [...base];
const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
@@ -316,7 +372,7 @@ export function seatCarrierStops(
// 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';
const opens = opensTheDay(e.role);
key =
after === -Infinity
? opens
@@ -344,7 +400,7 @@ export function seatCarrierStops(
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) {
if (slot.role === 'departure' && slot.seam.arrival?.dayId === dayId) {
out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt));
}
};
@@ -428,9 +484,19 @@ export function viasLeaving<V extends { day_id: number; after_order_index: numbe
.sort((a, b) => a.sequence - b.sequence);
}
/** The bookings whose ride the days draw, for the map to show their arcs beside the roads. */
/**
* The bookings whose ride the days draw, for the map to show their arcs beside the roads.
* A hire car is left out: its line on the map is the road the drive already draws.
*/
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);
for (const day of days)
for (const stop of day.stops)
if (stop.carrier && isCarrierType(stop.carrier.type)) ids.add(stop.carrier.reservationId);
return [...ids];
}
/** Whether a stop is where a hire car is picked up: the tank the drive goes on with is full. */
export function isPickupStop(stop: Pick<RoadtripStop, 'carrier'> | null | undefined): boolean {
return stop?.carrier?.role === 'pickup';
}
+10 -7
View File
@@ -5,18 +5,21 @@ 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.
* A point a booking puts on the drive: the airport, station or port a flight, train,
* ferry, cruise or bus leaves from or lands at, or the desk a hire car is picked up at
* or handed back 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.
* For a ride 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, and the leg
* between the two carries the ride's minutes and nothing else. A hire car's pick-up and
* return are points ON the road: the drive runs through them, it just starts or ends
* there.
*/
export interface CarrierTerminal {
reservationId: number;
/** flight | train | ferry | cruise | bus, the booking's own type. */
/** flight | train | ferry | cruise | bus for a ride, car for a hire car. */
type: string;
role: 'departure' | 'arrival';
role: 'departure' | 'arrival' | 'pickup' | 'return';
/** 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. */