mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the pick-up opens its day when nothing timed comes first, the return closes the day it is handed back on, the road runs through both and the range budget starts from a full tank at the pick-up. The return is only placed when the booking names a return day and a located return desk. A table, tickets or a tour on the day hang under the stop they are pinned to or booked at as chips that open the booking; one for no stop is listed under the day. A ride that leaves and lands on the same day is one block on the rail and in the phone chain, not two terminal rows and a band. Both shells read the same helpers (carrierRide, stopBookings, roadtripRowModel). Server reads car bookings into the plan context; the MCP descriptions say so. The pt-BR locale had the three ride strings in Breton, fixed alongside. Refs #2428
This commit is contained in:
@@ -121,15 +121,16 @@ 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.
|
||||
* The bookings that seam the drive, a flight, train, ferry, cruise or bus on a day, and
|
||||
* the hire cars whose desks stand on it, with their terminals and the slots the day
|
||||
* plan gave them. 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`,
|
||||
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
|
||||
tripId,
|
||||
);
|
||||
if (!rows.length) return [];
|
||||
@@ -171,7 +172,7 @@ 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 seams = context.carriers.map(carrierSeam).filter((seam): seam is CarrierSeam => seam !== null);
|
||||
const seams = context.carriers.map((booking) => carrierSeam(booking)).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);
|
||||
|
||||
@@ -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, 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.',
|
||||
'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, plus the hire cars whose pick-up and return desks stand on it. 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. 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.',
|
||||
'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.',
|
||||
inputSchema: roadtripPlanRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -172,15 +172,57 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
expect(calculated.totalStops).toBe(3);
|
||||
});
|
||||
|
||||
it('lets a booking without located terminals fall through, and ignores a hire car', async () => {
|
||||
it('lets a booking without located terminals fall through, a hire car without a desk among them', 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);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Cab', 'taxi', ?, '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(context.carriers.map((c) => c.type).sort()).toEqual(['car', 'train']);
|
||||
expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true);
|
||||
});
|
||||
|
||||
it("puts a hire car's desks on the road: the pick-up opens the day, the return closes it, one run through both", async () => {
|
||||
const { user, trip, visits, plans } = setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '12:00' 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 result = db
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
|
||||
VALUES (?, 'Sixt', 'car', ?, ?, '08:00', '18:00')`,
|
||||
)
|
||||
.run(trip.id, day.day_id, day.day_id);
|
||||
const carId = Number(result.lastInsertRowid);
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0);
|
||||
endpoint.run(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99);
|
||||
const router = hourlyRouter();
|
||||
const service = new RoadtripPlanService(
|
||||
new DatabaseService(db),
|
||||
{ getUserSettings: () => ({}) } as never,
|
||||
{ read: () => ({}) } as never,
|
||||
router as never,
|
||||
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
|
||||
{ list: () => [] } as never,
|
||||
);
|
||||
|
||||
const { calculated } = await service.calculate(trip.id, user.id);
|
||||
|
||||
const card = calculated.days[0];
|
||||
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Hamburg', 'Lueneburg', 'Celle', 'return']);
|
||||
expect(card.stops[0].carrier).toMatchObject({ reservationId: carId, type: 'car', at: '08:00' });
|
||||
expect(card.stops[4].carrier).toMatchObject({ role: 'return', code: 'HAM', at: '18:00' });
|
||||
// One road, desk to desk: no ride, no seam, one routing run.
|
||||
expect(router.route).toHaveBeenCalledTimes(1);
|
||||
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'driving', 'driving', 'driving']);
|
||||
expect(card.schedule.entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00' });
|
||||
expect(card.distance).toBe(240_000);
|
||||
expect(calculated.totalStops).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user