fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken

The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
This commit is contained in:
Maurice
2026-09-23 11:26:59 +02:00
parent ab3a81a9eb
commit 451e6d240b
54 changed files with 1061 additions and 208 deletions
+7 -8
View File
@@ -1,5 +1,5 @@
import type Database from 'better-sqlite3';
import { carriedSeam } from '@trek/shared/roadtrip';
import { seamViaIndex } from '@trek/shared/roadtrip';
/**
* Where a booked night sits in its day, and how the drawn roads follow when a stop
@@ -176,9 +176,10 @@ export function reseatOwnStop(
* The rules the planner applies when a stop is dragged or taken out: a via follows
* its stop, a stop that left the day hands its road to the stop before it, and a
* stop that is last has no leg to keep a via on. A via behind the day's last stop
* bends the drive into the next day and stays with that stop while it is still last,
* whatever its number is now; measured as a leg it would be dropped. That last rule is
* the planner's own (`carriedSeam`), so the two cannot disagree about it.
* bends the drive into the next day: it stays with that stop while it is still last,
* whatever its number is now, and goes once another stop is last, because it lies on
* the road to tomorrow and no leg of the day. That rule is the planner's own
* (`seamViaIndex`), so the two cannot disagree about it.
*
* Returns what changed, or null when nothing did.
*/
@@ -187,13 +188,11 @@ export function carryVias(db: SeatConnection, dayId: number, previousIds: number
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
if (!vias.length) return null;
const seam = carriedSeam(previousIds, nextIds);
const remove: number[] = [];
const moved: { id: number; after_order_index: number }[] = [];
for (const via of vias) {
const next = seam && via.after_order_index === seam.from
? seam.to
: legAfter(via.after_order_index, previousIds, nextIds);
const seam = seamViaIndex(via.after_order_index, previousIds, nextIds);
const next = seam !== undefined ? seam : legAfter(via.after_order_index, previousIds, nextIds);
if (next === null) remove.push(via.id);
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
}
@@ -342,8 +342,10 @@ export class AssignmentsService {
// A via behind the day's last stop bends the drive into the next day, on a trip
// with connected days or a night drive (the planner's `anchorFor` files it there).
// It stays with that stop while the sort leaves it last (`carriedSeam`, applied
// inside `reanchorByStopOrder`), the same rule the planner's own drags follow.
// It stays with that stop while the sort leaves it last, and goes once the sort puts
// another stop last (`seamViaIndex`, applied inside `reanchorByStopOrder`): it lies on
// the road to tomorrow, not on the leg that stop leaves by now. The same rule the
// planner's own drags follow.
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
@@ -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. 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.',
'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 that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) 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 within the trip 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' },
@@ -128,14 +128,14 @@ describe('booked night reseat', () => {
reseatBookedNights(db);
// The rules the service applies when it moves a night: a via follows the stop
// it was drawn after, so A's road is on leg one now and the hotel's on leg
// zero; B is last now and has no leg to keep a via on, so its road goes.
// it was drawn after, so A's road is on leg one now; B is last now and has no leg
// to keep a via on, so its road goes. The hotel's road was the drive into the next
// day, behind the last stop, and goes too now that the hotel leads the day: on the
// leg out of the hotel it would bend the drive to A through a point on that road.
expect(order(db)).toEqual([hotel, a, b]);
expect(vias(db)).toEqual([
{ id: afterHotel, after_order_index: 0, sequence: 0 },
{ id: afterA, after_order_index: 1, sequence: 0 },
]);
expect(vias(db)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
expect(vias(db).map((v) => v.id)).not.toContain(afterB);
expect(vias(db).map((v) => v.id)).not.toContain(afterHotel);
});
it('RESEAT-006: a road behind the last stop stays with it, and the roads ahead follow their stops', () => {
@@ -1284,16 +1284,17 @@ describe('the drawn roads a booking moves', () => {
const existing = svc.getAccommodation(accommodation.id, trip.id)!;
const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }) as any;
// The night is last now. The road out of it has no leg left and goes; the roads
// behind B and C follow them one number up, and the one into the next day is
// C's no longer.
// The night is last now. The road out of it has no leg left and goes; the road
// behind B follows it one number up. The one behind C was the drive into the next
// day, which leaves from the hotel now, so it goes as well rather than bending the
// drive from C to the hotel through a point on the road to tomorrow.
expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 0 },
{ id: afterB, after_order_index: 1 },
{ id: afterC, after_order_index: 2 },
]);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterHotel);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterC);
expect(mirror.moved).not.toBeNull();
expect(mirror.vias).toEqual([{ dayId: day.id, vias: expect.arrayContaining([expect.objectContaining({ id: afterB, after_order_index: 1 })]) }]);
});
@@ -574,9 +574,10 @@ describe('updateTime', () => {
});
it('ASG-SVC-042: a sort that moves the last stop up the day takes the drive behind it along, never leaving it on no stop', () => {
// The rule the planner applies too (carriedSeam): a via behind the last stop stays
// there only while that stop is last. Once another stop is, the via follows its stop
// like any other, and nothing is left on an index behind the day's end.
// The rule the planner applies too (seamViaIndex): a via behind the last stop stays
// there only while that stop is last. Once another stop is, it goes: it lies on the
// road to tomorrow, and read as the leg its stop leaves by now it would bend the
// drive from C to A through a point on that road.
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]);
const afterA = addVia(day.id, 0);
const intoTomorrow = addVia(day.id, 2);
@@ -584,10 +585,8 @@ describe('updateTime', () => {
const update = svc.updateTime(c, '08:00', null);
expect(dayOrder(day.id)).toEqual([c, a, b]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 1, sequence: 0 },
{ id: intoTomorrow, after_order_index: 0, sequence: 0 },
]);
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(intoTomorrow);
expect(update.vias?.dayId).toBe(day.id);
});