fix(roadtrip): the hotel at the edge of a day stands, drives and reads right

A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
This commit is contained in:
Maurice
2026-09-23 15:29:44 +02:00
parent 05651eed1a
commit e6e520b78f
46 changed files with 547 additions and 111 deletions
+11 -2
View File
@@ -18,6 +18,15 @@ import { roadtripViaUpdateRequestSchema, type RoadtripViaUpdateRequest } from '@
*/
const roadtripAddonOn = addonGate(ADDON_IDS.ROADTRIP);
/**
* What a via on the drive into a booked night's hotel does, said on both tools that place
* one. The planner refuses the click there with a sentence (`roadtrip.bookend.noVia`); a
* tool stores what it is given, so the assistant has to hear before it reports a detour
* that the calculation will not take.
*/
const BOOKEND_VIA_NOTE =
'With roadtrip_hotel_bookends on, a via after the last stop of a day that ends at a booked night is kept but not used: the drive to the hotel keeps its own road, so calculate_roadtrip returns the same route. Add a stop there instead.';
/**
* Road-trip via points over MCP, the same surface the REST routes expose (#1797).
*
@@ -58,7 +67,7 @@ export class RoadtripMcp {
@Tool({
name: 'add_route_via',
description: 'Make a day\'s drive pass through a point without stopping there — use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead.',
description: `Make a day's drive pass through a point without stopping there: use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead. ${BOOKEND_VIA_NOTE}`,
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
@@ -85,7 +94,7 @@ export class RoadtripMcp {
@Tool({
name: 'add_route_vias',
description: 'Lay a whole chain of via points on one day at once, so the drive follows a particular road for a stretch rather than being nudged at a single point. Use this when the shape comes from a line — a recorded track, a signed scenic route — and add_route_via when it is one detour. Pass replace_legs to clear the vias on those legs first; leave it out to add to what is already there, which is what leaves hand-placed detours on other legs alone.',
description: `Lay a whole chain of via points on one day at once, so the drive follows a particular road for a stretch rather than being nudged at a single point. Use this when the shape comes from a line (a recorded track, a signed scenic route) and add_route_via when it is one detour. Pass replace_legs to clear the vias on those legs first; leave it out to add to what is already there, which is what leaves hand-placed detours on other legs alone. ${BOOKEND_VIA_NOTE}`,
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
@@ -356,6 +356,20 @@ describe('road-trip MCP tools', () => {
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_preferences WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
});
it('MCP-ROADTRIP-014: both via tools say that a via on the drive into a booked night is kept and not used', async () => {
// The planner refuses that click with a sentence; a tool stores the via as asked, so
// the note on the tool is the only place the assistant can learn it changes nothing.
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const tools = (await h.client.listTools()).tools;
for (const name of ['add_route_via', 'add_route_vias']) {
const description = tools.find(t => t.name === name)?.description ?? '';
expect(description, name).toContain('roadtrip_hotel_bookends');
expect(description, name).toContain('kept but not used');
}
});
});
it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => {
const { user, trip, day } = scenario();
@@ -21,7 +21,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
function hourlyRouter() {
return {
profiles: () => ['driving'],
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[], _profile?: string) => ({
parts: points.slice(1).map(() => ({ distance: 60000, duration: 3600 })),
avoidMissed: [],
leg: {
@@ -512,6 +512,23 @@ describe('a booked night at both ends of its days', () => {
expect(lats(router)[1]).toEqual([HOTEL.lat, 45.3, 45.4, HOTEL.lat]);
});
it('drives out of the morning hotel the way the first place is reached from it, as the browser and the day plan do', async () => {
const { user, trip: created, router, plans } = simeon();
db.prepare(
"UPDATE day_assignments SET incoming_leg_transport_mode = 'walking' WHERE place_id = (SELECT id FROM places WHERE trip_id = ? AND name = 'P3')",
).run(created.id);
const { calculated } = await plans.calculate(created.id, user.id);
expect(calculated.days[1].stops[0]).toMatchObject({ legMode: 'walking', bookend: { phase: 'morning' } });
const runs = router.route.mock.calls.map((call) => [call[4], call[3].map((p: { lat: number }) => p.lat)]);
// The hotel's leg on foot on its own, the rest of the day in the day's own mode.
expect(runs).toContainEqual(['walking', [HOTEL.lat, 45.3]]);
expect(runs).toContainEqual(['driving', [45.3, 45.4, HOTEL.lat]]);
// The other mornings are reached no particular way and stay in one run.
expect(runs).toContainEqual(['driving', [HOTEL.lat, 45.5, 45.6, HOTEL.lat]]);
});
it('drives the stored days while the trip has it off, and a preview can switch it either way', async () => {
const off = simeon({});
const stored = await off.plans.calculate(off.trip.id, off.user.id);