feat(roadtrip): lay a whole chain of via points in one write

The single-via route is right for a detour dragged by hand and wrong for
anything that derives its anchors from a line. Pinning a day onto a recorded
track or a signed scenic road means a couple of dozen points, and one request
each re-routes the entire trip once per point, spaced by the routing host's own
rate limit — twenty anchors would spend half a minute drawing routes nobody
asked to see.

replace_legs names the legs to clear rather than taking a flag for the day, and
that is the whole difference: a traveller who bent two legs by hand and then
adopts a scenic road for a third has not asked to lose the two.

The tools follow in the same change, as parity requires. Undoing a detour picks
this up too, where the vias of one leg were deleted one request at a time.
This commit is contained in:
Maurice
2026-09-04 21:21:53 +02:00
committed by Maurice
parent ba7bd1dd91
commit 67163e7224
9 changed files with 203 additions and 5 deletions
+8
View File
@@ -62,6 +62,7 @@ import {
type PluginActionResult,
type PluginInstallRequest,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
@@ -1227,6 +1228,13 @@ export const roadtripApi = {
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[] }),
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
/**
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
* line produces dozens of them, and one request each would re-route the whole trip once
* per point at better than a second apart.
*/
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
/**
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
* per via: the anchors are only correct as a set.
@@ -7,6 +7,14 @@ export interface RoadtripVias {
/** Every via of the trip, keyed by day. */
byDay: Record<number, RoadtripVia[]>
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise<void>
/**
* Lay a chain of vias on one day, optionally clearing the legs it fills first.
*
* One request and one reload for the whole chain. `add` per point would trigger a full
* trip re-route between each one, spaced by the routing host's rate limit, so a
* twenty-anchor track would spend half a minute drawing routes nobody asked to see.
*/
addMany: (dayId: number, vias: { after_order_index: number; lat: number; lng: number }[], replaceLegs?: number[]) => Promise<void>
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
remove: (dayId: number, id: number) => Promise<void>
/**
@@ -61,6 +69,19 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
await reload()
}, [tripId, reload])
const addMany = useCallback(async (
dayId: number,
vias: { after_order_index: number; lat: number; lng: number }[],
replaceLegs?: number[],
) => {
if (!tripId) return
// An empty chain with nothing to clear is not a write. It is a real call though, from
// a track that thinned down to nothing on a very short day.
if (!vias.length && !replaceLegs?.length) return
await roadtripApi.addVias(tripId, dayId, { vias, replace_legs: replaceLegs })
await reload()
}, [tripId, reload])
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
if (!tripId) return
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
@@ -79,5 +100,5 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
await reload()
}, [tripId, reload])
return { byDay, add, move, remove, reanchor }
return { byDay, add, addMany, move, remove, reanchor }
}
@@ -968,8 +968,9 @@ export function useTripPlanner() {
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
const stop = day?.stops[open.index]
const dayIndex = stop && day ? day.stops.indexOf(stop) : -1
const existing = (roadtripVias.byDay[open.dayId] ?? []).filter(v => v.after_order_index === dayIndex)
for (const via of existing) await roadtripVias.remove(open.dayId, via.id).catch(() => {})
// Clearing the leg in one write. One delete per via meant a full trip re-route
// between each of them, so undoing a detour with three vias drew three routes.
if (dayIndex >= 0) await roadtripVias.addMany(open.dayId, [], [dayIndex]).catch(() => {})
routeAlternatives.close()
return
}
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import { RoadtripService } from './roadtrip.service';
import { RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
import { RoadtripViaBatchDto, RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
@@ -53,6 +53,26 @@ export class RoadtripController {
return { via: this.roadtrip.create(dayId, body) };
}
/**
* Lay a whole chain of vias on one day at once.
*
* Separate from the single-via route rather than folded into it: this one takes a list
* and can clear the legs it is about to fill, which is what deriving a day's route from
* a recorded track or a signed road needs. Doing it one point at a time would re-route
* the whole trip once per point.
*/
@RequirePermission('day_edit')
@Post('days/:dayId/vias/batch')
@HttpCode(200)
createMany(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: RoadtripViaBatchDto,
): { vias: RoadtripVia[] } {
this.requireDay(dayId, tripId);
return { vias: this.roadtrip.createMany(dayId, body) };
}
/**
* Re-pin this day's vias after its stops changed shape.
*
+2 -1
View File
@@ -1,5 +1,5 @@
import { createZodDto } from 'nestjs-zod';
import { roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadtripViaUpdateRequestSchema } from '@trek/shared';
import { roadtripViaBatchRequestSchema, roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadtripViaUpdateRequestSchema } from '@trek/shared';
/**
* createZodDto wrappers over the @trek/shared road-trip contracts. The global
@@ -9,3 +9,4 @@ import { roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadt
export class RoadtripViaCreateDto extends createZodDto(roadtripViaCreateRequestSchema) {}
export class RoadtripViaUpdateDto extends createZodDto(roadtripViaUpdateRequestSchema) {}
export class RoadtripViaReanchorDto extends createZodDto(roadtripViaReanchorRequestSchema) {}
export class RoadtripViaBatchDto extends createZodDto(roadtripViaBatchRequestSchema) {}
+33
View File
@@ -80,6 +80,39 @@ export class RoadtripMcp {
return ok({ via: this.roadtrip.create(dayId, { after_order_index, lat, lng }) });
}
@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.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
vias: z.array(z.object({
after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
})).max(100).describe('In the order the drive passes through them'),
replace_legs: z.array(z.number().int().min(0)).max(100).optional()
.describe('Legs to clear before inserting, by the index of the stop they follow'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
when: roadtripAddonOn,
})
async addVias(
{ tripId, dayId, vias, replace_legs }: {
tripId: number; dayId: number;
vias: { after_order_index: number; lat: number; lng: number }[];
replace_legs?: number[];
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
return ok({ vias: this.roadtrip.createMany(dayId, { vias, replace_legs }) });
}
@Tool({
name: 'reanchor_route_vias',
description: 'Re-pin a day\'s via points after its stops changed. A via records which stop it follows by position, so adding, removing or reordering a stop leaves every later via pointing at the wrong leg and the drive silently reverts to the road it was steered away from. Send the corrected positions for the whole day at once; ids left out keep the position they have.',
@@ -65,6 +65,51 @@ export class RoadtripService {
return this.byId(Number(result.lastInsertRowid))!;
}
/**
* Lay a chain of vias on one day in a single transaction.
*
* One transaction for the same reason `reanchor` uses one: a half-written chain steers
* the drive onto a road nobody chose, along a stretch of the way and not along the rest.
* The sequence is the position within the leg, so points arriving in order keep it.
*
* `replace_legs` clears by leg rather than by day, so vias the traveller placed by hand
* on other legs survive a track being laid on this one.
*/
createMany(
dayId: string | number,
input: { vias: { after_order_index: number; lat: number; lng: number }[]; replace_legs?: number[] },
): RoadtripVia[] {
return this.db.transaction(() => {
for (const leg of input.replace_legs ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?', dayId, leg);
}
// Per leg, because sequence only orders the vias that follow the same stop. Read
// once up front rather than per insert: the loop is inside the transaction, and a
// MAX() per point over a hundred points is a hundred scans of the same rows.
const nextSeq = new Map<number, number>();
for (const via of input.vias) {
let seq = nextSeq.get(via.after_order_index);
if (seq === undefined) {
seq = this.db.get<{ next: number }>(
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
dayId,
via.after_order_index,
)?.next ?? 0;
}
this.db.run(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
dayId,
via.after_order_index,
seq,
via.lat,
via.lng,
);
nextSeq.set(via.after_order_index, seq + 1);
}
return this.listForDay(dayId);
});
}
/** Moving a via is the whole edit; where it sits in the chain does not change. */
move(id: string | number, dayId: string | number, lat: number, lng: number): RoadtripVia | null {
const existing = this.db.get<{ id: number }>(
@@ -135,6 +135,52 @@ describe('RoadtripService', () => {
expect(after.map(v => v.after_order_index)).toEqual([1]);
});
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', () => {
const vias = service.createMany(1, { vias: [
{ after_order_index: 0, lat: 53.0, lng: 10.0 },
{ after_order_index: 0, lat: 53.1, lng: 10.1 },
{ after_order_index: 1, lat: 53.2, lng: 10.2 },
] });
expect(vias.map(v => [v.after_order_index, v.sequence])).toEqual([[0, 0], [0, 1], [1, 0]]);
expect(vias.map(v => v.lat)).toEqual([53.0, 53.1, 53.2]);
});
it('ROADTRIP-SVC-013: a chain appends to what a leg already has', () => {
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
const vias = service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] });
expect(vias.map(v => v.sequence)).toEqual([0, 1]);
});
it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', () => {
// The point of naming legs rather than taking a flag for the day: a hand-placed
// detour on another leg is not something adopting a track on this one asked to lose.
const keep = service.create(1, { after_order_index: 5, lat: 50, lng: 8 });
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
const vias = service.createMany(1, {
vias: [{ after_order_index: 0, lat: 54, lng: 11 }],
replace_legs: [0],
});
expect(vias.map(v => v.id)).toEqual([vias[0].id, keep.id]);
expect(vias.find(v => v.after_order_index === 0)?.lat).toBe(54);
});
it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', () => {
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
expect(service.createMany(1, { vias: [], replace_legs: [0] })).toEqual([]);
});
it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', () => {
service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
expect(service.listForDay(1)).toEqual([]);
expect(service.listForDay(2)).toHaveLength(1);
});
it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', () => {
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+23
View File
@@ -72,6 +72,29 @@ export const roadtripViaReanchorRequestSchema = z.object({
});
export type RoadtripViaReanchorRequest = z.infer<typeof roadtripViaReanchorRequestSchema>;
/**
* Laying a whole chain of vias on one day at once.
*
* The single-via route is right for a hand-dragged detour and wrong for everything that
* derives its anchors from a line: pinning a day onto a recorded track or a signed scenic
* road means a couple of dozen points, and one request each would re-route the entire trip
* once per point, at better than a second apart.
*
* `replace_legs` clears the vias on the named legs before inserting, and names legs rather
* than taking a boolean for the day: a traveller who already bent two legs by hand and
* then adopts a scenic road for a third has not asked to lose the two.
*/
export const roadtripViaBatchRequestSchema = z.object({
vias: z.array(z.object({
after_order_index: z.number().int().min(0),
lat: latSchema,
lng: lngSchema,
})).max(100),
/** Legs to clear first, by `after_order_index`. Absent means add to what is there. */
replace_legs: z.array(z.number().int().min(0)).max(100).optional(),
});
export type RoadtripViaBatchRequest = z.infer<typeof roadtripViaBatchRequestSchema>;
export const roadtripViaListResponseSchema = z.object({
vias: z.array(roadtripViaSchema),
});