fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock

A check-out is the LATEST the room has to be handed back, never the earliest
anybody may drive on, so it says nothing the chain can use. It shaped four things
it had no business shaping: the departure the day was built from, the length the
rail printed for a booked night, a day-selector and a clock field in the stop
dialog, and the branch that sent a night's STAY badge to the booking form because
its length was not editable here. All four go. The booking keeps its check-out;
it is a booking detail and lives under Days, where it is entered.

What replaces it is the thing that was always the honest answer: how long you
stand there. That is the traveller's own statement rather than a deadline, and a
stay running past midnight now carries the clock into the morning after — a stop
pinned before it ends keeps its hour and collects the late finding, as it would
from any leg it cannot make. A stay spanning whole days walks the clock forward
over them. An ordinary evening hands on nothing: the night between two days is
not a wait somebody is serving out.

The stay dialog goes to a full day rather than four hours, since it is now the
only place a night is given its hours, and offers eight and twelve as presets.
Every stop answers the same question the same way.

Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so
the first time each dialog was ever opened a pale block flashed under the planner
and never again once its chunk was cached.
This commit is contained in:
Maurice
2026-09-13 14:23:49 +02:00
parent f4809a67d3
commit 3d201e1ced
39 changed files with 208 additions and 215 deletions
-2
View File
@@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'وجهة',
'roadtrip.stay.mode.pause': 'استراحة',
'roadtrip.stay.mode.night': 'مبيت',
'roadtrip.stay.checkOutDay': 'المغادرة في',
'roadtrip.stay.dayNumber': 'اليوم {number}',
'roadtrip.stay.nightAdded': 'أُضيف كمبيت',
'roadtrip.stay.nightAction': 'إضافة كمبيت',
'roadtrip.stop.makeService': 'اجعله توقفاً في الطريق',
-2
View File
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Um destino',
'roadtrip.stay.mode.pause': 'Pausa',
'roadtrip.stay.mode.night': 'Pernoite',
'roadtrip.stay.checkOutDay': 'Saída em',
'roadtrip.stay.dayNumber': 'Dia {number}',
'roadtrip.stay.nightAdded': 'Adicionado como pernoite',
'roadtrip.stay.nightAction': 'Adicionar como pernoite',
'roadtrip.stop.makeService': 'Tornar uma parada no caminho',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Una destinació',
'roadtrip.stay.mode.pause': 'Parada',
'roadtrip.stay.mode.night': 'Pernoctar',
'roadtrip.stay.checkOutDay': 'Sortida el',
'roadtrip.stay.dayNumber': 'Dia {number}',
'roadtrip.stay.nightAdded': 'Afegit com a pernoctació',
'roadtrip.stay.nightAction': 'Afegeix com a pernoctació',
'roadtrip.stop.makeService': 'Converteix en parada del camí',
-2
View File
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Cíl cesty',
'roadtrip.stay.mode.pause': 'Pauza',
'roadtrip.stay.mode.night': 'Nocleh',
'roadtrip.stay.checkOutDay': 'Odjezd',
'roadtrip.stay.dayNumber': 'Den {number}',
'roadtrip.stay.nightAdded': 'Přidáno jako nocleh',
'roadtrip.stay.nightAction': 'Přidat jako nocleh',
'roadtrip.stop.makeService': 'Změnit na zastávku po cestě',
-2
View File
@@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Ein Ziel',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Übernachten',
'roadtrip.stay.checkOutDay': 'Abreise am',
'roadtrip.stay.dayNumber': 'Tag {number}',
'roadtrip.stay.nightAdded': 'Als Übernachtung hinzugefügt',
'roadtrip.stay.nightAction': 'Als Übernachtung hinzufügen',
'roadtrip.stop.makeService': 'Zum Halt unterwegs machen',
-2
View File
@@ -142,8 +142,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'A destination',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Overnight',
'roadtrip.stay.checkOutDay': 'Check out on',
'roadtrip.stay.dayNumber': 'Day {number}',
'roadtrip.stay.nightAdded': 'Added as an overnight stay',
'roadtrip.stay.nightAction': 'Add as an overnight stay',
'roadtrip.stop.makeService': 'Make it a stop on the way',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Un destino',
'roadtrip.stay.mode.pause': 'Parada',
'roadtrip.stay.mode.night': 'Pernoctar',
'roadtrip.stay.checkOutDay': 'Salida el',
'roadtrip.stay.dayNumber': 'Día {number}',
'roadtrip.stay.nightAdded': 'Añadido como pernoctación',
'roadtrip.stay.nightAction': 'Añadir como pernoctación',
'roadtrip.stop.makeService': 'Convertir en parada del camino',
-2
View File
@@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Une destination',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Nuitée',
'roadtrip.stay.checkOutDay': 'Départ le',
'roadtrip.stay.dayNumber': 'Jour {number}',
'roadtrip.stay.nightAdded': 'Ajouté comme nuitée',
'roadtrip.stay.nightAction': 'Ajouter comme nuitée',
'roadtrip.stop.makeService': 'En faire un arrêt en chemin',
-2
View File
@@ -149,8 +149,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Προορισμός',
'roadtrip.stay.mode.pause': 'Στάση',
'roadtrip.stay.mode.night': 'Διανυκτέρευση',
'roadtrip.stay.checkOutDay': 'Αναχώρηση',
'roadtrip.stay.dayNumber': 'Ημέρα {number}',
'roadtrip.stay.nightAdded': 'Προστέθηκε ως διανυκτέρευση',
'roadtrip.stay.nightAction': 'Προσθήκη ως διανυκτέρευση',
'roadtrip.stop.makeService': 'Μετατροπή σε στάση στον δρόμο',
-2
View File
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Úti cél',
'roadtrip.stay.mode.pause': 'Szünet',
'roadtrip.stay.mode.night': 'Éjszakázás',
'roadtrip.stay.checkOutDay': 'Kijelentkezés',
'roadtrip.stay.dayNumber': '{number}. nap',
'roadtrip.stay.nightAdded': 'Hozzáadva éjszakázásként',
'roadtrip.stay.nightAction': 'Hozzáadás éjszakázásként',
'roadtrip.stop.makeService': 'Legyen útközbeni megálló',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Sebuah tujuan',
'roadtrip.stay.mode.pause': 'Istirahat',
'roadtrip.stay.mode.night': 'Menginap',
'roadtrip.stay.checkOutDay': 'Check out',
'roadtrip.stay.dayNumber': 'Hari {number}',
'roadtrip.stay.nightAdded': 'Ditambahkan sebagai menginap',
'roadtrip.stay.nightAction': 'Tambahkan sebagai menginap',
'roadtrip.stop.makeService': 'Jadikan perhentian di jalan',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Una destinazione',
'roadtrip.stay.mode.pause': 'Sosta',
'roadtrip.stay.mode.night': 'Pernottamento',
'roadtrip.stay.checkOutDay': 'Partenza il',
'roadtrip.stay.dayNumber': 'Giorno {number}',
'roadtrip.stay.nightAdded': 'Aggiunto come pernottamento',
'roadtrip.stay.nightAction': 'Aggiungi come pernottamento',
'roadtrip.stop.makeService': 'Trasforma in sosta lungo il percorso',
-2
View File
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '休憩',
'roadtrip.stay.mode.night': '宿泊',
'roadtrip.stay.checkOutDay': 'チェックアウト日',
'roadtrip.stay.dayNumber': '{number}日目',
'roadtrip.stay.nightAdded': '宿泊として追加しました',
'roadtrip.stay.nightAction': '宿泊として追加',
'roadtrip.stop.makeService': '途中の立ち寄りにする',
-2
View File
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': '목적지',
'roadtrip.stay.mode.pause': '휴식',
'roadtrip.stay.mode.night': '숙박',
'roadtrip.stay.checkOutDay': '체크아웃 날짜',
'roadtrip.stay.dayNumber': '{number}일차',
'roadtrip.stay.nightAdded': '숙박으로 추가했습니다',
'roadtrip.stay.nightAction': '숙박으로 추가',
'roadtrip.stop.makeService': '경유 정차로 변경',
-2
View File
@@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Een bestemming',
'roadtrip.stay.mode.pause': 'Pauze',
'roadtrip.stay.mode.night': 'Overnachten',
'roadtrip.stay.checkOutDay': 'Uitchecken op',
'roadtrip.stay.dayNumber': 'Dag {number}',
'roadtrip.stay.nightAdded': 'Toegevoegd als overnachting',
'roadtrip.stay.nightAction': 'Toevoegen als overnachting',
'roadtrip.stop.makeService': 'Maak er een stop onderweg van',
-2
View File
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Cel podróży',
'roadtrip.stay.mode.pause': 'Postój',
'roadtrip.stay.mode.night': 'Nocleg',
'roadtrip.stay.checkOutDay': 'Wymeldowanie',
'roadtrip.stay.dayNumber': 'Dzień {number}',
'roadtrip.stay.nightAdded': 'Dodano jako nocleg',
'roadtrip.stay.nightAction': 'Dodaj jako nocleg',
'roadtrip.stop.makeService': 'Zamień w postój po drodze',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Пункт назначения',
'roadtrip.stay.mode.pause': 'Остановка',
'roadtrip.stay.mode.night': 'Ночёвка',
'roadtrip.stay.checkOutDay': 'Выезд',
'roadtrip.stay.dayNumber': 'День {number}',
'roadtrip.stay.nightAdded': 'Добавлено как ночёвка',
'roadtrip.stay.nightAction': 'Добавить как ночёвку',
'roadtrip.stop.makeService': 'Сделать остановкой в пути',
-2
View File
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Ett resmål',
'roadtrip.stay.mode.pause': 'Paus',
'roadtrip.stay.mode.night': 'Övernattning',
'roadtrip.stay.checkOutDay': 'Utcheckning',
'roadtrip.stay.dayNumber': 'Dag {number}',
'roadtrip.stay.nightAdded': 'Tillagd som övernattning',
'roadtrip.stay.nightAction': 'Lägg till som övernattning',
'roadtrip.stop.makeService': 'Gör det till ett stopp på vägen',
-2
View File
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Bir varış noktası',
'roadtrip.stay.mode.pause': 'Mola',
'roadtrip.stay.mode.night': 'Konaklama',
'roadtrip.stay.checkOutDay': 'Çıkış günü',
'roadtrip.stay.dayNumber': '{number}. gün',
'roadtrip.stay.nightAdded': 'Konaklama olarak eklendi',
'roadtrip.stay.nightAction': 'Konaklama olarak ekle',
'roadtrip.stop.makeService': 'Yol üzerinde molaya dönüştür',
-2
View File
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Пункт призначення',
'roadtrip.stay.mode.pause': 'Зупинка',
'roadtrip.stay.mode.night': 'Ночівля',
'roadtrip.stay.checkOutDay': 'Виїзд',
'roadtrip.stay.dayNumber': 'День {number}',
'roadtrip.stay.nightAdded': 'Додано як ночівлю',
'roadtrip.stay.nightAction': 'Додати як ночівлю',
'roadtrip.stop.makeService': 'Зробити зупинкою в дорозі',
-2
View File
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': 'Một điểm đến',
'roadtrip.stay.mode.pause': 'Nghỉ',
'roadtrip.stay.mode.night': 'Qua đêm',
'roadtrip.stay.checkOutDay': 'Trả phòng ngày',
'roadtrip.stay.dayNumber': 'Ngày {number}',
'roadtrip.stay.nightAdded': 'Đã thêm làm nơi nghỉ đêm',
'roadtrip.stay.nightAction': 'Thêm làm nơi nghỉ đêm',
'roadtrip.stop.makeService': 'Biến thành điểm dừng dọc đường',
-2
View File
@@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '停留',
'roadtrip.stay.mode.night': '過夜',
'roadtrip.stay.checkOutDay': '退房日',
'roadtrip.stay.dayNumber': '第 {number} 天',
'roadtrip.stay.nightAdded': '已新增為過夜住宿',
'roadtrip.stay.nightAction': '新增為過夜住宿',
'roadtrip.stop.makeService': '設為途中停靠',
-2
View File
@@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '停留',
'roadtrip.stay.mode.night': '过夜',
'roadtrip.stay.checkOutDay': '退房日',
'roadtrip.stay.dayNumber': '第 {number} 天',
'roadtrip.stay.nightAdded': '已添加为过夜住宿',
'roadtrip.stay.nightAction': '添加为过夜住宿',
'roadtrip.stop.makeService': '设为途中停靠',
+1 -10
View File
@@ -90,7 +90,6 @@ export function assembleRoadtrip({
d.stops.map((s) => ({
anchor: s.time ?? s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
})),
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
),
@@ -160,15 +159,7 @@ export function assembleRoadtrip({
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)];
const line = spurFor(snap);
if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) });
const entry = schedule.entries[index];
const arrival = parseClock(entry?.arrival);
const dwellMinutes =
s.checkoutAt !== undefined && arrival !== null
? Math.max(0, s.checkoutAt - ((chain.dayNumber + (entry?.dayOffset ?? 0)) * 1440 + arrival))
: s.checkoutAt !== undefined
? null
: s.dwellMinutes;
return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null };
return { ...s, offRoadMeters: line ? snap!.meters : null };
});
const drive = {
+2 -7
View File
@@ -18,15 +18,10 @@ export function continueSchedule(
if (departure === null || duration === undefined) continue;
const previousArrival = parseClock(previous.arrival);
const departureAt =
stops[i - 1]!.checkoutAt !== undefined
? stops[i - 1]!.checkoutAt! - dayNumber * 1440
: previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
const arrivalAt = departureAt + Math.round(duration / 60);
const stop = stops[i]!;
const leaveAt =
stop.checkoutAt === undefined
? arrivalAt + (stop.dwellMinutes ?? 0)
: Math.max(arrivalAt, stop.checkoutAt - dayNumber * 1440);
const leaveAt = arrivalAt + (stop.dwellMinutes ?? 0);
entry.arrival = formatClock(arrivalAt);
entry.departure = formatClock(leaveAt);
entry.dayOffset = Math.floor(arrivalAt / 1440);
+12 -31
View File
@@ -366,47 +366,28 @@ describe('daily travel window', () => {
});
});
describe('booked checkout', () => {
it('waits at the hotel until noon on the checkout day', () => {
const hotel = stop(2, { checkoutAt: 2 * 1440 + 720, dwellMinutes: 30 });
const stops = [stop(1), hotel, stop(3)];
const plan = calculate(stops, [60, 60]);
expect(plan.issue).toBeNull();
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00');
expect(plan.chains[0]!.schedule.entries[1]!.departure).toBe('12:00');
expect(plan.chains[1]!.schedule.entries[0]!.arrival).toBe('12:00');
expect(plan.chains[1]!.schedule.entries[1]!.arrival).toBe('13:00');
});
it('honours an early checkout and multiple nights', () => {
const stops = [stop(1, { checkoutAt: 3 * 1440 + 420 }), stop(2)];
const plan = calculate(stops, [60], [day(2), day(3)]);
expect(plan.issue).toBeNull();
expect(plan.chains[2]!.schedule.entries[0]!.arrival).toBe('07:00');
expect(plan.chains[2]!.schedule.entries[1]!.arrival).toBe('08:00');
});
it('does not move time backwards when arrival is after checkout', () => {
const stops = [stop(1, { time: '13:00', checkoutAt: 1440 + 720 }), stop(2)];
const plan = calculate(stops, [60]);
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00');
});
});
it('keeps checkout when the drive arrives after suggested check-in', () => {
it('waits for a check-in the drive overshoots rather than rushing it', () => {
const stops = [
stop(1, { time: '07:00', dwellMinutes: 60 }),
stop(2, { checkInTime: '09:11', checkoutAt: 2 * 1440 + 600 }),
stop(2, { checkInTime: '09:11' }),
stop(3),
];
const plan = calculate(stops, [72.5, 29]);
expect(plan.issue).toBeNull();
// Arrives two minutes past the hour it may check in from, and is left there rather
// than pulled back to the round number.
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13');
expect(plan.chains[1]!.stops.at(-1)!.placeId).toBe(3);
expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29');
const stops0 = plan.chains[0]!.stops;
expect(stops0.at(-1)!.placeId).toBe(3);
// And carries straight on from there: nothing holds the traveller at the stop.
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42');
});
it('waits for check-in without treating it as a fixed appointment', () => {
const plan = calculate([stop(1), stop(2, { checkInTime: '15:00', checkoutAt: 2 * 1440 + 600 }), stop(3)], [60, 29]);
const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]);
expect(plan.issue).toBeNull();
// The check-in is a door opening, not an appointment: the drive waits for it and then
// carries straight on, rather than the day being rebuilt around it.
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29');
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
});
-21
View File
@@ -181,7 +181,6 @@ export function planDayWindow(
name: labels.end,
time: null,
dwellMinutes: 0,
checkoutAt: undefined,
checkInTime: undefined,
stopType: null,
fillPercent: null,
@@ -278,26 +277,6 @@ export function planDayWindow(
append(stop, clock);
previous = stop;
if (stop.checkoutAt !== undefined) {
const checkoutDay = Math.floor(stop.checkoutAt / 1440);
const checkoutTime = stop.checkoutAt % 1440;
const arrivalAt = number * 1440 + clock;
if (stop.checkoutAt > arrivalAt) {
const entries = chainAt(number).schedule.entries;
entries[entries.length - 1]!.departure = formatClock(checkoutTime);
while (number < checkoutDay) {
previous = night(previous);
if (issue) return failed(issue);
}
clock = checkoutTime;
if (previous.automaticNight?.phase === 'start') {
const entries = chainAt(number).schedule.entries;
entries[entries.length - 1]!.arrival = formatClock(clock);
entries[entries.length - 1]!.departure = formatClock(clock);
}
}
continue;
}
let dwell = Math.max(0, stop.dwellMinutes ?? 0);
while (dwell > 0) {
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
+62
View File
@@ -276,4 +276,66 @@ describe('spillChains', () => {
expect(chains[1]!.spills).toHaveLength(1);
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
});
describe('a stay that runs past midnight carries the clock into the next day', () => {
// Standing somewhere for twenty-four hours is not over when the date changes. The
// day after used to begin at nothing, so a trip whose second day had no pinned time
// showed no times at all, right after the first day had said exactly when it ends.
it('FE-NIGHTSPILL-013: the next day starts where the stay ends', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '07:56' }),
stop({ assignmentId: 2, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Panorama' }), stop({ assignmentId: 4, name: 'Bergedorf' })]),
];
const chains = spillChains(plan, [], everyLeg(11));
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
expect(dayTwo.schedule.entries[0]!.arrival).toBe('08:00');
expect(dayTwo.schedule.entries[1]!.arrival).toBe('08:11');
});
it('FE-NIGHTSPILL-014: an ordinary evening hands on nothing', () => {
// The night between two days is not a wait somebody is serving out. Only a stay
// that genuinely runs past midnight reaches into the morning.
const plan = [
day(1, 1, [stop({ assignmentId: 1, name: 'Hamburg', time: '18:00', dwellMinutes: 60 })]),
day(2, 2, [stop({ assignmentId: 2, name: 'Panorama' })]),
];
const chains = spillChains(plan, [], everyLeg(11));
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
});
it('FE-NIGHTSPILL-015: a stop pinned before the stay ends keeps its clock and is flagged', () => {
const plan = [
day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 })]),
day(2, 2, [stop({ assignmentId: 2, name: 'Panorama', time: '07:00' })]),
];
const chains = spillChains(plan, [], everyLeg(11));
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
// Never moved: the hour is the traveller's.
expect(dayTwo.schedule.entries[0]!.arrival).toBe('07:00');
expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'late', minutes: 60 });
});
it('FE-NIGHTSPILL-016: a stay of two whole days walks the clock forward day by day', () => {
const plan = [
day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 48 * 60 })]),
day(2, 2, [stop({ assignmentId: 2, name: 'Nothing planned' })]),
day(3, 3, [stop({ assignmentId: 3, name: 'Berlin' })]),
];
const chains = spillChains(plan, [], everyLeg(11));
// Day two is still inside the stay, so it cannot begin at all.
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
expect(chains.find((c) => c.dayNumber === 3)!.schedule.entries[0]!.arrival).toBe('08:00');
});
});
});
+25 -2
View File
@@ -69,17 +69,40 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
const landing = new Map<number, Placed[]>();
for (const d of all) landing.set(d.dayNumber, []);
/**
* How far into a day the one before it still reaches.
*
* A stop stood at for twenty-four hours is not over when the date changes: the day
* after it begins where it ends, not at nothing. Only ever a floor, and only when a
* stay genuinely runs past midnight — an ordinary day that finishes at six in the
* evening hands on nothing, because the night between them is not a wait, it is a
* night.
*/
let carriedInto: { dayNumber: number; minute: number } | null = null;
for (const d of all) {
const notBefore = carriedInto?.dayNumber === d.dayNumber ? carriedInto.minute : null;
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!));
const legs = routed.map((l) => l?.seg);
const schedule = computeSchedule(
d.stops.map((s) => ({
anchor: s.time ?? s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
})),
legs.map((l) => l?.duration),
{ notBefore },
);
// Where this day's last clock lands, carried to whichever day that turns out to be.
const ends = schedule.endsAt ?? null;
if (ends !== null && ends > 1440) {
carriedInto = { dayNumber: d.dayNumber + Math.floor(ends / 1440), minute: ends % 1440 };
} else if (carriedInto && carriedInto.dayNumber <= d.dayNumber) {
// Spent: this is the day it was pointing at.
carriedInto = null;
}
// Otherwise it is left alone. A stay of several days passes over the days in the
// middle without them having anything to say, and clearing it there would lose the
// morning it was aimed at.
let day = 0;
let previous: number | null = null;
@@ -90,7 +113,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
if (previous !== null && clock < previous) day += 1;
previous = clock;
}
const offset = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day;
const offset = day;
const marks = schedule.warnings.filter((w) => w.index === i);
const reachable = offset > 0 && numbers.has(d.dayNumber + offset);
-1
View File
@@ -18,7 +18,6 @@ export interface RoadtripStop {
time: string | null;
dwellMinutes: number | null;
checkoutAt?: number;
checkInTime?: string | null;
endDay?: boolean;
+1 -1
View File
@@ -252,7 +252,7 @@ describe('computeSchedule', () => {
});
it('handles an empty chain', () => {
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] });
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [], endsAt: null });
});
it('treats a missing dwell as no time spent', () => {
+38 -3
View File
@@ -133,6 +133,15 @@ export interface ScheduleEntry {
export interface Schedule {
entries: ScheduleEntry[];
warnings: ScheduleWarning[];
/**
* Minute the last stop is left at, counted from the start of this day, so a stay
* running past midnight comes back as more than 1440.
*
* The one number the next day needs. A stop stood at for twenty-four hours is not
* over when the date changes, and the day after it cannot begin before it ends.
* Null or absent when nothing on this day has a time at all.
*/
endsAt?: number | null;
}
const DAY_MINUTES = 24 * 60;
@@ -154,13 +163,24 @@ function resolveArrival(
};
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
/**
* @param opts.notBefore Minute of this day the first stop cannot be reached before,
* because the day before is still running into it: a stop stood at past midnight ends
* where it ends, and nothing can happen ahead of that. Behaves like the arrival of an
* imaginary stop just before the first, so a stop pinned earlier keeps its clock and
* picks up the same late finding any leg it cannot make in time would give it.
*/
export function computeSchedule(
stops: ScheduleStop[],
legSeconds: (number | undefined)[],
opts: { notBefore?: number | null } = {},
): Schedule {
const warnings: ScheduleWarning[] = [];
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
const anchored: boolean[] = new Array(stops.length).fill(false);
let cursor: number | null = null;
let cursor: number | null = opts.notBefore ?? null;
let dayOffset = 0;
for (let i = 0; i < stops.length; i++) {
@@ -231,7 +251,22 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
});
}
return { entries, warnings };
// Read off the last stop that actually has a clock: a trailing stop with no time of
// its own carries nothing forward, and a day whose times peter out halfway should
// hand on what it does know rather than nothing.
let endsAt: number | null = null;
for (let i = stops.length - 1; i >= 0; i--) {
const raw = arrivals[i];
if (raw === null || raw === undefined) continue;
const arrival = raw + shift;
endsAt =
stops[i]!.departureAt === undefined
? arrival + (stops[i]!.dwellMinutes ?? 0)
: Math.max(arrival, stops[i]!.departureAt! + shift);
break;
}
return { entries, warnings, endsAt };
}
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {