fix(roadtrip): a booked ferry is driven to, and one without a date says so

A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
This commit is contained in:
Maurice
2026-09-23 10:21:00 +02:00
parent e6daed9403
commit ab3a81a9eb
37 changed files with 823 additions and 41 deletions
@@ -19,6 +19,7 @@ import {
formatDistance,
formatDurationShort,
seatCarrierStops,
undatedRides,
viasLeaving,
type CarrierBooking,
type CarrierSeam,
@@ -128,11 +129,35 @@ export class RoadtripPlanService {
* reservations graph.
*/
private carriers(tripId: number): CarrierBooking[] {
return this.withTerminals(
tripId,
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', 'car') AND day_id IS NOT NULL`,
tripId,
),
);
}
/**
* The rides the drive leaves out because they are on no day: a flight, train, ferry,
* cruise or bus with both terminals located and no day to leave on (#2461). The map
* draws their arcs all the same, so the answer names them rather than leaving an
* assistant to read a drive around a booked crossing as the plan. Which ones count is
* `undatedRides` alone, the rule the planner's rail lists them by, so the statement
* names no types of its own.
*/
undatedRides(tripId: number): { id: number; type: string; title: string }[] {
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', 'car') AND day_id IS NOT NULL`,
FROM reservations WHERE trip_id = ? AND day_id IS NULL`,
tripId,
);
return undatedRides(this.withTerminals(tripId, rows)).map(({ id, type, title }) => ({ id, type, title }));
}
/** The rows with their terminals and the slots the day plan gave them joined on. */
private withTerminals(tripId: number, rows: CarrierRow[]): CarrierBooking[] {
if (!rows.length) return [];
const endpoints = this.db.all<{
reservation_id: number;
@@ -359,6 +384,7 @@ export class RoadtripPlanService {
calculated,
failures,
omittedVisits: context.visits.filter((v) => v.lat === null || v.lng === null).map((v) => v.id),
undatedRides: this.undatedRides(tripId),
};
}
}
@@ -49,7 +49,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. 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. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. 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. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking on no day is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date with the reservation tools, after which the road ends at its terminal. 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' },
@@ -64,6 +64,7 @@ export class RoadtripPlanningMcp {
settings: plan.preferences,
failures: plan.failures,
omittedVisitIds: plan.omittedVisits,
undatedRides: plan.undatedRides,
dayWindowIssue: calculated.dayWindowIssue,
totalDistanceMetres: calculated.totalDistance,
totalDurationSeconds: calculated.totalDuration,
@@ -6,8 +6,10 @@
* column the statement never selects would go unnoticed there. These run the statement.
*/
import { db } from '../../../src/db/database';
import type { McpContext } from '../../../src/nest-mcp';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
@@ -177,6 +179,10 @@ describe('a booking the traveller rides (#2428)', () => {
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);
// Pinned to the minute the drive reaches it anyway, so the clock alone seats the
// flight here. Untimed, Celle lies south of Lueneburg with the airport north of both,
// and the flight would go where it adds the least road (#2461, see below).
db.prepare("UPDATE day_assignments SET assignment_time = '15:30' WHERE id = ?").run(visits[2].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);
@@ -185,7 +191,7 @@ describe('a booking the traveller rides (#2428)', () => {
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.
// Behind Lueneburg (10:00), before Celle (15:30): 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' });
@@ -266,3 +272,89 @@ describe('a booking the traveller rides (#2428)', () => {
expect(calculated.totalStops).toBe(3);
});
});
/**
* The crossing from the report behind #2461: Amsterdam and Newcastle on one day, neither
* with a clock, and a ferry from IJmuiden to the Port of Tyne. A day sailing here, so the
* drive on from the far pier ends before midnight and the one-day trip stays one card.
*/
describe('a ferry across the day, and one on no day (#2461)', () => {
function crossing() {
const { user } = createUser(db);
const trip = createTrip(db, user.id);
const day = createDay(db, trip.id);
for (const [name, lat, lng] of [
['Amsterdam', 52.3731, 4.8926],
['Newcastle', 54.9783, -1.6178],
] as const) {
createDayAssignment(db, day.id, createPlace(db, trip.id, { name, lat, lng }).id);
}
const router = hourlyRouter();
const plans = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => ({}) } as never,
router as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
return { user, trip, day, router, plans };
}
function withFerry(tripId: number, dayId: number | null, title: string, located = true): number {
const id = Number(
db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, ?, 'ferry', ?, ?, '09:30', '15:00')`,
)
.run(tripId, title, dayId, dayId).lastInsertRowid,
);
if (located) {
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879);
endpoint.run(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522);
}
return id;
}
it('drives to the pier and on from the far one, and never overland between the two shores', async () => {
const { user, trip, day, router, plans } = crossing();
withFerry(trip.id, day.id, 'IJmuiden to Newcastle');
const { calculated, undatedRides } = await plans.calculate(trip.id, user.id);
const card = calculated.days[0];
// By the clock alone the ferry closed the day: Amsterdam to Newcastle overland, back
// to IJmuiden, and only then the crossing.
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Amsterdam', 'departure', 'arrival', 'Newcastle']);
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'ferry', 'driving']);
// Two roads, one to each side of the crossing, and nothing between the two cities.
expect(router.route).toHaveBeenCalledTimes(2);
expect(card.distance).toBe(120_000);
expect(undatedRides).toEqual([]);
});
it('names a located ride on no day, and the tool answers with the same list', async () => {
const { user, trip, day, plans } = crossing();
const undated = withFerry(trip.id, null, 'Ferry without a date');
withFerry(trip.id, day.id, 'Ferry on the day');
withFerry(trip.id, null, 'Ferry without terminals', false);
db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'restaurant')").run(trip.id);
const plan = await plans.calculate(trip.id, user.id);
expect(plan.undatedRides).toEqual([{ id: undated, type: 'ferry', title: 'Ferry without a date' }]);
// It is on the drive nowhere, which is why it is named.
expect(plan.context.carriers.map((c) => c.id)).not.toContain(undated);
expect(plan.calculated.days.flatMap((d) => d.stops).some((s) => s.carrier?.reservationId === undated)).toBe(false);
const tool = new RoadtripPlanningMcp(plans, {} as never, {} as never);
const answer = await tool.calculate({ tripId: trip.id, includeGeometry: false }, { userId: user.id } as McpContext);
const body = JSON.parse(answer.content[0].text as string);
expect(body.undatedRides).toEqual(plan.undatedRides);
expect(body.complete).toBe(true);
});
});
@@ -241,6 +241,29 @@ describe('browser-independent roadtrip calculation', () => {
expect(day).not.toHaveProperty('legLines');
expect(day).not.toHaveProperty('arrivingLine');
});
it('answers calculate_roadtrip with the rides on no day, an empty list when there are none (#2461)', async () => {
// Additive: the rest of the answer is what it was. The rides themselves are read off the
// real tables in roadtrip-plan.service.test.ts; this pins the field and the words for it.
const s = setup();
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const body = JSON.parse((await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx)).content[0].text as string);
expect(body.undatedRides).toEqual([]);
expect(body.days.length).toBeGreaterThan(0);
// And the tool says what the field is, or an assistant has no reason to read it.
const addons = { isAddonEnabled: vi.fn(() => true) };
const registry = createTestRegistry([new RoadtripPlanningMcp(s.plans, {} as never, addons as never)], {
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
});
const described = new Map<string, string>();
const registrar = {
registerTool: (name: string, config: { description?: string }) => {
described.set(name, config.description ?? '');
},
};
registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
expect(described.get('calculate_roadtrip')).toContain('undatedRides');
});
it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => {
const s = setup();
s.visits[0].end_day = 1;