fix(roadtrip): a booked night no longer shows up mid-day when the day already ends at the stay

Booking a night writes the hotel onto its check-in day as a stop, seated first
or at its check-in, so the drive reaches it even without hotel bookends. With
"Start and end each day at your stay" on, the evening row already ends that day
at the same hotel, so the road trip drove check-out, tonight's hotel, the day's
places and back to the hotel again. Days hides that stop and draws the stay at
the day's edges only.

seatNightBookends now passes over the stop tonight's booking wrote whenever it
seats that stay as the day's evening, in the browser and in calculate_roadtrip
alike. The stops keep their stored index, a hotel the traveller placed stays,
and the booking's stop stays while it is the day's last one or no evening is
seated. With the switch off nothing changes.

Closes #2546
This commit is contained in:
Maurice
2026-09-29 20:53:54 +02:00
parent 5c7aaab3c6
commit 7b0333a0c1
8 changed files with 174 additions and 23 deletions
+57 -2
View File
@@ -1,5 +1,5 @@
/**
* ROADTRIP-BOOKENDS-001..032: a booked night stands at both ends of the days around it.
* ROADTRIP-BOOKENDS-001..035: 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
@@ -7,7 +7,8 @@
* 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. From 029 on: a day moved by a
* ride the road does not see, a terminal out of a drive's reach, and the flight day of
* the report read from the stored day to its nights.
* the report read from the stored day to its nights. From 033 on: the stop a booking put
* on its check-in day gives way to the evening at that stay.
*/
import { assembleRoadtrip } from './assemble';
import { carrierLegsFor, carrierSeam, seatCarrierStops, terminalAssignmentId, type CarrierBooking } from './carriers';
@@ -760,3 +761,57 @@ describe('the flight from Hamburg to the Munich hotel (trip 45, day 3)', () => {
expect(timed(seated!).schedule.entries[0]).toMatchObject({ arrival: '13:45', departure: '13:45' });
});
});
describe('the stop a booking put on its check-in day (#2546)', () => {
const a = stay(1, D1, D2, GETAWAY);
const b = stay(2, D2, D3, WALLINGA);
/** The Wallinga stop the booking wrote, which Days hides. */
const booked = (ownerIndex: number, over: Partial<RoadtripStop> = {}) =>
stayStop(D2, ownerIndex, WALLINGA, { name: 'B', bookedNightId: 2, ...over });
it('ROADTRIP-BOOKENDS-033: seated first without a check-in, it gives way to the evening, which checks in', () => {
const [day] = seatNightBookends(
[planDay(D2, [booked(0), visit(D2, 1, { name: 'P1' }), visit(D2, 2, { name: 'P2', time: '18:00' })])],
days,
[a, b],
);
// Not check out, the hotel, the places and the hotel again: the day ends where it sleeps.
expect(shape(day)).toEqual(['morning:1', 'P1', 'P2', 'evening:2']);
expect(day!.stops[3]!.bookend).toMatchObject({ checkingIn: true, accommodationId: 2 });
// The places keep their stored index, and the evening the one behind the last stored stop.
expect(day!.stops.map((s) => s.ownerIndex)).toEqual([0, 1, 2, 3]);
});
it('ROADTRIP-BOOKENDS-034: seated at its check-in in the middle of the day, it gives way too', () => {
const [day] = seatNightBookends(
[
planDay(D2, [
visit(D2, 0, { name: 'P0', time: '11:00' }),
booked(1, { checkInTime: '15:00' }),
visit(D2, 2, { name: 'P1' }),
]),
],
days,
[a, { ...b, check_in: '15:00' }],
);
expect(shape(day)).toEqual(['morning:1', 'P0', 'P1', 'evening:2']);
// The hour the room is ready stays on the evening's row, as a label.
expect(day!.stops[3]!.bookend).toMatchObject({ checkingIn: true, checkIn: '15:00' });
});
it('ROADTRIP-BOOKENDS-035: the hotel stays where the traveller placed it, and the booked stop where nothing replaces it', () => {
const p = () => visit(D2, 1, { name: 'P' });
// Placed by the traveller: theirs, and the day comes back to it in the evening.
const placed = seatNightBookends([planDay(D2, [booked(0, { bookedNightId: null }), p()])], days, [a, b]);
expect(shape(placed[0])).toEqual(['morning:1', 'B', 'P', 'evening:2']);
// Already the day's last stop: it is the evening, and nothing is seated behind it.
const last = seatNightBookends([planDay(D2, [p(), booked(1)])], days, [a, b]);
expect(shape(last[0])).toEqual(['morning:1', 'P', 'B']);
// A departure closes the day, so no evening is seated and the stop stays the hotel's visit.
const flight = seatNightBookends([planDay(D2, [booked(0), p(), terminal(D2, 2, 'departure')])], days, [a, b]);
expect(shape(flight[0])).toEqual(['morning:1', 'B', 'P', 'departure']);
// Another stay's stop is not tonight's, and is left alone.
const other = seatNightBookends([planDay(D2, [booked(0, { bookedNightId: 9 }), p()])], days, [a, b]);
expect(shape(other[0])).toEqual(['morning:1', 'B', 'P', 'evening:2']);
});
});
+53 -17
View File
@@ -219,29 +219,36 @@ export function seatNightBookends(
const out = plan.map((day) => {
const ref = dayOf(day.dayId);
if (!ref) return day;
const stops = day.stops;
if (stops.every((stop) => stop.night) && ridden(ref)) return day;
if (day.stops.every((stop) => stop.night) && ridden(ref)) return day;
const hotels = getDayBookendHotels(ref, days, nights);
const first = stops[0];
const last = stops[stops.length - 1];
let head =
!!hotels.morning &&
!!hotels.morningIsSleptHere &&
!(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role))) &&
withinReach(hotels.morning, first);
let tail =
!!hotels.evening &&
!!hotels.eveningIsOvernight &&
!(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role))) &&
withinReach(hotels.evening, last);
if (!stops.length) head = tail = head && tail && !sameSpot(hotels.morning, hotels.evening);
const edges = (stops: RoadtripStop[]): { head: boolean; tail: boolean } => {
const first = stops[0];
const last = stops[stops.length - 1];
const head =
!!hotels.morning &&
!!hotels.morningIsSleptHere &&
!(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role))) &&
withinReach(hotels.morning, first);
const tail =
!!hotels.evening &&
!!hotels.eveningIsOvernight &&
!(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role))) &&
withinReach(hotels.evening, last);
if (!stops.length) {
const both = head && tail && !sameSpot(hotels.morning, hotels.evening);
return { head: both, tail: both };
}
return { head, tail };
};
const stops = withoutBookedNight(day.stops, hotels.evening, day.dayId, edges);
const { head, tail } = edges(stops);
if (!head && !tail) return day;
changed = true;
const stored = stops.filter((stop) => !stop.carrier).length;
const stored = day.stops.filter((stop) => !stop.carrier).length;
return {
...day,
stops: [
...(head ? [bookendStop(day, 'morning', hotels.morning!, 0, first?.incomingLegMode ?? null)] : []),
...(head ? [bookendStop(day, 'morning', hotels.morning!, 0, stops[0]?.incomingLegMode ?? null)] : []),
...stops,
...(tail ? [bookendStop(day, 'evening', hotels.evening!, stored, null)] : []),
],
@@ -249,3 +256,32 @@ export function seatNightBookends(
});
return changed ? out : plan;
}
/**
* The day's stops without the one tonight's booking put on it, when the evening seats that
* stay at the end of the day instead (#2546).
*
* Booking a night writes the hotel onto its check-in day as a stop (#2354), seated first or
* at its check-in, so a trip that routes without bookends still reaches the hotel. With
* bookends the evening already ends the day there, and the booking's stop drew the same
* hotel a second time in the middle of the day: check out, drive to tonight's hotel, the
* day's places, and back to the hotel for the night. Days hides that stop and draws the
* stay at the day's edges, and so does the road trip now. A hotel the traveller placed on
* the day themselves is theirs and stays, and so does the booking's stop while it is the
* day's last one, or while the evening is not seated after all (a departure closes the day,
* or the hotel is out of reach of the stop before it). The stops keep their `ownerIndex`,
* so every write still addresses the stored order, and the evening keeps the index behind
* the day's last stored stop.
*/
function withoutBookedNight(
stops: RoadtripStop[],
evening: BookendStay | undefined,
dayId: number,
edges: (stops: RoadtripStop[]) => { tail: boolean },
): RoadtripStop[] {
if (!evening || evening.start_day_id !== dayId || !stops.length) return stops;
const own = (stop: RoadtripStop): boolean => stop.bookedNightId === evening.id && !stop.carrier;
if (!stops.some(own) || own(stops[stops.length - 1]!)) return stops;
const rest = stops.filter((stop) => !own(stop));
return rest.length && edges(rest).tail ? rest : stops;
}
+7
View File
@@ -84,6 +84,13 @@ export interface RoadtripStop {
checkInTime?: string | null;
/** A booked night's stop on its check-in day, whether or not the booking names an hour. */
night?: boolean;
/**
* The stay whose booking wrote this stop (`day_assignments.accommodation_id`), rather
* than the traveller placing the hotel on the day. Days hides such a stop and draws the
* stay at the day's edges instead; `seatNightBookends` does the same once it seats that
* stay at the end of the day.
*/
bookedNightId?: number | null;
endDay?: boolean;
legMode: string | null;