feat(roadtrip): a booked night can stand at both ends of its days

The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
This commit is contained in:
Maurice
2026-09-23 14:18:59 +02:00
parent 7fa0434570
commit 514e2e62ee
60 changed files with 2563 additions and 134 deletions
+5
View File
@@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'فتح الحجز',
'roadtrip.ride.undated': 'الحجز {title} لا يقع في أي يوم من أيام هذه الرحلة، لذلك لا يستخدمه المسار.',
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
'roadtrip.bookend.checkOut': 'تسجيل المغادرة · {name}',
'roadtrip.bookend.from': 'من {name}',
'roadtrip.bookend.back': 'العودة إلى {name}',
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
'roadtrip.bookend.noVia': 'يبقى الطريق من مكان إقامتك وإليه كما هو.',
};
export default roadtrip;
+5
View File
@@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'A reserva {title} não está em nenhum dia desta viagem, por isso o percurso não a considera.',
'roadtrip.bookings.loose': 'Também reservado neste dia',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Saindo de {name}',
'roadtrip.bookend.back': 'Volta para {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'O trajeto de ida e volta da sua hospedagem mantém o próprio caminho.',
};
export default roadtrip;
+5
View File
@@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'La reserva {title} no és a cap dia d’aquest viatge, així que el trajecte no la té en compte.',
'roadtrip.bookings.loose': 'També reservat aquest dia',
'roadtrip.bookend.checkOut': 'Sortida · {name}',
'roadtrip.bookend.from': 'Des de {name}',
'roadtrip.bookend.back': 'Tornada a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.noVia': 'El trajecte des del teu allotjament o fins a ell manté la seva ruta.',
};
export default roadtrip;
+5
View File
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Otevřít rezervaci',
'roadtrip.ride.undated': 'Rezervace {title} nepatří k žádnému dni této cesty, proto s ní jízda nepočítá.',
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Z ubytování {name}',
'roadtrip.bookend.back': 'Zpět do ubytování {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'Trasa z vašeho ubytování a do něj zůstává beze změny.',
};
export default roadtrip;
+5
View File
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'Die Buchung {title} liegt auf keinem Tag dieser Reise, deshalb plant die Fahrt nicht mit ihr.',
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Ab {name}',
'roadtrip.bookend.back': 'Zurück zu {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'Die Fahrt von und zu deiner Unterkunft behält ihre Strecke.',
};
export default roadtrip;
+5
View File
@@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Open booking',
'roadtrip.ride.undated': '{title} is on none of this trip’s days, so the drive does not use it.',
'roadtrip.bookings.loose': 'Also booked this day',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'From {name}',
'roadtrip.bookend.back': 'Back to {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'The drive from or to your stay keeps its road.',
};
export default roadtrip;
+5
View File
@@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'La reserva {title} no está en ningún día de este viaje, así que el trayecto no la tiene en cuenta.',
'roadtrip.bookings.loose': 'También reservado este día',
'roadtrip.bookend.checkOut': 'Salida · {name}',
'roadtrip.bookend.from': 'Desde {name}',
'roadtrip.bookend.back': 'Vuelta a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.noVia': 'El trayecto desde tu alojamiento o hasta él mantiene su ruta.',
};
export default roadtrip;
+5
View File
@@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'La réservation {title} n’est sur aucun jour de ce voyage, le trajet n’en tient donc pas compte.',
'roadtrip.bookings.loose': 'Également réservé ce jour',
'roadtrip.bookend.checkOut': 'Départ · {name}',
'roadtrip.bookend.from': 'Depuis {name}',
'roadtrip.bookend.back': 'Retour à {name}',
'roadtrip.bookend.checkIn': 'Arrivée · {name}',
'roadtrip.bookend.noVia': 'Le trajet depuis ou vers votre hébergement garde son itinéraire.',
};
export default roadtrip;
+5
View File
@@ -294,5 +294,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'Η κράτηση {title} δεν βρίσκεται σε καμία μέρα αυτού του ταξιδιού, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Από {name}',
'roadtrip.bookend.back': 'Επιστροφή: {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'Η διαδρομή προς και από το κατάλυμα διατηρεί τον δρόμο της.',
};
export default roadtrip;
+5
View File
@@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Foglalás megnyitása',
'roadtrip.ride.undated': '{title}: a foglalás az utazás egyik napjára sem esik, ezért az útvonal nem számol vele.',
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
'roadtrip.bookend.checkOut': 'Kijelentkezés · {name}',
'roadtrip.bookend.from': 'Indulás: {name}',
'roadtrip.bookend.back': 'Vissza: {name}',
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
'roadtrip.bookend.noVia': 'A szálláshoz vezető és onnan induló út útvonala nem változik.',
};
export default roadtrip;
+5
View File
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'Pemesanan {title} tidak berada di hari mana pun dalam perjalanan ini, jadi perjalanan tidak memperhitungkannya.',
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Dari {name}',
'roadtrip.bookend.back': 'Kembali ke {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'Perjalanan dari dan ke penginapan Anda tetap pada jalurnya.',
};
export default roadtrip;
+5
View File
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'La prenotazione {title} non cade in nessun giorno di questo viaggio, quindi il percorso non la considera.',
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
'roadtrip.bookend.checkOut': 'Check-out · {name}',
'roadtrip.bookend.from': 'Da {name}',
'roadtrip.bookend.back': 'Ritorno a {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.noVia': 'Il tragitto da e verso il tuo alloggio mantiene il suo percorso.',
};
export default roadtrip;
+5
View File
@@ -285,5 +285,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': '予約を開く',
'roadtrip.ride.undated': '{title} はこの旅行のどの日にも入っていないため、走行では使われません。',
'roadtrip.bookings.loose': 'この日のその他の予約',
'roadtrip.bookend.checkOut': 'チェックアウト · {name}',
'roadtrip.bookend.from': '{name}から',
'roadtrip.bookend.back': '{name}へ戻る',
'roadtrip.bookend.checkIn': 'チェックイン · {name}',
'roadtrip.bookend.noVia': '宿泊先との間の区間は、今のルートのままになります。',
};
export default roadtrip;
+5
View File
@@ -282,5 +282,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': '예약 열기',
'roadtrip.ride.undated': '{title}은(는) 이 여행의 어느 날에도 속하지 않아 주행에 반영되지 않습니다.',
'roadtrip.bookings.loose': '이날의 다른 예약',
'roadtrip.bookend.checkOut': '체크아웃 · {name}',
'roadtrip.bookend.from': '{name}에서 출발',
'roadtrip.bookend.back': '{name}(으)로 돌아가기',
'roadtrip.bookend.checkIn': '체크인 · {name}',
'roadtrip.bookend.noVia': '숙소를 오가는 구간은 지금 경로를 그대로 유지합니다.',
};
export default roadtrip;
+5
View File
@@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'De boeking {title} valt op geen enkele dag van deze reis, dus de rit houdt er geen rekening mee.',
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
'roadtrip.bookend.checkOut': 'Uitchecken · {name}',
'roadtrip.bookend.from': 'Vanaf {name}',
'roadtrip.bookend.back': 'Terug naar {name}',
'roadtrip.bookend.checkIn': 'Inchecken · {name}',
'roadtrip.bookend.noVia': 'De rit van en naar je verblijf houdt zijn route.',
};
export default roadtrip;
+5
View File
@@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Otwórz rezerwację',
'roadtrip.ride.undated': 'Rezerwacja {title} nie przypada na żaden dzień tej podróży, więc trasa jej nie uwzględnia.',
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
'roadtrip.bookend.checkOut': 'Wymeldowanie · {name}',
'roadtrip.bookend.from': 'Wyjazd: {name}',
'roadtrip.bookend.back': 'Powrót: {name}',
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
'roadtrip.bookend.noVia': 'Trasa do noclegu i z niego pozostaje bez zmian.',
};
export default roadtrip;
+5
View File
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'Бронирование {title} не относится ни к одному дню этой поездки, поэтому маршрут его не учитывает.',
'roadtrip.bookings.loose': 'Также забронировано в этот день',
'roadtrip.bookend.checkOut': 'Выезд · {name}',
'roadtrip.bookend.from': 'Отправление: {name}',
'roadtrip.bookend.back': 'Возвращение: {name}',
'roadtrip.bookend.checkIn': 'Заезд · {name}',
'roadtrip.bookend.noVia': 'Маршрут от места проживания и обратно к нему остаётся прежним.',
};
export default roadtrip;
+5
View File
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Öppna bokning',
'roadtrip.ride.undated': 'Bokningen {title} ligger inte på någon av resans dagar, så körningen tar inte med den.',
'roadtrip.bookings.loose': 'Även bokat denna dag',
'roadtrip.bookend.checkOut': 'Utcheckning · {name}',
'roadtrip.bookend.from': 'Från {name}',
'roadtrip.bookend.back': 'Tillbaka till {name}',
'roadtrip.bookend.checkIn': 'Incheckning · {name}',
'roadtrip.bookend.noVia': 'Resan till och från ditt boende behåller sin väg.',
};
export default roadtrip;
+5
View File
@@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.undated':
'{title} rezervasyonu bu yolculuğun hiçbir gününde değil, bu yüzden sürüş onu hesaba katmıyor.',
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
'roadtrip.bookend.checkOut': 'Çıkış · {name}',
'roadtrip.bookend.from': 'Başlangıç: {name}',
'roadtrip.bookend.back': 'Dönüş: {name}',
'roadtrip.bookend.checkIn': 'Giriş · {name}',
'roadtrip.bookend.noVia': 'Konaklama yerinize gidiş ve dönüş yolu değişmez.',
};
export default roadtrip;
+5
View File
@@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Відкрити бронювання',
'roadtrip.ride.undated': 'Бронювання {title} не припадає на жоден день цієї подорожі, тому маршрут його не враховує.',
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
'roadtrip.bookend.checkOut': 'Виїзд · {name}',
'roadtrip.bookend.from': 'Відправлення: {name}',
'roadtrip.bookend.back': 'Повернення: {name}',
'roadtrip.bookend.checkIn': 'Заїзд · {name}',
'roadtrip.bookend.noVia': 'Маршрут від місця проживання й назад до нього лишається незмінним.',
};
export default roadtrip;
+5
View File
@@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': 'Mở đặt chỗ',
'roadtrip.ride.undated': 'Đặt chỗ {title} không thuộc ngày nào của chuyến đi này, nên hành trình không tính đến nó.',
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
'roadtrip.bookend.checkOut': 'Trả phòng · {name}',
'roadtrip.bookend.from': 'Từ {name}',
'roadtrip.bookend.back': 'Về lại {name}',
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
'roadtrip.bookend.noVia': 'Chặng đi và về nơi lưu trú giữ nguyên tuyến đường.',
};
export default roadtrip;
+5
View File
@@ -272,5 +272,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': '開啟預訂',
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不會用到它。',
'roadtrip.bookings.loose': '當天的其他預訂',
'roadtrip.bookend.checkOut': '退房 · {name}',
'roadtrip.bookend.from': '從 {name} 出發',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.noVia': '往返住宿地點的路段維持原本的路線。',
};
export default roadtrip;
+5
View File
@@ -273,5 +273,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.open': '打开预订',
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不会用到它。',
'roadtrip.bookings.loose': '当天的其他预订',
'roadtrip.bookend.checkOut': '退房 · {name}',
'roadtrip.bookend.from': '从 {name} 出发',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.noVia': '往返住处的路段保持原有路线。',
};
export default roadtrip;
+153 -3
View File
@@ -1,6 +1,8 @@
/**
* ROADTRIP-ASSEMBLE-001..016: what the assembler does with the model's warnings, and
* what it tells a surface about the drive between connected days.
* ROADTRIP-ASSEMBLE-001..022: what the assembler does with the model's warnings, and
* what it tells a surface about the drive between connected days. From 017 on: a booked
* night seated at both ends of its days (`seatNightBookends`) is one spot overnight,
* driven to and from like any stop and never between.
*
* The model itself is pinned by roadtripModel.spec.ts. This file pins the
* wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes
@@ -12,7 +14,9 @@
*/
import { assembleRoadtrip } from './assemble';
import type { DayWindow } from './dayWindow';
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
import { seatNightBookends } from './nightBookends';
import type { PlanDay, RoadtripStop, RoutedLeg, SnappedWaypoint } from './planning-types';
import { standsAsDay } from './roadtripModel';
import { describe, it, expect } from 'vitest';
@@ -379,3 +383,149 @@ describe('assembleRoadtrip booked nights', () => {
expect(schedule.warnings).toEqual([]);
});
});
describe('assembleRoadtrip nights at a booked hotel', () => {
// Three nights in one hotel from day 1, places on days 1 and 2, the check-out on day 3
// with nothing of its own.
const days = [1, 2, 3].map((id) => ({ id, day_number: id }));
const hotelStay = {
id: 5,
place_id: 900,
start_day_id: 1,
end_day_id: 3,
place_lat: 45,
place_lng: 7,
place_name: 'Hotel',
check_out: '10:00',
reservation_id: null,
};
const checkIn = stop({ ownerIndex: 0, lat: 45, lng: 7, night: true, stopType: 'hotel', checkInTime: '15:00' });
const stored: PlanDay[] = [
{
dayId: 1,
dayNumber: 1,
date: '2026-06-01',
title: null,
stops: [checkIn, stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })],
},
{
dayId: 2,
dayNumber: 2,
date: '2026-06-02',
title: null,
stops: [
stop({ ownerDayId: 2, ownerIndex: 0, assignmentId: 300, placeId: 400, lat: 60 }),
stop({ ownerDayId: 2, ownerIndex: 1, assignmentId: 301, placeId: 401, lat: 61 }),
],
},
{ dayId: 3, dayNumber: 3, date: '2026-06-03', title: null, stops: [] },
];
const seated = seatNightBookends(stored, days, [hotelStay]);
const evening = seated[0]!.stops[3]!;
const morning = seated[1]!.stops[0]!;
const hotelKey = stopKey(checkIn);
function assembleNights(
options: { connectDays?: boolean; window?: DayWindow | null; snaps?: Record<string, SnappedWaypoint> } = {},
) {
const allLegs: Record<string, RoutedLeg> = {};
for (const day of seated) {
day.stops.slice(0, -1).forEach((from, i) => {
allLegs[legKey(from, day.stops[i + 1]!)] = leg(100);
});
}
// A drive fetched for the hotel and itself some other time: the night is none.
allLegs[legKey(evening, morning)] = leg(172);
return assembleRoadtrip({
plan: seated.filter((d) => standsAsDay(d.stops)),
quietDays: seated.filter((d) => !standsAsDay(d.stops)),
window: options.window ?? null,
distanceUnit: 'metric',
allLegs,
snapByDay: options.snaps ? { 1: options.snaps } : {},
missedByDay: {},
loading: false,
limits: { rangeKm: null, legMinutes: null, dayMinutes: null },
vehicleKind: null,
connectDays: options.connectDays ?? false,
boundaries: [],
labels: { start: 'start', end: 'end' },
});
}
const names = (stops: RoadtripStop[]) => stops.map((s) => s.bookend?.phase ?? s.automaticNight?.phase ?? s.name);
it('ROADTRIP-ASSEMBLE-017: connected days draw no band, no line and no figure across a night at one hotel', () => {
const routes = assembleNights({ connectDays: true });
const second = routes.days[1]!;
expect(second.arrivingLeg).toBeUndefined();
expect(second.arrivingLine).toBeUndefined();
expect(second.arrivingFrom).toBeUndefined();
// Three legs a day, the drive to the hotel and from it included, and nothing between.
expect(routes.lines).toHaveLength(6);
expect(routes.lineJoins).toEqual([false, false, false, false, false, false]);
expect(routes.segments).toHaveLength(6);
});
it('ROADTRIP-ASSEMBLE-018: the drives to and from the hotel count, the hotel is no destination', () => {
const routes = assembleNights({ connectDays: true });
expect(routes.days.map((d) => d.distance)).toEqual([300_000, 300_000]);
expect(routes.totalDistance).toBe(600_000);
// The four places; the stay's own stop and its bookends are the hotel, which is not counted.
expect(routes.totalStops).toBe(4);
// The check-out day with nothing of its own is not driven, and holds no hotel either.
expect(routes.days.map((d) => d.dayId)).toEqual([1, 2]);
expect(routes.quietDays.flatMap((d) => d.stops)).toEqual([]);
});
it('ROADTRIP-ASSEMBLE-019: a quiet day lists no hotel, even one left alone on it', () => {
// Where every stop after the morning's hotel went on past midnight, the hotel is all
// that is left on the card: nothing anybody could move to another day.
const alone: PlanDay = { ...seated[1]!, stops: [morning] };
const routes = assembleRoadtrip({
plan: [alone],
quietDays: [],
window: null,
distanceUnit: 'metric',
allLegs: {},
snapByDay: {},
missedByDay: {},
loading: false,
limits: { rangeKm: null, legMinutes: null, dayMinutes: null },
vehicleKind: null,
connectDays: false,
boundaries: [],
labels: { start: 'start', end: 'end' },
});
expect(routes.days).toEqual([]);
expect(routes.quietDays).toEqual([expect.objectContaining({ dayId: 2, stops: [] })]);
});
it('ROADTRIP-ASSEMBLE-020: a day window runs the night at the hotel without a gap and ends the day there', () => {
const routes = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } });
expect(routes.dayWindowIssue).toBeNull();
const [first, second] = routes.days;
// The drive to the hotel is not cut at 18:00: the night is booked there.
expect(names(first!.stops)).toEqual(['Stop 0', 'Stop 1', 'Stop 2', 'evening', 'end']);
expect(first!.stops[4]!).toMatchObject({ lat: 45, lng: 7 });
expect(first!.stops[4]!.bookend).toBeUndefined();
expect(names(second!.stops)).toEqual(['start', 'morning', 'Stop 0', 'Stop 1', 'evening']);
expect(second!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']);
});
it('ROADTRIP-ASSEMBLE-021: a boundary can only be dragged along a drive between two stored stops', () => {
const path = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } }).boundaryPath ?? [];
expect(path.map((p) => [p.from.assignmentId, p.to.assignmentId])).toEqual([
[100, 101],
[101, 102],
[300, 301],
]);
});
it('ROADTRIP-ASSEMBLE-022: the walk from the road to the hotel is drawn once, however often the days stand there', () => {
const snap: SnappedWaypoint = { asked: [45, 7], at: [45.001, 7], meters: 110 };
const routes = assembleNights({ connectDays: true, snaps: { [hotelKey]: snap } });
expect(routes.accessLines.filter((a) => a.stopKey === hotelKey)).toHaveLength(1);
// Every stop there still says how far the road is.
expect(routes.days[1]!.stops[0]!.offRoadMeters).toBe(110);
});
});
+33 -6
View File
@@ -3,6 +3,7 @@ import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers';
import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow';
import { isStationaryJoin, withStationaryJoins } from './nightBookends';
import { spillChains } from './nightSpill';
import type {
PlanDay,
@@ -63,11 +64,13 @@ export function assembleRoadtrip({
boundaries: RoadtripDayBoundary[];
labels: { start: string; end: string };
}): RoadtripRoutes {
const chains = spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]);
// A night spent at one hotel is a leg going nowhere, whatever was fetched for the pair:
// the hotel in the evening and the same hotel the next morning are one spot.
const storedLegFor = withStationaryJoins((from, to) => allLegs[legKey(from, to)]);
const chains = spillChains(plan, quietDays, storedLegFor);
const allSnaps: Record<string, SnappedWaypoint> = {};
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {});
const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)];
const timed = window
? planDayWindow(
[...plan, ...quietDays],
@@ -103,6 +106,8 @@ export function assembleRoadtrip({
const lineJoins: boolean[] = [];
const segments: RouteSegment[] = [];
const accessLines: RoadtripRoutes['accessLines'] = [];
const spurKeys = new Set<string>();
const bookendSpots = new Set<string>();
const out: RoadtripDay[] = [];
let carryKm: number | null = 0;
@@ -122,7 +127,13 @@ export function assembleRoadtrip({
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber });
}
const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0]!);
// No band for a night spent at one hotel: nothing is driven between the evening and
// the morning, and a band would read "0 km" between a hotel and itself.
const first = chain.stops[0];
const joined =
inboundAt.has(0) || !previousStop || !first || isStationaryJoin(previousStop, first)
? undefined
: legFor(previousStop, first);
if (joined) {
inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber });
arrivingLeg = joined.seg;
@@ -188,9 +199,17 @@ export function assembleRoadtrip({
const schedule = chain.schedule;
const legVias = routed.map((l) => l?.vias ?? []);
const stops = chain.stops.map((s) => {
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)];
const key = stopKey(s);
const snap = s.automaticNight ? undefined : allSnaps[key];
const line = spurFor(snap);
if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) });
// A hotel the days start and end at is one walk from the road, however many of its
// bookends and its own stop stand there: drawn once.
const twice = spurKeys.has(key) && (!!s.bookend || bookendSpots.has(key));
if (line && !twice) {
accessLines.push({ line, meters: snap!.meters, stopKey: key });
spurKeys.add(key);
}
if (s.bookend) bookendSpots.add(key);
return { ...s, offRoadMeters: line ? snap!.meters : null };
});
@@ -341,9 +360,17 @@ export function assembleRoadtrip({
0,
),
// A quiet day lists what could be moved onto it, and a bookend is nothing anybody moves:
// it is left behind alone only when every stop after it went on past midnight.
quietDays: out
.filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight))
.map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
.map((d) => ({
dayId: d.dayId,
dayNumber: d.dayNumber,
date: d.date,
title: d.title,
stops: d.stops.filter((s) => !s.bookend),
})),
loading,
};
}
+54 -1
View File
@@ -1,5 +1,5 @@
/**
* ROADTRIP-CARRIERS-001..029: a booking the traveller rides becomes a seam in the drive,
* ROADTRIP-CARRIERS-001..031: 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
@@ -10,6 +10,8 @@
* on no day is named rather than dropped, and a ride within one day is seated where it
* 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.
*/
import { assembleRoadtrip } from './assemble';
import {
@@ -20,15 +22,18 @@ import {
carrierReservationIds,
carrierSeam,
carriesTheCar,
closesTheDay,
isCarrierMode,
isPickupStop,
isUndatedRide,
opensTheDay,
rideSeatAfter,
sameDayRide,
seatCarrierStops,
terminalAssignmentId,
undatedRides,
viasLeaving,
viasOnLeg,
type CarrierBooking,
type SeatItem,
} from './carriers';
@@ -815,3 +820,51 @@ describe('a ride that lands on a later day (#2461)', () => {
expect(car.arrival!.position).toBe(1.5);
});
});
describe('a booked night at the edge of a day', () => {
const hotel = (phase: 'morning' | 'evening', ownerIndex: number) =>
stop({
ownerIndex,
bookend: {
phase,
accommodationId: 5,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
},
});
const vias = [
{ day_id: 1, after_order_index: 0, sequence: 0, lat: 1, lng: 1 },
{ day_id: 1, after_order_index: 1, sequence: 0, lat: 2, lng: 2 },
];
it('ROADTRIP-CARRIERS-030: no via leaves the hotel, and none bends the drive to tonight’s', () => {
// The morning's hotel borrows the first stop's index; the points filed there are
// that stop's, for the drive leaving it.
expect(viasLeaving(hotel('morning', 0), vias)).toEqual([]);
expect(viasOnLeg(hotel('morning', 0), stop({ ownerIndex: 0 }), vias)).toEqual([]);
expect(viasOnLeg(stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), vias)).toEqual([vias[0]]);
// The points behind the day's last stop shape the road into tomorrow, which the drive
// to tonight's hotel is not.
expect(viasOnLeg(stop({ ownerIndex: 1 }), hotel('evening', 2), vias)).toEqual([]);
expect(viasLeaving(stop({ ownerIndex: 1 }), vias)).toEqual([vias[1]]);
});
it('ROADTRIP-CARRIERS-031: a landing or a pick-up opens a day, a departure or a hand-back closes it', () => {
expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => opensTheDay(role as never))).toEqual([
true,
true,
false,
false,
false,
]);
expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => closesTheDay(role as never))).toEqual([
false,
false,
true,
true,
false,
]);
});
});
+29 -6
View File
@@ -475,10 +475,15 @@ 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 {
export function opensTheDay(role: CarrierTerminal['role'] | undefined): boolean {
return role === 'arrival' || role === 'pickup';
}
/** Whether a terminal closes the day it stands last on: a departure or a hire car handed back. */
export function closesTheDay(role: CarrierTerminal['role'] | undefined): boolean {
return role === 'departure' || role === 'return';
}
/**
* The day's stops with the terminals of its rides, and the desks of its hire cars, seated
* among them.
@@ -653,20 +658,38 @@ export function carrierLegsFor(
}
/**
* 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.
* The via points shaping the drive that leaves a stop. None leave a terminal or a booked
* night at a day's edge: a via is filed by the position of a stored stop, and neither
* stands in for one, 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'>,
stop: Pick<RoadtripStop, 'carrier' | 'bookend' | 'ownerDayId' | 'ownerIndex'>,
vias: readonly V[],
): V[] {
if (stop.carrier) return [];
if (stop.carrier || stop.bookend) return [];
return vias
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
.sort((a, b) => a.sequence - b.sequence);
}
/**
* The via points shaping the drive from `from` to `to`.
*
* Those leaving `from`, unless the drive ends at a booked night's hotel. The points behind
* a day's last stored stop shape the road from it into the next day (`carriedSeam`), and
* with the night booked that road is no longer driven: the day ends at the hotel. Bent
* through them, the drive to the hotel would run out along tomorrow's road and back. They
* stay stored, and bend the road again once the day ends at its last stop.
*/
export function viasOnLeg<V extends { day_id: number; after_order_index: number; sequence: number }>(
from: Pick<RoadtripStop, 'carrier' | 'bookend' | 'ownerDayId' | 'ownerIndex'>,
to: Pick<RoadtripStop, 'bookend'>,
vias: readonly V[],
): V[] {
return to.bookend ? [] : viasLeaving(from, vias);
}
/**
* 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.
+135 -1
View File
@@ -1,6 +1,7 @@
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow';
import type { RoadtripStop, RoutedLeg } from './planning-types';
import { withStationaryJoins } from './nightBookends';
import type { BookendPhase, RoadtripStop, RoutedLeg } from './planning-types';
import { describe, expect, it } from 'vitest';
@@ -529,3 +530,136 @@ describe('a time set to leave a stop, with daily travel times', () => {
expect(night!.leaveAt).toBeUndefined();
});
});
describe('night bookends in the day window', () => {
const HOTEL = { lat: 1, lng: 1 };
const bookend = (id: number, dayId: number, phase: BookendPhase, ownerIndex: number, at = HOTEL) =>
stop(id, {
assignmentId: -6_000_000_000 - id,
ownerDayId: dayId,
ownerIndex,
...at,
stopType: 'hotel',
name: `${phase} hotel`,
bookend: {
phase,
accommodationId: 5,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
},
});
const minutesBetween = (table: Record<string, number>) =>
withStationaryJoins((a, b) => leg(table[`${a.assignmentId}>${b.assignmentId}`] ?? 60, a, b));
const names = (chain: { stops: RoadtripStop[] } | undefined) =>
(chain?.stops ?? []).map((s) => s.automaticNight?.phase ?? s.name);
// Day 1 drives to the hotel it sleeps in, day 2 sets out from it.
const p1 = stop(1, { time: '08:00' });
const p2 = stop(2, { dwellMinutes: 60 });
const evening = bookend(90, 1, 'evening', 2);
const morning = bookend(91, 2, 'morning', 0);
const p3 = stop(3, { ownerDayId: 2, ownerIndex: 0, lng: 7 });
const p4 = stop(4, { ownerDayId: 2, ownerIndex: 1, lng: 8 });
// Five hours to the second place, five more to the hotel: in at 19:00, past the window.
const long = minutesBetween({ '1>2': 300, [`2>${evening.assignmentId}`]: 300 });
const twoDays = (first: RoadtripStop[], second: RoadtripStop[] = [morning, p3, p4]) => [
day(1, first),
day(2, second),
];
it('never cuts the drive to tonight’s hotel, and closes the day there', () => {
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels);
expect(plan.issue).toBeNull();
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '13:00', '19:00', '19:00']);
expect(names(plan.chains[1])).toEqual(['start', 'morning hotel', 'Stop 3', 'Stop 4']);
expect(plan.chains[1]!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']);
});
it('never puts the drive to tonight’s hotel off until the morning either', () => {
const plan = planDayWindow(twoDays([p1, p2, evening]), { ...hours, endMode: 'stop' }, long, 'metric', labels);
expect(plan.issue).toBeNull();
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
});
it('leaves the marker at the hotel without the hotel’s booking on it', () => {
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels);
const markers = plan.chains.flatMap((c) => c.stops.filter((s) => s.automaticNight));
expect(markers).toHaveLength(2);
for (const marker of markers) {
expect(marker.bookend).toBeUndefined();
expect(marker).toMatchObject(HOTEL);
}
});
it('ends a day ended at its last place behind the hotel, by hand or by the stop', () => {
const short = minutesBetween({});
const byStop = planDayWindow(twoDays([p1, { ...p2, endDay: true }, evening]), hours, short, 'metric', labels);
expect(byStop.issue).toBeNull();
expect(names(byStop.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
const byHand = planDayWindow(twoDays([p1, p2, evening]), hours, short, 'metric', labels, [
{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 },
]);
expect(byHand.issue).toBeNull();
expect(names(byHand.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
expect(byHand.chains[0]!.stops[3]!.automaticNight).toMatchObject({ manual: true });
});
it('lets a boundary across the night go: the booked night ends the day there already', () => {
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [
{ day_number: 1, from_assignment_id: 2, to_assignment_id: 3, fraction: 0.5 },
]);
expect(plan.issue).toBeNull();
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
// Across a stop it is still the conflict it always was.
const across = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [
{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 },
]);
expect(across.issue).toBe('conflict');
});
it('sets out from the hotel early for a place pinned before the window opens', () => {
const pinned = { ...p3, time: '07:00' };
const plan = planDayWindow(
twoDays([p1, p2, evening], [morning, pinned, p4]),
hours,
minutesBetween({}),
'metric',
labels,
);
expect(plan.issue).toBeNull();
expect(plan.chains[1]!.schedule.entries.slice(0, 3).map((e) => e.arrival)).toEqual(['06:00', '06:00', '07:00']);
});
it('does so on the first day of the drive too, where no marker comes before the hotel', () => {
const pinned = { ...p3, time: '07:00' };
const plan = planDayWindow([day(2, [morning, pinned, p4])], hours, minutesBetween({}), 'metric', labels);
expect(plan.issue).toBeNull();
expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00', '08:00']);
});
it('keeps the drive between two hotels when the morning moves early', () => {
const other = bookend(92, 2, 'morning', 0, { lat: 1, lng: 3 });
const pinned = { ...p3, time: '07:00' };
const lookup = minutesBetween({ [`${evening.assignmentId}>${other.assignmentId}`]: 30 });
const plan = planDayWindow(twoDays([p1, p2, evening], [other, pinned]), hours, lookup, 'metric', labels);
expect(plan.issue).toBeNull();
expect(plan.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['05:30', '06:00', '07:00']);
});
it('files a stop added behind tonight’s hotel or a desk behind the day’s last place', () => {
const card = { stops: [morning, p3, p4, bookend(93, 2, 'evening', 2)] };
expect(roadtripInsertion(card, 0)).toEqual({ dayId: 2, position: 0 });
expect(roadtripInsertion(card, 1)).toEqual({ dayId: 2, position: 0 });
expect(roadtripInsertion(card, 3)).toEqual({ dayId: 2, position: 2 });
expect(roadtripInsertion(card, 4)).toEqual({ dayId: 2, position: 2 });
const handedBack = stop(9, {
ownerIndex: 2,
carrier: { reservationId: 4, type: 'car', role: 'return', title: 'Car', code: null, at: null },
});
expect(roadtripInsertion({ stops: [p1, p2, handedBack] }, 3)).toEqual({ dayId: 1, position: 2 });
expect(roadtripInsertion({ stops: [p1, p2] }, 2)).toEqual({ dayId: 1, position: 2 });
});
});
+70 -14
View File
@@ -4,7 +4,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema';
import type { SpillChain } from './nightSpill';
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types';
import type { DistanceUnit } from './planning-types';
import { formatClock, hasChosenArrival, leaveAfter, parseClock } from './roadtripModel';
import { formatClock, hasChosenArrival, isStoredStop, leaveAfter, parseClock } from './roadtripModel';
import { formatDurationShort } from './roadtripModel';
import { formatDistance } from './units';
@@ -70,7 +70,13 @@ function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit):
};
}
function stationary(stop: RoadtripStop): RoutedLeg {
/**
* The leg of a drive that goes nowhere: from a stop to the same spot, nothing measured.
*
* Between the last stop of a day and the marker that closes it, and between a booked
* night's hotel in the evening and the same hotel the next morning (`withStationaryJoins`).
*/
export function stationary(stop: RoadtripStop): RoutedLeg {
const at: [number, number] = [stop.lat, stop.lng];
return {
line: [],
@@ -108,13 +114,36 @@ export function planDayWindow(
const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue });
if (!stops.length) return { chains: [], legFor: lookup, issue: null };
const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i]));
// Where a day ended at stop `i` really ends: behind tonight's hotel when the booked night
// stands right after it on the same day. Ended at the stop, the hotel would be put on the
// morning after, a drive to bed made the next day.
const behindTonight = (i: number): number => {
const next = stops[i + 1]?.stop;
return next?.bookend?.phase === 'evening' && next.ownerDayId === stops[i]!.stop.ownerDayId ? i + 1 : i;
};
// A stop set to end its day ends it there, or behind tonight's hotel right after it.
const endsDayHere = (i: number): boolean => {
if (stops[i]!.stop.endDay) return behindTonight(i) === i;
return i > 0 && !!stops[i - 1]!.stop.endDay && behindTonight(i - 1) === i;
};
const targets = new Map<number, number>();
let lastTarget = -1;
for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) {
const from = positions.get(boundary.from_assignment_id);
const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id);
// A boundary between two stops a booked night now stands between is left alone: the
// night ends the day there already, and the two are no longer next to each other only
// because the hotel stands in between.
if (
from !== undefined &&
to !== null &&
to !== undefined &&
to > from + 1 &&
stops.slice(from + 1, to).every(({ stop }) => stop.bookend)
)
continue;
if (from === undefined || (to !== null && to !== from + 1)) return failed('conflict');
const target = from + (to === null ? 0 : boundary.fraction);
const target = to === null ? behindTonight(from) : from + boundary.fraction;
if (target <= lastTarget) return failed('conflict');
targets.set(boundary.day_number, target);
lastTarget = target;
@@ -178,8 +207,10 @@ 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.
// A marker placed where a terminal or a booked night's hotel stands is a marker, not
// the terminal or the hotel.
carrier: undefined,
bookend: undefined,
assignmentId: -2000000000 - number * 2,
name: labels.end,
time: null,
@@ -271,17 +302,34 @@ export function planDayWindow(
let early: number | null = null;
if (pin !== null) early = pin - minutes;
else if (leave !== null && leave - minutes >= 0) early = leave - minutes;
if (early !== null && early < clock && chainAt(number).stops.every((s) => s.automaticNight)) {
// The hotel the day wakes up in is part of the morning, not a stop in the way of it:
// the day sets out from there early just the same. Everything before this leg moves
// back by the same minutes, so the marker keeps the drive to the hotel it has, which
// is none when the night was spent there.
const morning = chainAt(number);
if (
early !== null &&
early < clock &&
morning.stops.every((s) => s.automaticNight || s.bookend?.phase === 'morning')
) {
const shift = early - clock;
const moved = (at: string | null): number => (parseClock(at) ?? clock) + shift;
if (early < 0 || moved(morning.schedule.entries[0]!.arrival) < 0) return failed('conflict');
for (const entry of morning.schedule.entries) {
entry.arrival = formatClock(Math.round(moved(entry.arrival)));
entry.departure = formatClock(Math.round(moved(entry.departure)));
}
clock = early;
if (clock < 0) return failed('conflict');
const startEntry = chainAt(number).schedule.entries[0]!;
startEntry.arrival = formatClock(Math.round(clock));
startEntry.departure = formatClock(Math.round(clock));
}
if (pin !== null && clock + minutes > pin + 1) return failed('conflict');
let fraction = 0;
const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined);
// The drive to tonight's hotel is never cut by the window, and never put off to the
// morning: the night is booked there, so the day ends there, later than the window
// says if it must. Cut, the day ended on the road and the hotel was reached the next
// morning, just before the day set out from it again.
const split =
pin === null && stop.bookend?.phase !== 'evening' && (leg.seg.mode === 'driving' || leg.seg.mode === undefined);
if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) {
if (minutes > window.end - window.start) return failed('legTooLong');
previous = night(previous);
@@ -360,7 +408,7 @@ export function planDayWindow(
}
}
}
if (target === position || (!targets.has(number) && stop.endDay && i < stops.length - 1)) {
if (target === position || (!targets.has(number) && endsDayHere(i) && i < stops.length - 1)) {
previous = night(previous, i === stops.length - 1);
if (issue) return failed(issue);
}
@@ -398,9 +446,17 @@ export function planDayWindow(
};
}
/**
* Where a stop inserted at `index` of a card lands on the day it is stored on.
*
* In front of the stop at that index, which is its own index there. Behind the card's last
* stop, one further on, unless that stop is a terminal or tonight's hotel: those stand
* behind the stored stops with the index the next stored stop would have, which is the
* position a stop added at the end of the day takes.
*/
export function roadtripInsertion(day: Pick<RoadtripDay, 'stops'>, index: number) {
const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]!;
return stop
? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) }
: null;
if (!stop) return null;
const behind = stop.automaticNight || (index >= day.stops.length && isStoredStop(stop));
return { dayId: stop.ownerDayId, position: stop.ownerIndex + (behind ? 1 : 0) };
}
+508
View File
@@ -0,0 +1,508 @@
/**
* ROADTRIP-BOOKENDS-001..027: 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.
*/
import { terminalAssignmentId } from './carriers';
import {
bookendAssignmentId,
bookendStaysOf,
hotelBookendsOn,
isStationaryJoin,
seatNightBookends,
withStationaryJoins,
type BookendStay,
} from './nightBookends';
import type { CarrierTerminal, PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
import { standsAsDay } from './roadtripModel';
import { describe, expect, it } from 'vitest';
const days = [1, 2, 3, 4, 5, 6].map((n) => ({ id: n * 10, day_number: n }));
const [D1, D2, D3, D4] = [10, 20, 30, 40];
const GETAWAY: [number, number] = [-33.5, 150.4];
const WALLINGA: [number, number] = [-34.1, 150.9];
const HOTEL: [number, number] = [45.1, 7.6];
const stay = (
id: number,
start: number,
end: number,
at: [number, number] | null,
over: Partial<BookendStay> = {},
): BookendStay => ({
id,
place_id: 500 + id,
start_day_id: start,
end_day_id: end,
place_lat: at ? at[0] : null,
place_lng: at ? at[1] : null,
place_name: `Stay ${id}`,
check_out: '10:00',
reservation_id: 900 + id,
...over,
});
let nextId = 1;
const visit = (dayId: number, ownerIndex: number, over: Partial<RoadtripStop> = {}): RoadtripStop => {
const id = nextId++;
return {
assignmentId: id,
ownerDayId: dayId,
ownerIndex,
placeId: 100 + id,
name: `Place ${id}`,
lat: 40 + id * 0.01,
lng: 5 + id * 0.01,
time: null,
dwellMinutes: 30,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
};
};
/** The stay's own stop on its check-in day, where the booking put it (#2354). */
const stayStop = (dayId: number, ownerIndex: number, at: [number, number], over: Partial<RoadtripStop> = {}) =>
visit(dayId, ownerIndex, { lat: at[0], lng: at[1], night: true, stopType: 'hotel', ...over });
const terminal = (dayId: number, ownerIndex: number, role: CarrierTerminal['role']): RoadtripStop =>
visit(dayId, ownerIndex, {
carrier: {
reservationId: 77,
type: role === 'pickup' || role === 'return' ? 'car' : 'flight',
role,
title: 'Ride',
code: null,
at: null,
},
assignmentId: terminalAssignmentId(77, role),
placeId: -77,
});
const planDay = (dayId: number, stops: RoadtripStop[]): PlanDay => ({
dayId,
dayNumber: dayId / 10,
date: null,
title: null,
stops,
});
const shape = (day: PlanDay | undefined): string[] =>
(day?.stops ?? []).map((s) =>
s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.carrier ? s.carrier.role : s.name,
);
describe('a check-in with places, then a transfer day with nothing of its own', () => {
const getaway = stay(1, D1, D2, GETAWAY, { check_out: '10:00' });
const wallinga = stay(2, D2, D3, WALLINGA);
const checkIn = stayStop(D1, 0, GETAWAY, { name: 'Getaway', checkInTime: '14:00' });
const lookout = visit(D1, 1, { name: 'Lookout' });
const falls = visit(D1, 2, { name: 'Falls' });
const wallingaStop = stayStop(D2, 0, WALLINGA, { name: 'Wallinga', checkInTime: '15:00' });
const plan = [planDay(D1, [checkIn, lookout, falls]), planDay(D2, [wallingaStop]), planDay(D3, [])];
const seated = seatNightBookends(plan, days, [getaway, wallinga]);
it('ROADTRIP-BOOKENDS-001: the check-in day ends back at the stay, and nothing wakes up there', () => {
expect(shape(seated[0])).toEqual(['Getaway', 'Lookout', 'Falls', 'evening:1']);
const back = seated[0]!.stops[3]!;
expect(back.bookend).toEqual({
phase: 'evening',
accommodationId: 1,
reservationId: 901,
checkingOut: false,
checkingIn: true,
checkOut: null,
});
expect(back).toMatchObject({
placeId: 501,
name: 'Stay 1',
lat: GETAWAY[0],
lng: GETAWAY[1],
ownerDayId: D1,
ownerIndex: 3,
stopType: 'hotel',
assignmentId: bookendAssignmentId(D1, 'evening'),
});
});
it('ROADTRIP-BOOKENDS-002: the transfer day checks out of one stay and drives to the stop of the next', () => {
expect(shape(seated[1])).toEqual(['morning:1', 'Wallinga']);
const out = seated[1]!.stops[0]!;
expect(out.bookend).toMatchObject({ phase: 'morning', checkingOut: true, checkingIn: false, checkOut: '10:00' });
expect(out.ownerIndex).toBe(0);
// Tonight's stay is already the day's last stop, so no evening is seated behind it.
expect(seated[1]!.stops).toHaveLength(2);
});
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]);
});
});
describe('three nights in one hotel, places on the days between', () => {
const hotel = stay(5, D1, D4, HOTEL);
const h = stayStop(D1, 0, HOTEL, { name: 'H', checkInTime: '15:00' });
const plan = [
planDay(D1, [h, visit(D1, 1, { name: 'P1' }), visit(D1, 2, { name: 'P2' })]),
planDay(D2, [visit(D2, 0, { name: 'P3' }), visit(D2, 1, { name: 'P4' })]),
planDay(D3, [visit(D3, 0, { name: 'P5' }), visit(D3, 1, { name: 'P6' })]),
planDay(D4, []),
];
const seated = seatNightBookends(plan, days, [hotel]);
it('ROADTRIP-BOOKENDS-004: every day between the nights starts and ends at the hotel', () => {
expect(shape(seated[0])).toEqual(['H', 'P1', 'P2', 'evening:5']);
expect(shape(seated[1])).toEqual(['morning:5', 'P3', 'P4', 'evening:5']);
expect(shape(seated[2])).toEqual(['morning:5', 'P5', 'P6', 'evening:5']);
expect(seated[1]!.stops[0]!.bookend).toMatchObject({ checkingOut: false, checkingIn: false, checkOut: null });
// The evening keeps the index the next stored stop would have, the terminals' rule.
expect(seated[1]!.stops[3]!.ownerIndex).toBe(2);
});
it('ROADTRIP-BOOKENDS-005: the empty check-out day is not driven', () => {
expect(seated[3]).toBe(plan[3]);
});
it('ROADTRIP-BOOKENDS-006: a day in the hotel with nothing planned stays quiet, one with a single place becomes a loop', () => {
const quiet = seatNightBookends([planDay(D2, [])], days, [hotel]);
expect(quiet[0]!.stops).toEqual([]);
const one = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [hotel]);
expect(shape(one[0])).toEqual(['morning:5', 'P', 'evening:5']);
});
it('ROADTRIP-BOOKENDS-007: the hotel already first or last on the day is not seated again on that side', () => {
const first = seatNightBookends(
[planDay(D2, [stayStop(D2, 0, HOTEL, { name: 'H again' }), visit(D2, 1, { name: 'P' })])],
days,
[hotel],
);
expect(shape(first[0])).toEqual(['H again', 'P', 'evening:5']);
const last = seatNightBookends(
[planDay(D2, [visit(D2, 0, { name: 'P' }), stayStop(D2, 1, HOTEL, { name: 'H again' })])],
days,
[hotel],
);
expect(shape(last[0])).toEqual(['morning:5', 'P', 'H again']);
});
});
describe('a transfer day', () => {
const a = stay(1, D1, D2, GETAWAY);
const b = stay(2, D2, D3, WALLINGA);
it('ROADTRIP-BOOKENDS-008: with nothing stored, the day drives from one stay to the other', () => {
const [day] = seatNightBookends([planDay(D2, [])], days, [a, b]);
expect(shape(day)).toEqual(['morning:1', 'evening:2']);
expect(day!.stops[0]!.bookend).toMatchObject({ checkingOut: true });
expect(day!.stops[1]!.bookend).toMatchObject({ checkingIn: true, checkOut: null });
expect(day!.stops.map((s) => s.ownerIndex)).toEqual([0, 0]);
});
it('ROADTRIP-BOOKENDS-009: with places, a place with its own earlier hour stays where it is', () => {
const p0 = visit(D2, 0, { name: 'P0', time: '11:00' });
const checkIn = stayStop(D2, 1, WALLINGA, { name: 'B', checkInTime: '15:00' });
const p1 = visit(D2, 2, { name: 'P1' });
const [day] = seatNightBookends([planDay(D2, [p0, checkIn, p1])], days, [a, b]);
expect(shape(day)).toEqual(['morning:1', 'P0', 'B', 'P1', 'evening:2']);
});
});
describe('a day that starts or ends on a ride or at a hire car desk', () => {
const hotel = stay(5, D1, D4, HOTEL);
// Landing, or at the desk the car is picked up at: the traveller was not at the hotel.
it.each([{ role: 'arrival' }, { role: 'pickup' }] as const)(
'ROADTRIP-BOOKENDS-010: no morning when $role opens the day',
({ role }) => {
const [day] = seatNightBookends([planDay(D2, [terminal(D2, 0, role), visit(D2, 0, { name: 'P' })])], days, [
hotel,
]);
expect(shape(day)).toEqual([role, 'P', 'evening:5']);
},
);
// At the gate, or where the car goes back: the traveller is not driving to the hotel.
it.each([{ role: 'departure' }, { role: 'return' }] as const)(
'ROADTRIP-BOOKENDS-011: no evening when $role closes the day',
({ role }) => {
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), terminal(D2, 1, role)])], days, [
hotel,
]);
expect(shape(day)).toEqual(['morning:5', 'P', role]);
},
);
it('ROADTRIP-BOOKENDS-012: a flight in the morning after a night sets off from the hotel', () => {
const [day] = seatNightBookends(
[planDay(D2, [terminal(D2, 0, 'departure'), terminal(D2, 0, 'arrival'), visit(D2, 0, { name: 'P' })])],
days,
[hotel],
);
expect(shape(day)).toEqual(['morning:5', 'departure', 'arrival', 'P', 'evening:5']);
// Counted by stored stops, not by terminals.
expect(day!.stops[4]!.ownerIndex).toBe(1);
});
});
describe('what a stay without the facts gets', () => {
it('ROADTRIP-BOOKENDS-013: a stay without coordinates gets no bookend, and the other side stands on its own', () => {
const a = stay(1, D1, D2, null);
const b = stay(2, D2, D3, WALLINGA);
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [a, b]);
expect(shape(day)).toEqual(['P', 'evening:2']);
});
it('ROADTRIP-BOOKENDS-014: with no stay on the map at all, the plan comes back as it was', () => {
const plan = [planDay(D2, [visit(D2, 0), visit(D2, 1)])];
expect(seatNightBookends(plan, days, [stay(1, D1, D3, null), stay(2, D1, D3, null)])).toBe(plan);
expect(seatNightBookends(plan, days, [stay(3, D1, D3, HOTEL, { place_id: null })])).toBe(plan);
expect(seatNightBookends(plan, days, [])).toBe(plan);
});
it('ROADTRIP-BOOKENDS-015: a stay entered without a booking still stands there, with nothing to open', () => {
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [
stay(5, D1, D3, HOTEL, { reservation_id: null }),
]);
expect(day!.stops[0]!.bookend?.reservationId).toBeNull();
expect(day!.stops[2]!.bookend?.reservationId).toBeNull();
});
it('ROADTRIP-BOOKENDS-016: a stay that begins and ends on one day is slept in by nobody', () => {
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), visit(D2, 1, { name: 'Q' })])], days, [
stay(5, D2, D2, HOTEL),
]);
expect(shape(day)).toEqual(['P', 'Q']);
});
});
describe('two stays on one day', () => {
it('ROADTRIP-BOOKENDS-017: of two checked into on one day, the evening goes to the one entered first', () => {
const later = stay(7, D2, D3, WALLINGA);
const earlier = stay(3, D2, D3, HOTEL);
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0), visit(D2, 1)])], days, [later, earlier]);
expect(day!.stops[2]!.bookend?.accommodationId).toBe(3);
});
it('ROADTRIP-BOOKENDS-018: overlapping stays follow the day plan: woken up in the older, tonight in the one checked into', () => {
const long = stay(1, D1, D4, HOTEL);
const short = stay(2, D2, D3, WALLINGA);
const plan = [planDay(D2, [visit(D2, 0, { name: 'P' })]), planDay(D3, [visit(D3, 0, { name: 'Q' })])];
const seated = seatNightBookends(plan, days, [long, short]);
expect(shape(seated[0])).toEqual(['morning:1', 'P', 'evening:2']);
expect(shape(seated[1])).toEqual(['morning:1', 'Q', 'evening:1']);
});
it('ROADTRIP-BOOKENDS-019: the order the stays come in changes nothing', () => {
const stays = [stay(1, D1, D4, HOTEL), stay(2, D2, D3, WALLINGA), stay(3, D2, D3, GETAWAY)];
const plan = [planDay(D2, [visit(D2, 0)]), planDay(D3, [visit(D3, 0)])];
expect(seatNightBookends(plan, days, [...stays].reverse())).toEqual(seatNightBookends(plan, days, stays));
});
});
describe('what a bookend is', () => {
const hotel = stay(5, D1, D4, HOTEL);
const plan = [2, 3].map((n) => planDay(n * 10, [visit(n * 10, 0), visit(n * 10, 1)]));
const seated = seatNightBookends(plan, days, [hotel]);
const bookends = seated.flatMap((d) => d.stops.filter((s) => s.bookend));
it('ROADTRIP-BOOKENDS-020: one id per day and phase, the same every run, and below every terminal', () => {
const ids = bookends.map((s) => s.assignmentId);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual([
bookendAssignmentId(D2, 'morning'),
bookendAssignmentId(D2, 'evening'),
bookendAssignmentId(D3, 'morning'),
bookendAssignmentId(D3, 'evening'),
]);
expect(seatNightBookends(plan, days, [hotel]).flatMap((d) => d.stops.map((s) => s.assignmentId))).toEqual(
seated.flatMap((d) => d.stops.map((s) => s.assignmentId)),
);
for (const id of ids) expect(id).toBeLessThan(terminalAssignmentId(1_000_000, 'arrival'));
});
it('ROADTRIP-BOOKENDS-021: never a clock, a stay, a mode or a night of its own, whatever the stay says', () => {
const [day] = seatNightBookends([planDay(D4, [visit(D4, 0), visit(D4, 1)])], days, [
stay(5, D1, D4, HOTEL, { check_out: '11:00' }),
]);
const all = [...bookends, day!.stops[0]!];
expect(day!.stops[0]!.bookend?.checkOut).toBe('11:00');
for (const s of all) {
expect(s).toMatchObject({
time: null,
leaveAt: null,
checkInTime: null,
dwellMinutes: 0,
night: false,
endDay: false,
legMode: null,
incomingLegMode: null,
fillPercent: null,
});
}
});
it('ROADTRIP-BOOKENDS-022: a day with a bookend has two stops or more, and stands as a day for it', () => {
const everyCase = [
...seated,
...seatNightBookends([planDay(D2, [])], days, [stay(1, D1, D2, GETAWAY), stay(2, D2, D3, WALLINGA)]),
...seatNightBookends([planDay(D2, [visit(D2, 0)])], days, [hotel]),
...seatNightBookends([planDay(D4, [visit(D4, 0)])], days, [hotel]),
];
for (const day of everyCase.filter((d) => d.stops.some((s) => s.bookend))) {
expect(day.stops.length).toBeGreaterThanOrEqual(2);
expect(standsAsDay(day.stops)).toBe(true);
}
});
it('ROADTRIP-BOOKENDS-023: a day the trip does not list is passed over', () => {
const stray = planDay(990, [visit(990, 0), visit(990, 1)]);
const out = seatNightBookends([stray], days, [hotel]);
expect(out[0]).toBe(stray);
});
});
describe('a night spent at one hotel', () => {
const at = (over: Partial<RoadtripStop>) => visit(D2, 0, { lat: HOTEL[0], lng: HOTEL[1], ...over });
const evening = at({
bookend: {
phase: 'evening',
accommodationId: 5,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
},
});
const morning = at({
bookend: {
phase: 'morning',
accommodationId: 5,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
},
});
const drive: RoutedLeg = {
seg: {
from: HOTEL,
to: HOTEL,
mid: HOTEL,
distance: 172_000,
duration: 7200,
mode: 'driving',
walkingText: '',
drivingText: '',
distanceText: '',
},
line: [HOTEL, [45.2, 7.7], HOTEL],
vias: [],
};
it('ROADTRIP-BOOKENDS-024: is a join only where a bookend stands at one end and both are one spot', () => {
expect(isStationaryJoin(evening, morning)).toBe(true);
expect(isStationaryJoin(at({}), morning)).toBe(true);
expect(isStationaryJoin(evening, at({}))).toBe(true);
// Two stored stops on one spot are a stop visited twice, not a night.
expect(isStationaryJoin(at({}), at({}))).toBe(false);
expect(isStationaryJoin(evening, { ...morning, lat: WALLINGA[0], lng: WALLINGA[1] })).toBe(false);
});
it('ROADTRIP-BOOKENDS-025: goes nowhere whatever was fetched for the pair, and every other pair is looked up', () => {
const lookup = withStationaryJoins(() => drive);
const night = lookup(evening, morning)!;
expect(night.seg.distance).toBe(0);
expect(night.seg.duration).toBe(0);
expect(night.line).toEqual([]);
expect(lookup(at({}), at({}))).toBe(drive);
expect(withStationaryJoins(() => undefined)(at({}), visit(D2, 1))).toBeUndefined();
});
});
describe('the stays the rule reads, and the switch', () => {
it('ROADTRIP-BOOKENDS-026: each stay once, with its earliest booking; the switch is on only when set on', () => {
const accommodations = [
{
id: 1,
place_id: 11,
start_day_id: D1,
end_day_id: D2,
place_lat: 1,
place_lng: 2,
place_name: 'A',
check_out: '10:00',
},
{
id: 1,
place_id: 11,
start_day_id: D1,
end_day_id: D2,
place_lat: 1,
place_lng: 2,
place_name: 'A',
check_out: '10:00',
},
{ id: 2, place_id: 12, start_day_id: D2, end_day_id: D3 },
];
const reservations = [
{ id: 40, accommodation_id: '1' },
{ id: 31, accommodation_id: 1 },
{ id: 12, accommodation_id: null },
{ id: 5, accommodation_id: '' },
];
expect(bookendStaysOf(accommodations, reservations)).toEqual([
{
id: 1,
place_id: 11,
start_day_id: D1,
end_day_id: D2,
place_lat: 1,
place_lng: 2,
place_name: 'A',
check_out: '10:00',
reservation_id: 31,
},
{
id: 2,
place_id: 12,
start_day_id: D2,
end_day_id: D3,
place_lat: null,
place_lng: null,
place_name: null,
check_out: null,
reservation_id: null,
},
]);
expect(hotelBookendsOn({ roadtrip_hotel_bookends: true })).toBe(true);
expect(hotelBookendsOn({ roadtrip_hotel_bookends: false })).toBe(false);
expect(hotelBookendsOn({})).toBe(false);
expect(hotelBookendsOn({ roadtrip_hotel_bookends: null })).toBe(false);
});
});
describe('with the switch off', () => {
it('ROADTRIP-BOOKENDS-027: the plan is the one stored, stop for stop, whatever stays the trip holds', () => {
const hotel = stay(5, D1, D4, HOTEL);
const plan = [
planDay(D1, [stayStop(D1, 0, HOTEL, { name: 'H' }), visit(D1, 1, { name: 'P1' })]),
planDay(D2, [visit(D2, 0, { name: 'P2' })]),
planDay(D4, []),
];
const read = (on: boolean) => (on ? seatNightBookends(plan, days, [hotel]) : plan);
const off = read(hotelBookendsOn({}));
expect(off).toBe(plan);
expect(off.map(shape)).toEqual([['H', 'P1'], ['P2'], []]);
// Only the switch set on seats anything.
expect(read(hotelBookendsOn({ roadtrip_hotel_bookends: true })).map(shape)).toEqual([
['H', 'P1', 'evening:5'],
['morning:5', 'P2', 'evening:5'],
[],
]);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { getDayBookendHotels, getDayOrder, hotelIsTheStop, type DayRef } from '../day/stay-bookends';
import { closesTheDay, opensTheDay } from './carriers';
import { stationary } from './dayWindow';
import type { BookendPhase, PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
/**
* A booked night at both ends of the days around it.
*
* A stay's hotel is a stop on the day it is checked in on, where the booking put it
* (#2354). The days after it did not know about it: a day after a booked night set off
* from its first place, a day before one ended at its last, and with the days connected
* the drive between them ran from one place to the other as if nobody slept anywhere. The
* day plan has always drawn those two legs (`getDayBookendHotels`); this seats the same
* two hotels on the road trip, as stops of their own at a day's edges, so the drive to
* them and from them is timed, measured and drawn like any other.
*
* One rule for the browser and the server, run right after the terminals are seated
* (`seatCarrierStops`) and before a day is asked whether it stands as one. Nothing is
* stored: a bookend is the stay's place with an id that belongs to no assignment, and
* every reader that files something by a stored stop's index passes it over
* (`isStoredStop`).
*/
/** A stay as the rule reads it: its nights, its place, and the booking behind it. */
export interface BookendStay {
id: number;
place_id?: number | null;
start_day_id: number;
end_day_id: number;
place_lat?: number | null;
place_lng?: number | null;
place_name?: string | null;
check_out?: string | null;
reservation_id?: number | null;
}
/**
* Below the terminals (-3e9), the automatic nights (-2e9) and the days the window
* invents (-1e9), so a bookend's id never meets one of theirs.
*/
const BOOKEND_ID_BASE = -6_000_000_000;
/** The id a day's bookend goes by: one per day and phase, and the same on every run. */
export function bookendAssignmentId(dayId: number, phase: BookendPhase): number {
return BOOKEND_ID_BASE - dayId * 2 - (phase === 'evening' ? 1 : 0);
}
/** Whether a trip's days start and end at the stay. Off unless it was switched on. */
export function hotelBookendsOn(preferences: { roadtrip_hotel_bookends?: boolean | null }): boolean {
return preferences.roadtrip_hotel_bookends === true;
}
/**
* Whether the drive between two stops is a night spent at one hotel: one end is a
* bookend and both stand on the same spot. Nothing is driven there, and nothing is asked.
*/
export function isStationaryJoin(
from: Pick<RoadtripStop, 'bookend' | 'lat' | 'lng'>,
to: Pick<RoadtripStop, 'bookend' | 'lat' | 'lng'>,
): boolean {
return !!(from.bookend || to.bookend) && from.lat === to.lat && from.lng === to.lng;
}
/**
* A leg lookup that answers a night spent at one hotel with a leg going nowhere, ahead of
* whatever is stored for the pair. A leg fetched for the same two points some other time
* is a drive, and the night is not one.
*/
export function withStationaryJoins(
lookup: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined,
): (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined {
return (from, to) => (isStationaryJoin(from, to) ? stationary(from) : lookup(from, to));
}
/**
* The stays as the rule reads them, from the trip's stays and its bookings: each stay
* once, with the earliest booking linked to it. A booking names its stay as text on some
* rows, so the link is compared as a number.
*/
export function bookendStaysOf<S extends Omit<BookendStay, 'reservation_id'>>(
stays: readonly S[],
reservations: readonly { id: number; accommodation_id?: number | string | null }[],
): BookendStay[] {
const seen = new Set<number>();
const out: BookendStay[] = [];
for (const stay of stays) {
if (seen.has(stay.id)) continue;
seen.add(stay.id);
const linked = reservations.filter(
(r) => r.accommodation_id != null && r.accommodation_id !== '' && Number(r.accommodation_id) === stay.id,
);
out.push({
id: stay.id,
place_id: stay.place_id ?? null,
start_day_id: stay.start_day_id,
end_day_id: stay.end_day_id,
place_lat: stay.place_lat ?? null,
place_lng: stay.place_lng ?? null,
place_name: stay.place_name ?? null,
check_out: stay.check_out ?? null,
reservation_id: linked.length ? Math.min(...linked.map((r) => r.id)) : null,
});
}
return out;
}
function bookendStop(day: PlanDay, phase: BookendPhase, stay: BookendStay, ownerIndex: number): RoadtripStop {
const checkingOut = phase === 'morning' && stay.end_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,
checkOut: checkingOut ? (stay.check_out ?? null) : null,
},
assignmentId: bookendAssignmentId(day.dayId, phase),
ownerDayId: day.dayId,
ownerIndex,
placeId: stay.place_id!,
name: stay.place_name ?? '',
lat: stay.place_lat!,
lng: stay.place_lng!,
// No clock of its own. The check-out is the latest the room is handed back, not when
// the drive leaves (#2357), and the evening's check-in is the stay's business: a stop
// nobody can move or edit must never be the one the day is late for.
time: null,
leaveAt: null,
checkInTime: null,
dwellMinutes: 0,
night: false,
endDay: false,
legMode: null,
incomingLegMode: null,
stopType: 'hotel',
fillPercent: null,
};
}
const sameSpot = (a: BookendStay | undefined, b: BookendStay | undefined): boolean =>
!!a && !!b && a.place_lat === b.place_lat && a.place_lng === b.place_lng;
/**
* The plan's days with the night before and the night after seated at their edges.
*
* Which stay a day wakes up in and which one it sleeps in is the day plan's own choice
* (`getDayBookendHotels`), read over the real nights only: a stay that begins and ends on
* one day is slept in by nobody. The stays are read in id order, so the browser, which may
* hold them in any order, and the server choose the same one where two overlap.
*
* The morning gets the stay slept in, unless the day already starts there, or starts on
* landing or at a hire car's desk, where the traveller was not at the hotel. The evening
* gets tonight's stay, unless the day already ends there, or ends at a departure or at the
* desk the hire car goes back to. A day without a stop of its own gets both or neither: a
* 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.
*
* 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[] {
const dayOf = (id: number): DayRef | undefined => days.find((d) => d.id === id);
const realNight = (stay: BookendStay): boolean => {
const start = dayOf(stay.start_day_id);
const end = dayOf(stay.end_day_id);
return start && end ? getDayOrder(end, days) > getDayOrder(start, days) : stay.end_day_id > stay.start_day_id;
};
const nights = stays.filter((stay) => stay.place_id != null && realNight(stay)).sort((a, b) => a.id - b.id);
if (!nights.length) return plan;
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;
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)));
let tail =
!!hotels.evening &&
!!hotels.eveningIsOvernight &&
!(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role)));
if (!stops.length) head = tail = head && tail && !sameSpot(hotels.morning, hotels.evening);
if (!head && !tail) return day;
changed = true;
const stored = stops.filter((stop) => !stop.carrier).length;
return {
...day,
stops: [
...(head ? [bookendStop(day, 'morning', hotels.morning!, 0)] : []),
...stops,
...(tail ? [bookendStop(day, 'evening', hotels.evening!, stored)] : []),
],
};
});
return changed ? out : plan;
}
+44
View File
@@ -1,3 +1,4 @@
import { withStationaryJoins } from './nightBookends';
import { spillChains } from './nightSpill';
import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types';
import type { RouteSegment } from './planning-types';
@@ -571,3 +572,46 @@ describe('spillChains', () => {
});
});
});
describe('a booked night reached after midnight', () => {
const bookend = (phase: 'morning' | 'evening') => ({
phase,
accommodationId: 5,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
});
it('FE-NIGHTSPILL-028: the drive back to the hotel goes on the next card, and the morning waits for it', () => {
// 20:00 + 1 h and four hours back to the hotel: in at 01:00. The night at the hotel
// is one spot, so the morning sets out no earlier than that.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '20:00', dwellMinutes: 60 }),
stop({
assignmentId: -6_000_000_003,
name: 'Back to the hotel',
lat: 52,
lng: 13,
bookend: bookend('evening'),
}),
]),
day(2, 2, [
stop({ assignmentId: -6_000_000_004, name: 'From the hotel', lat: 52, lng: 13, bookend: bookend('morning') }),
stop({ assignmentId: 3, name: 'Dresden' }),
]),
];
const chains = spillChains(
plan,
[],
withStationaryJoins(() => ({ ...everyLeg(240)(), vias: [] })),
);
expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg']);
expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Back to the hotel', 'From the hotel', 'Dresden']);
expect(chains[1]!.spills).toEqual([expect.objectContaining({ at: 0, count: 1, fromDayNumber: 1 })]);
expect(chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['01:00', '01:00', '05:00']);
});
});
+29
View File
@@ -28,10 +28,39 @@ export interface CarrierTerminal {
at: string | null;
}
/** Which end of a day a booked night stands at: the stay slept in, or tonight's. */
export type BookendPhase = 'morning' | 'evening';
/**
* A booked night standing at one end of a day it is no stop of: in the morning the stay
* the traveller woke up in, in the evening the one they sleep in (`seatNightBookends`).
*
* The stay's own place, not an assignment: nothing about it can be moved, timed or given a
* via point, and what it opens is the booking behind the night, or the stay itself when
* nothing was booked for it.
*/
export interface NightBookend {
phase: BookendPhase;
accommodationId: number;
/** The booking behind the night, the one a tap opens. Null for a stay entered without one. */
reservationId: number | null;
/** A morning on the day the stay is handed back. */
checkingOut: boolean;
/** An evening on the day the stay begins. */
checkingIn: boolean;
/**
* The latest the room has to be handed back, on the morning it is: a label for the row,
* never a time the drive leaves at (#2357).
*/
checkOut: 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;
/** Set on a booked night standing at the start or the end of a day. Belongs to no assignment. */
bookend?: NightBookend;
assignmentId: number;
ownerDayId: number;
+1
View File
@@ -8,3 +8,4 @@ export * from './vehicleRange';
export * from './assemble';
export * from './routeRun';
export * from './carriers';
export * from './nightBookends';
+25
View File
@@ -19,6 +19,7 @@ import {
reanchorAfterReorder,
reanchorByStopOrder,
seamViaIndex,
isStoredStop,
} from './roadtripModel';
import { describe, it, expect } from 'vitest';
@@ -1243,3 +1244,27 @@ describe('seamViaIndex', () => {
}
});
});
describe('isStoredStop', () => {
it('FE-ROADTRIP-MODEL-111: a stored stop is an assignment, not a marker, a terminal or a booked night at the edge', () => {
expect(isStoredStop({})).toBe(true);
expect(isStoredStop({ automaticNight: { phase: 'end', fromDayNumber: 1 } })).toBe(false);
expect(
isStoredStop({
carrier: { reservationId: 1, type: 'flight', role: 'arrival', title: 'Flight', code: null, at: null },
}),
).toBe(false);
expect(
isStoredStop({
bookend: {
phase: 'morning',
accommodationId: 1,
reservationId: null,
checkingOut: false,
checkingIn: false,
checkOut: null,
},
}),
).toBe(false);
});
});
+14
View File
@@ -377,6 +377,20 @@ export function computeSchedule(
* at the bottom of the rail under "only X so far", which reads as the hotel missing
* from the road trip altogether.
*/
/**
* Whether a stop is one somebody stored on the day: an assignment, filed at its own
* index. Not an automatic night, a terminal or a booked night at a day's edge, which the
* plan seats between the stored stops and which borrow a stored stop's index to be seated
* by. A reader looking a stop up by its day and index has to pass those three over, or it
* finds the marker, the airport or the hotel in front of the stop it meant.
*
* Asked of the shape rather than of the id's sign: a stop added a moment ago carries a
* temporary negative id until the server answers, and is stored all the same.
*/
export function isStoredStop(stop: Pick<RoadtripStop, 'automaticNight' | 'carrier' | 'bookend'>): boolean {
return !stop.automaticNight && !stop.carrier && !stop.bookend;
}
export function standsAsDay(stops: readonly { night?: boolean }[]): boolean {
return stops.length > 1 || stops.some((stop) => stop.night === true);
}