feat(roadtrip): remember which track a day follows

The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
This commit is contained in:
Maurice
2026-09-05 00:49:59 +02:00
committed by Maurice
parent 6fca238440
commit 676c82a308
40 changed files with 356 additions and 31 deletions
+2 -1
View File
@@ -61,6 +61,7 @@ import {
type PluginActionDescriptor,
type PluginActionResult,
type PluginInstallRequest,
RoadtripDayTrack,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
@@ -1225,7 +1226,7 @@ export const mapsApi = {
export const roadtripApi = {
/** Every via of the trip, so all days can be routed without a request per day. */
listVias: (tripId: number | string) =>
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[] }),
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
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 }),
/**
+14 -1
View File
@@ -31,10 +31,23 @@ const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
* A configured base is expected to serve the standard OSRM layout,
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
*/
/**
* A configured base URL without its trailing slashes.
*
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
* slashes, and the value comes from an instance setting somebody types. Linear either
* way in practice, but the regex is the shape a scanner is right to flag.
*/
function withoutTrailingSlashes(value: string): string {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
if (!configured) return OSRM_PROFILE_BASE[profile]
return `${configured.replace(/\/+$/, '')}/route/v1/${OSRM_PROFILE_PATH[profile]}`
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
@@ -236,7 +236,9 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
const socketsFound = useMemo(() => {
const seen = new Set<string>()
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
return [...seen].sort()
// Compared explicitly: the default sort is by UTF-16 code unit, which is not the
// order anybody reads a list of plug names in.
return [...seen].sort((a, b) => a.localeCompare(b))
}, [search.results])
const hasCharging = socketsFound.length > 0
@@ -60,6 +60,8 @@ interface RoadtripSidebarProps {
* what somebody reading the day needs to know.
*/
viaCounts?: Record<number, number>
/** The name of the track each day follows, so the badge can say which road it is. */
trackNames?: Record<number, string>
}
const MODE_ICON: Record<string, LucideIcon> = {
@@ -759,7 +761,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
* (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across
* the trip would put "17" beside a pin the map calls "3".
*/
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount }: {
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName }: {
day: RoadtripDay
selectedAssignmentId?: number | null
onSelectStop?: (placeId: number, assignmentId: number) => void
@@ -773,6 +775,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onFollowTrack?: RoadtripSidebarProps['onFollowTrack']
viaCount?: number
trackName?: string
}): React.ReactElement {
const { from, setFrom, dropAt, setDropAt } = drag
const dragging = from?.dayId === day.dayId ? from.index : null
@@ -837,7 +840,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
none of them, so this badge is the only place a drive shaped by hand differs
from one the router picked on its own. */}
{onFollowTrack ? (
<Tooltip label={t('roadtrip.track.hint')}>
<Tooltip label={trackName ? t('roadtrip.track.current', { name: trackName }) : t('roadtrip.track.hint')}>
<button
type="button"
onClick={() => onFollowTrack(day.dayId)}
@@ -1029,7 +1032,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onFollowTrack, viaCounts,
onSetStopKind, onFollowTrack, viaCounts, trackNames,
}: RoadtripSidebarProps): React.ReactElement {
const { t } = useTranslation()
// One drag state for the whole rail rather than one per day: a stop that cannot leave
@@ -1074,6 +1077,7 @@ export default function RoadtripSidebar({
onSetStopKind={onSetStopKind}
onFollowTrack={onFollowTrack}
viaCount={viaCounts?.[day.dayId] ?? 0}
trackName={trackNames?.[day.dayId]}
/>
))}
{routes.quietDays.map(day => (
@@ -29,6 +29,8 @@ function follow(over: Partial<FollowTrack> = {}): FollowTrack {
clear: vi.fn().mockResolvedValue(undefined),
viaCount: 0,
available: true,
current: null,
namesByDay: {},
...over,
}
}
@@ -75,7 +75,7 @@ export default function RoadtripTrackModal({ follow, dayNumber }: {
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [picked, setPicked] = useState<number | null>(null)
const { dayId, busy, round, error, outcome, tracks, viaCount } = follow
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow
// A track picked on one day means nothing on the next.
useEffect(() => { setPicked(null) }, [dayId])
@@ -97,6 +97,14 @@ export default function RoadtripTrackModal({ follow, dayNumber }: {
}
>
<div className="flex flex-col gap-3">
{/* What the day follows today, before anything is picked. Read back from the day
rather than remembered from the run that applied it, which is the whole reason
it is stored: it has to survive a reload. */}
{current ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.current', { name: current.name })}
</p>
) : null}
{tracks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-2">
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
@@ -47,6 +47,7 @@ const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
return {
byDay: {},
trackByDay: {},
add: vi.fn(),
addMany: vi.fn().mockResolvedValue(undefined),
move: vi.fn(),
@@ -88,6 +89,26 @@ describe('useFollowTrack', () => {
expect(result.current.available).toBe(true)
})
it('FE-FOLLOWHOOK-011: the track a day follows is read back from the day, not remembered', () => {
// The whole point of storing it: a field that only ever held what this session did
// would look right until somebody refreshed the page.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 3, stray_km: 0.8 } } });
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
expect(result.current.namesByDay).toEqual({ 7: 'Atlantic Road' })
act(() => { result.current.open(7) })
expect(result.current.current).toEqual({ name: 'Atlantic Road', strayKm: 0.8 })
})
it('FE-FOLLOWHOOK-012: a stored track whose place is gone names nothing', () => {
// The row cascades with the place, so this is belt and braces — but a label that
// outlived its line would be a day claiming to follow something invisible.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 999, stray_km: null } } })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
expect(result.current.current).toBeNull()
})
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
expect(result.current.available).toBe(false)
@@ -112,9 +133,12 @@ describe('useFollowTrack', () => {
await act(async () => { await result.current.apply(3) })
expect(vias.addMany).toHaveBeenCalledTimes(1)
const [dayId, written, legs] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
const [dayId, written, legs, track] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
expect(dayId).toBe(7)
expect(legs).toEqual([0])
// The chain and the name it came from land in one write: a day claiming to follow a
// road whose vias never arrived would be worse than a day claiming nothing.
expect(track).toMatchObject({ place_id: 3 })
expect(written.length).toBeGreaterThan(0)
// Every via hangs behind the first stop, because the day has exactly one leg.
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
@@ -131,8 +155,9 @@ describe('useFollowTrack', () => {
await act(async () => { await result.current.apply(3) })
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
// Still a write: the legs are cleared, so applying a second track does not stack.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0])
// Still a write: the legs are cleared so a second track does not stack, and the name
// is recorded even though the drive needed no bending to follow it.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], { place_id: 3, stray_km: 0 })
})
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
@@ -168,7 +193,8 @@ describe('useFollowTrack', () => {
expect(result.current.viaCount).toBe(2)
await act(async () => { await result.current.clear() })
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0])
// null, not nothing: dropping the points is also dropping the claim.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
})
@@ -50,6 +50,16 @@ export interface FollowTrack {
clear: () => Promise<void>
/** How many vias the open day carries — what makes clearing worth offering. */
viaCount: number
/**
* The track the open day follows, if it follows one.
*
* Read back from the day rather than remembered from the run that applied it: the point
* of storing it at all is that it survives a reload, and a field that only ever held
* what this session did would look right until somebody refreshed.
*/
current: { name: string; strayKm: number | null } | null
/** The name of the track each day follows, for the rail's badge. */
namesByDay: Record<number, string>
/**
* Whether the trip holds a track at all.
*
@@ -185,7 +195,9 @@ export function useFollowTrack(
if (controller.signal.aborted) { setBusy(false); return }
try {
await vias.addMany(dayId, plan.vias, plan.legs)
// The chain and the name it came from, in one write: a day claiming to follow a
// road whose vias never landed would be worse than a day claiming nothing.
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
} catch {
setError('save')
@@ -195,6 +207,29 @@ export function useFollowTrack(
}, [tracks, dayId, stops, busy, tripId, vias])
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
/**
* The name behind each day's stored track id.
*
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
* asks for this on every render.
*/
const namesByDay = useMemo(() => {
const names: Record<number, string> = {}
const byId = new Map(places.map(p => [p.id, p]))
for (const [day, track] of Object.entries(vias.trackByDay)) {
const place = byId.get(track.place_id)
if (place?.name) names[Number(day)] = place.name
}
return names
}, [places, vias.trackByDay])
const current = useMemo(() => {
if (dayId === null) return null
const track = vias.trackByDay[dayId]
const name = namesByDay[dayId]
return track && name ? { name, strayKm: track.stray_km } : null
}, [dayId, vias.trackByDay, namesByDay])
// A cheap look at the column, not a parse: this runs on every planner render, and the
// question is only whether anything at all was ever imported.
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
@@ -206,7 +241,8 @@ export function useFollowTrack(
try {
// Every leg of the day, so nothing is left behind on a leg the track never reached.
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
await vias.addMany(dayId, [], legs)
// null rather than nothing: dropping the points is also dropping the claim.
await vias.addMany(dayId, [], legs, null)
setOutcome({ vias: 0, strayKm: 0, capped: false })
} catch {
setError('save')
@@ -215,5 +251,5 @@ export function useFollowTrack(
}
}, [dayId, busy, stops.length, vias])
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available }
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
}
@@ -310,7 +310,9 @@ export function useRoadtripRoutes(
const viaKey = useMemo(
() => Object.entries(viasByDay)
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
.sort()
// Only the order has to be stable — this is a cache key, not a list anybody reads
// — but it has to be stable on purpose rather than by default.
.sort((a, b) => a.localeCompare(b))
.join(';'),
[viasByDay],
)
@@ -1,11 +1,19 @@
import { useCallback, useEffect, useState } from 'react'
import { roadtripApi } from '../../api/client'
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
import type { RoadtripVia } from '@trek/shared'
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared'
export interface RoadtripVias {
/** Every via of the trip, keyed by day. */
byDay: Record<number, RoadtripVia[]>
/**
* Which imported track each day was fitted to, keyed by day.
*
* The vias are what make the day follow it; this is what lets the day say so. Empty for
* a day shaped by hand, and gone by itself when the track is deleted — the row cascades
* with the place, so nothing here can name a line that no longer exists.
*/
trackByDay: Record<number, RoadtripDayTrack>
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.
@@ -14,7 +22,13 @@ export interface RoadtripVias {
* 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>
addMany: (
dayId: number,
vias: { after_order_index: number; lat: number; lng: number }[],
replaceLegs?: number[],
/** Absent leaves the day's track alone, null clears it, an object records a new one. */
track?: { place_id: number; stray_km?: number | null } | null,
) => Promise<void>
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
remove: (dayId: number, id: number) => Promise<void>
/**
@@ -28,6 +42,7 @@ export interface RoadtripVias {
}
const EMPTY: Record<number, RoadtripVia[]> = {}
const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
/**
* The points this trip's drives are routed through.
@@ -42,6 +57,7 @@ const EMPTY: Record<number, RoadtripVia[]> = {}
*/
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
const group = useCallback((vias: RoadtripVia[]) => {
const next: Record<number, RoadtripVia[]> = {}
@@ -50,14 +66,18 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
}, [])
const reload = useCallback(async () => {
if (!tripId || !active) { setByDay(EMPTY); return }
if (!tripId || !active) { setByDay(EMPTY); setTrackByDay(EMPTY_TRACKS); return }
try {
const { vias } = await roadtripApi.listVias(tripId)
const { vias, tracks } = await roadtripApi.listVias(tripId)
setByDay(group(vias))
const byId: Record<number, RoadtripDayTrack> = {}
for (const track of tracks ?? []) byId[track.day_id] = track
setTrackByDay(byId)
} catch {
// An instance with the addon off answers 404 here, which is not an error worth
// reporting — it just means there are no vias to draw.
setByDay(EMPTY)
setTrackByDay(EMPTY_TRACKS)
}
}, [tripId, active, group])
@@ -73,12 +93,18 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
dayId: number,
vias: { after_order_index: number; lat: number; lng: number }[],
replaceLegs?: number[],
track?: { place_id: number; stray_km?: number | null } | null,
) => {
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 })
// a track that thinned down to nothing on a very short day, and from a track that the
// drive already followed — which still has a name to record.
if (!vias.length && !replaceLegs?.length && track === undefined) return
await roadtripApi.addVias(tripId, dayId, {
vias,
replace_legs: replaceLegs,
...(track === undefined ? {} : { track }),
})
await reload()
}, [tripId, reload])
@@ -100,5 +126,5 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
await reload()
}, [tripId, reload])
return { byDay, add, addMany, move, remove, reanchor }
return { byDay, trackByDay, add, addMany, move, remove, reanchor }
}
+1
View File
@@ -542,6 +542,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
viaCounts={roadtripViaCounts}
trackNames={followTrack.namesByDay}
/>
</LazyPanel>
) : (
+29
View File
@@ -4345,6 +4345,35 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)');
},
/**
* Which imported track a day's drive was fitted to.
*
* The vias alone already make the day follow it, so this stores nothing the drive
* needs — it stores what the traveller needs: the name of the road they chose, still
* on the day after a reload, and something to re-fit against when the stops change.
*
* One row per day, hence `day_id` as the key: a day follows one road or none. The
* cascade on `place_id` is the point of the foreign key — delete the imported track
* and the label goes with it, rather than leaving a day claiming to follow a line
* nobody can see any more. The vias it laid down stay, because they are the drive.
*
* `stray_km` is how far the fitted route still ran from the track at its worst point,
* kept so the day can say how good a fit it is without routing again.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS roadtrip_day_tracks (
day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
stray_km REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)');
},
];
if (currentVersion < migrations.length) {
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared';
import { RoadtripService } from './roadtrip.service';
import { RoadtripViaBatchDto, RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -32,8 +32,8 @@ export class RoadtripController {
/** Every via of the trip, so the client can route all days without a request per day. */
@Get('vias')
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[] } {
return { vias: this.roadtrip.listForTrip(tripId) };
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] } {
return { vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) };
}
@Get('days/:dayId/vias')
@@ -70,6 +70,12 @@ export class RoadtripController {
@Body() body: RoadtripViaBatchDto,
): { vias: RoadtripVia[] } {
this.requireDay(dayId, tripId);
// Permission is not enough on its own: a place id from somebody else's trip would
// otherwise become this day's label, and a place that is not a track would become a
// label that can never be drawn.
if (body.track && !this.roadtrip.trackExists(body.track.place_id, tripId)) {
throw new HttpException({ error: 'Track not found' }, 404);
}
return { vias: this.roadtrip.createMany(dayId, body) };
}
+14 -3
View File
@@ -52,7 +52,7 @@ export class RoadtripMcp {
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
return ok({ vias: this.roadtrip.listForDay(dayId) });
}
return ok({ vias: this.roadtrip.listForTrip(tripId) });
return ok({ vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) });
}
@Tool({
@@ -93,16 +93,23 @@ export class RoadtripMcp {
})).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'),
track: z.object({
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
stray_km: z.number().min(0).max(40_000).nullable().optional()
.describe('How far the fitted route still runs from the track at its worst point'),
}).nullable().optional()
.describe('Records which imported track the day now follows. Leave it out to keep whatever it followed before; pass null to say it follows nothing.'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
when: roadtripAddonOn,
})
async addVias(
{ tripId, dayId, vias, replace_legs }: {
{ tripId, dayId, vias, replace_legs, track }: {
tripId: number; dayId: number;
vias: { after_order_index: number; lat: number; lng: number }[];
replace_legs?: number[];
track?: { place_id: number; stray_km?: number | null } | null;
},
ctx: McpContext,
) {
@@ -110,7 +117,11 @@ export class RoadtripMcp {
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 }) });
// The same check the REST route makes, in the same change: permission alone would let
// a place id from another trip, or a place that is not a track at all, become this
// day's label.
if (track && !this.roadtrip.trackExists(track.place_id, tripId)) return noAccess();
return ok({ vias: this.roadtrip.createMany(dayId, { vias, replace_legs, track }) });
}
@Tool({
+45 -2
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
/**
@@ -77,9 +77,26 @@ export class RoadtripService {
*/
createMany(
dayId: string | number,
input: { vias: { after_order_index: number; lat: number; lng: number }[]; replace_legs?: number[] },
input: {
vias: { after_order_index: number; lat: number; lng: number }[];
replace_legs?: number[];
track?: { place_id: number; stray_km?: number | null } | null;
},
): RoadtripVia[] {
return this.db.transaction(() => {
// Inside the same transaction as the chain it describes. A day that says it follows
// a road whose vias never landed is worse than a day that says nothing.
if (input.track === null) {
this.db.run('DELETE FROM roadtrip_day_tracks WHERE day_id = ?', dayId);
} else if (input.track) {
this.db.run(
`INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)
ON CONFLICT(day_id) DO UPDATE SET place_id = excluded.place_id, stray_km = excluded.stray_km`,
dayId,
input.track.place_id,
input.track.stray_km ?? null,
);
}
for (const leg of input.replace_legs ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?', dayId, leg);
}
@@ -110,6 +127,32 @@ export class RoadtripService {
});
}
/**
* The tracks this trip's days follow.
*
* Read with the vias in one go: both are wanted on every load of road-trip mode, and a
* second route for a handful of rows would be a second round trip for nothing.
*/
tracksForTrip(tripId: string | number): RoadtripDayTrack[] {
return this.db.all<RoadtripDayTrack>(
`SELECT t.day_id, t.place_id, t.stray_km
FROM roadtrip_day_tracks t
JOIN days d ON d.id = t.day_id
WHERE d.trip_id = ?
ORDER BY t.day_id`,
tripId,
);
}
/** Whether a place is on this trip, and is a track rather than an ordinary place. */
trackExists(placeId: number, tripId: string | number): boolean {
return !!this.db.get<{ id: number }>(
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
placeId,
tripId,
);
}
/** 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 }>(
@@ -19,7 +19,23 @@ function makeService() {
lng REAL NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE places (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL,
name TEXT,
route_geometry TEXT
);
CREATE TABLE roadtrip_day_tracks (
day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
stray_km REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO days (id, trip_id) VALUES (1, 7), (2, 7), (3, 99);
INSERT INTO places (id, trip_id, name, route_geometry) VALUES
(10, 7, 'Atlantic Road', '[[52,13],[52,14]]'),
(11, 7, 'A hotel', NULL),
(12, 99, 'Somebody else\u2019s track', '[[40,2],[40,3]]');
`);
const db = {
get: <T>(sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params as never[]) as T | undefined,
@@ -181,6 +197,53 @@ describe('RoadtripService', () => {
expect(service.listForDay(2)).toHaveLength(1);
});
it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', () => {
// Same transaction on purpose: a day saying it follows a road whose vias never
// arrived is worse than a day saying nothing at all.
service.createMany(1, {
vias: [{ after_order_index: 0, lat: 53, lng: 10 }],
track: { place_id: 10, stray_km: 0.8 },
});
expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]);
});
it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', () => {
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } });
service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } });
expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]);
});
it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', () => {
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } });
// Absent: a hand-dragged via on the same day must not wipe the road it follows.
service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
expect(service.tracksForTrip(7)).toHaveLength(1);
service.createMany(1, { vias: [], replace_legs: [0], track: null });
expect(service.tracksForTrip(7)).toEqual([]);
});
it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', () => {
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } });
service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } });
expect(service.tracksForTrip(7).map(t => t.day_id)).toEqual([1]);
expect(service.tracksForTrip(99).map(t => t.day_id)).toEqual([3]);
});
it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', () => {
// Permission is not enough on its own: without this a place id from another trip
// would become this day\u2019s label, and an ordinary place would become a label that
// can never be drawn.
expect(service.trackExists(10, 7)).toBe(true);
expect(service.trackExists(11, 7)).toBe(false);
expect(service.trackExists(12, 7)).toBe(false);
expect(service.trackExists(999, 7)).toBe(false);
});
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 });
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'لم تستجب خدمة تحديد المسارات.',
'roadtrip.track.errorSave': 'تعذّر حفظ نقاط العبور.',
'roadtrip.track.clear': 'إزالة {count} نقطة عبور',
'roadtrip.track.current': 'يتبع حالياً {name}',
'roadtrip.poi.rest': 'استراحة',
'roadtrip.poi.campsite': 'مخيم',
'roadtrip.poi.food': 'طعام',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'O serviço de rotas não respondeu.',
'roadtrip.track.errorSave': 'Não foi possível salvar os pontos de passagem.',
'roadtrip.track.clear': 'Remover {count} pontos de passagem',
'roadtrip.track.current': 'Atualmente segue {name}',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'El servei de rutes no ha respost.',
'roadtrip.track.errorSave': 'No s’han pogut desar els punts de pas.',
'roadtrip.track.clear': 'Elimina {count} punts de pas',
'roadtrip.track.current': 'Actualment segueix {name}',
'roadtrip.poi.rest': 'Àrea de descans',
'roadtrip.poi.campsite': 'Càmping',
'roadtrip.poi.food': 'Menjar',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Služba plánování trasy neodpověděla.',
'roadtrip.track.errorSave': 'Průjezdní body se nepodařilo uložit.',
'roadtrip.track.clear': 'Odebrat {count} průjezdních bodů',
'roadtrip.track.current': 'Aktuálně sleduje {name}',
'roadtrip.poi.rest': 'Odpočívadlo',
'roadtrip.poi.campsite': 'Kemp',
'roadtrip.poi.food': 'Jídlo',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Der Routendienst hat nicht geantwortet.',
'roadtrip.track.errorSave': 'Die Zwischenpunkte konnten nicht gespeichert werden.',
'roadtrip.track.clear': '{count} Zwischenpunkte entfernen',
'roadtrip.track.current': 'Folgt derzeit {name}',
'roadtrip.poi.rest': 'Rastplatz',
'roadtrip.poi.campsite': 'Campingplatz',
'roadtrip.poi.food': 'Essen',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'The routing service did not answer.',
'roadtrip.track.errorSave': 'The via points could not be saved.',
'roadtrip.track.clear': 'Drop {count} via points',
'roadtrip.track.current': 'Currently following {name}',
'roadtrip.poi.rest': 'Rest area',
'roadtrip.poi.campsite': 'Campsite',
'roadtrip.poi.food': 'Food',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'El servicio de rutas no respondió.',
'roadtrip.track.errorSave': 'No se pudieron guardar los puntos de paso.',
'roadtrip.track.clear': 'Quitar {count} puntos de paso',
'roadtrip.track.current': 'Actualmente sigue {name}',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Le service de calcul d’itinéraire n’a pas répondu.',
'roadtrip.track.errorSave': 'Les points de passage n’ont pas pu être enregistrés.',
'roadtrip.track.clear': 'Supprimer {count} points de passage',
'roadtrip.track.current': 'Suit actuellement {name}',
'roadtrip.poi.rest': 'Aire de repos',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Restauration',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Η υπηρεσία δρομολόγησης δεν απάντησε.',
'roadtrip.track.errorSave': 'Δεν ήταν δυνατή η αποθήκευση των ενδιάμεσων σημείων.',
'roadtrip.track.clear': 'Αφαίρεση {count} ενδιάμεσων σημείων',
'roadtrip.track.current': 'Ακολουθεί αυτή τη στιγμή {name}',
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
'roadtrip.poi.campsite': 'Κάμπινγκ',
'roadtrip.poi.food': 'Φαγητό',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Az útvonaltervező szolgáltatás nem válaszolt.',
'roadtrip.track.errorSave': 'A köztes pontokat nem sikerült menteni.',
'roadtrip.track.clear': '{count} köztes pont eltávolítása',
'roadtrip.track.current': 'Jelenleg ezt követi: {name}',
'roadtrip.poi.rest': 'Pihenőhely',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Étkezés',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Layanan rute tidak menjawab.',
'roadtrip.track.errorSave': 'Titik lintas tidak dapat disimpan.',
'roadtrip.track.clear': 'Hapus {count} titik lintas',
'roadtrip.track.current': 'Saat ini mengikuti {name}',
'roadtrip.poi.rest': 'Tempat istirahat',
'roadtrip.poi.campsite': 'Perkemahan',
'roadtrip.poi.food': 'Makanan',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Il servizio di calcolo percorso non ha risposto.',
'roadtrip.track.errorSave': 'Non è stato possibile salvare i punti di passaggio.',
'roadtrip.track.clear': 'Rimuovi {count} punti di passaggio',
'roadtrip.track.current': 'Attualmente segue {name}',
'roadtrip.poi.rest': 'Area di sosta',
'roadtrip.poi.campsite': 'Campeggio',
'roadtrip.poi.food': 'Ristoro',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'ルート検索サービスが応答しませんでした。',
'roadtrip.track.errorSave': '経由地を保存できませんでした。',
'roadtrip.track.clear': '経由地 {count} か所を削除',
'roadtrip.track.current': '現在 {name} をたどっています',
'roadtrip.poi.rest': 'サービスエリア',
'roadtrip.poi.campsite': 'キャンプ場',
'roadtrip.poi.food': '食事',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '경로 서비스가 응답하지 않았습니다.',
'roadtrip.track.errorSave': '경유지를 저장하지 못했습니다.',
'roadtrip.track.clear': '경유지 {count}개 삭제',
'roadtrip.track.current': '현재 {name}을(를) 따라갑니다',
'roadtrip.poi.rest': '휴게소',
'roadtrip.poi.campsite': '캠핑장',
'roadtrip.poi.food': '식사',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'De routeservice antwoordde niet.',
'roadtrip.track.errorSave': 'De tussenpunten konden niet worden opgeslagen.',
'roadtrip.track.clear': '{count} tussenpunten verwijderen',
'roadtrip.track.current': 'Volgt momenteel {name}',
'roadtrip.poi.rest': 'Rustplaats',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Eten',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Usługa wyznaczania trasy nie odpowiedziała.',
'roadtrip.track.errorSave': 'Nie udało się zapisać punktów pośrednich.',
'roadtrip.track.clear': 'Usuń {count} punktów pośrednich',
'roadtrip.track.current': 'Obecnie podąża za {name}',
'roadtrip.poi.rest': 'Miejsce odpoczynku',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Jedzenie',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Сервис маршрутов не ответил.',
'roadtrip.track.errorSave': 'Не удалось сохранить промежуточные точки.',
'roadtrip.track.clear': 'Убрать промежуточные точки: {count}',
'roadtrip.track.current': 'Сейчас идёт по {name}',
'roadtrip.poi.rest': 'Зона отдыха',
'roadtrip.poi.campsite': 'Кемпинг',
'roadtrip.poi.food': 'Еда',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Ruttjänsten svarade inte.',
'roadtrip.track.errorSave': 'Mellanpunkterna kunde inte sparas.',
'roadtrip.track.clear': 'Ta bort {count} mellanpunkter',
'roadtrip.track.current': 'Följer just nu {name}',
'roadtrip.poi.rest': 'Rastplats',
'roadtrip.poi.campsite': 'Campingplats',
'roadtrip.poi.food': 'Mat',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Rota servisi yanıt vermedi.',
'roadtrip.track.errorSave': 'Ara noktalar kaydedilemedi.',
'roadtrip.track.clear': '{count} ara noktayı kaldır',
'roadtrip.track.current': 'Şu anda {name} izini takip ediyor',
'roadtrip.poi.rest': 'Dinlenme alanı',
'roadtrip.poi.campsite': 'Kamp alanı',
'roadtrip.poi.food': 'Yemek',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Сервіс маршрутів не відповів.',
'roadtrip.track.errorSave': 'Не вдалося зберегти проміжні точки.',
'roadtrip.track.clear': 'Прибрати проміжні точки: {count}',
'roadtrip.track.current': 'Зараз іде за {name}',
'roadtrip.poi.rest': 'Зона відпочинку',
'roadtrip.poi.campsite': 'Кемпінг',
'roadtrip.poi.food': 'Їжа',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Dịch vụ chỉ đường không phản hồi.',
'roadtrip.track.errorSave': 'Không thể lưu các điểm trung gian.',
'roadtrip.track.clear': 'Bỏ {count} điểm trung gian',
'roadtrip.track.current': 'Hiện đang đi theo {name}',
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
'roadtrip.poi.campsite': 'Khu cắm trại',
'roadtrip.poi.food': 'Đồ ăn',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '路線服務沒有回應。',
'roadtrip.track.errorSave': '無法儲存途經點。',
'roadtrip.track.clear': '移除 {count} 個途經點',
'roadtrip.track.current': '目前沿 {name} 行駛',
'roadtrip.poi.rest': '休息站',
'roadtrip.poi.campsite': '露營地',
'roadtrip.poi.food': '餐飲',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '路线服务没有响应。',
'roadtrip.track.errorSave': '无法保存途经点。',
'roadtrip.track.clear': '移除 {count} 个途经点',
'roadtrip.track.current': '当前沿 {name} 行驶',
'roadtrip.poi.rest': '服务区',
'roadtrip.poi.campsite': '露营地',
'roadtrip.poi.food': '餐饮',
+29
View File
@@ -92,10 +92,39 @@ export const roadtripViaBatchRequestSchema = z.object({
})).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(),
/**
* The imported track this chain was fitted to, if it came from one.
*
* Three states rather than two, which is why it is an object and not an id: absent
* leaves whatever the day already followed alone, `null` says the day follows nothing
* any more, and an object records the road it now takes. A bare nullable id could not
* tell "do not touch this" apart from "forget it".
*/
track: z.object({
place_id: z.number().int().positive(),
/** How far the fitted route still ran from the track at its worst point. */
stray_km: z.number().min(0).max(40_000).nullable().optional(),
}).nullable().optional(),
});
export type RoadtripViaBatchRequest = z.infer<typeof roadtripViaBatchRequestSchema>;
/** Which track a day was fitted to, and how closely. */
export const roadtripDayTrackSchema = z.object({
day_id: z.number(),
place_id: z.number(),
stray_km: z.number().nullable(),
});
export type RoadtripDayTrack = z.infer<typeof roadtripDayTrackSchema>;
export const roadtripViaListResponseSchema = z.object({
vias: z.array(roadtripViaSchema),
/**
* The tracks the trip's days follow, one row per day that follows one.
*
* Alongside the vias rather than behind a route of its own: they are read together on
* every load of road-trip mode, and a second request for a handful of rows would be a
* second round trip for something the first one could carry.
*/
tracks: z.array(roadtripDayTrackSchema),
});
export type RoadtripViaListResponse = z.infer<typeof roadtripViaListResponseSchema>;