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

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

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

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

Closes #2428
This commit is contained in:
Maurice
2026-09-21 16:03:21 +02:00
parent 4be2314856
commit fa35966ab8
47 changed files with 1856 additions and 98 deletions
@@ -116,3 +116,71 @@ describe('a visit end time on the road trip', () => {
expect(calculated.days[0].stops[1].leaveAt).toBeNull();
});
});
describe('a booking the traveller rides (#2428)', () => {
function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
const result = db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, ?, 'flight', ?, ?, ?, ?)`,
)
.run(tripId, 'LH 2020 HAM → MUC', dayId, over.end_day_id ?? dayId, over.dep ?? '13:20', over.arr ?? '14:30');
const id = Number(result.lastInsertRowid);
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99);
endpoint.run(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78);
return id;
}
it('seats the terminals in the day and never asks the router for the ride', async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
const flightId = withFlight(day.day_id, trip.id);
const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.carriers).toHaveLength(1);
expect(context.carriers[0].endpoints).toHaveLength(2);
const card = calculated.days[0];
// Behind Lueneburg (10:00), before the untimed Celle: the day plan's own seat.
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Hamburg', 'Lueneburg', 'departure', 'arrival', 'Celle']);
const departure = card.stops[2];
expect(departure.carrier).toMatchObject({ reservationId: flightId, type: 'flight', code: 'HAM', at: '13:20' });
expect(departure.assignmentId).toBeLessThan(-2_000_000_000);
// The ride is a leg of the booking's minutes with no road under it; the roads to
// and from the terminals were routed like any other leg.
expect(card.legs.map((l) => [l?.mode, l?.duration])).toEqual([
['driving', 3600],
['driving', 3600],
['flight', 70 * 60],
['driving', 3600],
]);
expect(card.legs[2]?.distance).toBe(0);
// Reached an hour ahead of the flight, off on the timetable, landed on the timetable.
expect(card.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['09:00', '09:00'],
['10:00', '10:30'],
['12:20', '13:20'],
['14:30', '14:30'],
['15:30', '16:00'],
]);
// The day's figures are the drive's: three roads of 60 km, no flight in them.
expect(card.distance).toBe(180_000);
expect(calculated.totalStops).toBe(3);
});
it('lets a booking without located terminals fall through, and ignores a hire car', async () => {
const { user, trip, visits, plans } = setup();
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.carriers.map((c) => c.type)).toEqual(['train']);
expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true);
});
});