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
+103 -19
View File
@@ -8,6 +8,8 @@ import { Injectable, HttpException } from '@nestjs/common';
import { type RoadtripPreferences } from '@trek/shared';
import {
assembleRoadtrip,
carrierLegsFor,
carrierSeam,
foldRouteRun,
splitIntoRuns,
spillChains,
@@ -16,6 +18,10 @@ import {
parseAvoid,
formatDistance,
formatDurationShort,
seatCarrierStops,
viasLeaving,
type CarrierBooking,
type CarrierSeam,
type DistanceUnit,
type RoadtripStop,
type PlanDay,
@@ -32,6 +38,22 @@ interface StoredDay {
title: string | null;
default_transport_mode: string | null;
}
/**
* A booking the traveller rides, with what the seam needs of it: the days and clocks at
* both ends, the terminals, and where the day plan seats it. Same projection the client
* reads off the reservation list, so the two plans seat a flight in the same place.
*/
interface CarrierRow {
id: number;
type: string;
title: string;
day_id: number | null;
end_day_id: number | null;
reservation_time: string | null;
reservation_end_time: string | null;
metadata: string | null;
day_plan_position: number | null;
}
interface VisitRow {
/** The booking this stop stands for on its check-in day, when there is one. */
stay_id: number | null;
@@ -41,6 +63,7 @@ interface VisitRow {
checkout_day: number | null;
id: number;
day_id: number;
order_index: number;
place_id: number;
name: string;
lat: number | null;
@@ -75,7 +98,7 @@ export class RoadtripPlanService {
tripId,
);
const visits = this.db.all<VisitRow>(
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
`SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
@@ -88,6 +111,7 @@ export class RoadtripPlanService {
return {
days,
visits,
carriers: this.carriers(tripId),
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId),
@@ -96,6 +120,47 @@ export class RoadtripPlanService {
};
}
/**
* The bookings that seam the drive: a flight, train, ferry, cruise or bus on a day, with
* its terminals and the slots the day plan gave it. The reservation service reads the
* same three tables for the booking list; this is the road trip's own cut of them, so
* the module stays out of the reservations graph.
*/
private carriers(tripId: number): CarrierBooking[] {
const rows = this.db.all<CarrierRow>(
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus') AND day_id IS NOT NULL`,
tripId,
);
if (!rows.length) return [];
const endpoints = this.db.all<{
reservation_id: number;
role: string;
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
}>(
`SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`,
tripId,
);
const positions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(
`SELECT p.reservation_id, p.day_id, p.position
FROM reservation_day_positions p JOIN reservations r ON r.id = p.reservation_id WHERE r.trip_id = ?`,
tripId,
);
return rows.map((row) => ({
...row,
endpoints: endpoints.filter((e) => e.reservation_id === row.id),
day_positions: Object.fromEntries(
positions.filter((p) => p.reservation_id === row.id).map((p) => [String(p.day_id), p.position]),
),
}));
}
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
const context = this.context(tripId, userId);
const preferences = { ...context.settings, ...overrides };
@@ -106,14 +171,12 @@ export class RoadtripPlanService {
);
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
const plan: PlanDay[] = context.days.map((day) => ({
dayId: day.id,
dayNumber: day.day_number,
date: day.date,
title: day.title,
stops: context.visits
.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null)
.map((v, index) => ({
const seams = context.carriers.map(carrierSeam).filter((seam): seam is CarrierSeam => seam !== null);
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
const plan: PlanDay[] = context.days.map((day) => {
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
const stops = visits.map(
(v, index): RoadtripStop => ({
assignmentId: v.id,
ownerDayId: day.id,
ownerIndex: index,
@@ -133,8 +196,22 @@ export class RoadtripPlanService {
incomingLegMode: v.incoming_leg_transport_mode,
stopType: v.stop_type,
fillPercent: v.fill_percent,
})),
}));
}),
);
return {
dayId: day.id,
dayNumber: day.day_number,
date: day.date,
title: day.title,
// The terminals of the day's rides, seated where the day plan shows the booking.
stops: seatCarrierStops(
day.id,
stops,
visits.map((v) => v.order_index),
seams,
),
};
});
const allLegs: Record<string, RoutedLeg> = {};
const snapByDay: Record<number, Record<string, SnappedWaypoint>> = {};
const missedByDay: Record<number, RouteAvoidClass[]> = {};
@@ -147,21 +224,28 @@ export class RoadtripPlanService {
const distanceUnit: DistanceUnit =
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
// A ride is no road: the leg between its terminals is the booking's minutes, and
// the router is never asked for it.
const rides = carrierLegsFor(
stops,
profile,
(from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId),
(from, to) => stopKey(from) + '>' + stopKey(to),
);
if (rides) {
Object.assign(allLegs, rides);
return;
}
const points: { lat: number; lng: number }[] = [];
const stopAt: number[] = [];
stops.forEach((stop, index) => {
stopAt.push(points.length);
points.push({ lat: stop.lat, lng: stop.lng });
if (index < stops.length - 1)
points.push(
...context.vias
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
.map((v) => ({ lat: v.lat, lng: v.lng })),
);
points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
});
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
try {
if (points.length > 100) throw new Error('Too many waypoints');
const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid);
@@ -28,7 +28,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'get_roadtrip_context',
description:
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'calculate_roadtrip',
description:
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -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);
});
});