+ {/* 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 ? (
+
+ {t('roadtrip.track.current', { name: current.name })}
+
+ ) : null}
{tracks.length === 0 ? (
diff --git a/client/src/components/Roadtrip/useFollowTrack.test.ts b/client/src/components/Roadtrip/useFollowTrack.test.ts
index e5032110e..866716232 100644
--- a/client/src/components/Roadtrip/useFollowTrack.test.ts
+++ b/client/src/components/Roadtrip/useFollowTrack.test.ts
@@ -47,6 +47,7 @@ const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
function viasStub(over: Partial = {}): 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).mock.calls[0]
+ const [dayId, written, legs, track] = (vias.addMany as ReturnType).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 })
})
diff --git a/client/src/components/Roadtrip/useFollowTrack.ts b/client/src/components/Roadtrip/useFollowTrack.ts
index 820ebed6e..a05e182d1 100644
--- a/client/src/components/Roadtrip/useFollowTrack.ts
+++ b/client/src/components/Roadtrip/useFollowTrack.ts
@@ -50,6 +50,16 @@ export interface FollowTrack {
clear: () => Promise
/** 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
/**
* 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 = {}
+ 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 }
}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 1edf6ca79..753b58fa2 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -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],
)
diff --git a/client/src/components/Roadtrip/useRoadtripVias.ts b/client/src/components/Roadtrip/useRoadtripVias.ts
index 846a5a4b6..d626b036e 100644
--- a/client/src/components/Roadtrip/useRoadtripVias.ts
+++ b/client/src/components/Roadtrip/useRoadtripVias.ts
@@ -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
+ /**
+ * 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
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise
/**
* 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
+ 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
move: (dayId: number, id: number, lat: number, lng: number) => Promise
remove: (dayId: number, id: number) => Promise
/**
@@ -28,6 +42,7 @@ export interface RoadtripVias {
}
const EMPTY: Record = {}
+const EMPTY_TRACKS: Record = {}
/**
* The points this trip's drives are routed through.
@@ -42,6 +57,7 @@ const EMPTY: Record = {}
*/
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
const [byDay, setByDay] = useState>(EMPTY)
+ const [trackByDay, setTrackByDay] = useState>(EMPTY_TRACKS)
const group = useCallback((vias: RoadtripVia[]) => {
const next: Record = {}
@@ -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 = {}
+ 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 }
}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 1fa89f78e..8ce290445 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -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}
/>
) : (
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 5eeca4fe2..47bdb3555 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -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) {
diff --git a/server/src/nest/roadtrip/roadtrip.controller.ts b/server/src/nest/roadtrip/roadtrip.controller.ts
index 145c6a5a1..abe3e1363 100644
--- a/server/src/nest/roadtrip/roadtrip.controller.ts
+++ b/server/src/nest/roadtrip/roadtrip.controller.ts
@@ -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) };
}
diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts
index 888e59452..2778d27d3 100644
--- a/server/src/nest/roadtrip/roadtrip.mcp.ts
+++ b/server/src/nest/roadtrip/roadtrip.mcp.ts
@@ -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({
diff --git a/server/src/nest/roadtrip/roadtrip.service.ts b/server/src/nest/roadtrip/roadtrip.service.ts
index 09705d9a2..cf1fd8c35 100644
--- a/server/src/nest/roadtrip/roadtrip.service.ts
+++ b/server/src/nest/roadtrip/roadtrip.service.ts
@@ -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(
+ `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 }>(
diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts
index 0bbac1734..1fc4dda75 100644
--- a/server/tests/unit/nest/roadtrip.service.test.ts
+++ b/server/tests/unit/nest/roadtrip.service.test.ts
@@ -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: (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 });
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index 2d94da042..d05f3bacc 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -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': 'طعام',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index ebba7d5c9..35266ed6b 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index dbaea2b7b..ab45f03f7 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 95f403961..7e1e3a16a 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index d2f8cff55..34833e5dd 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 132825330..7659b123e 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index e7f3fdbca..10cd44bbb 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 48fe8cc98..9e758893e 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index 957514b18..7c44b625c 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -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': 'Φαγητό',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 511a6cec0..1a8468634 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index c6f540086..5bae1e9d8 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index c7de8384b..00c1deff4 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index d48945003..310cf5c0e 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -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': '食事',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index f5adcba79..41dbcbe9b 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -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': '식사',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index db0dfa87d..23b103111 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index cf73fc2b7..50a303542 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 6890feae5..c5d80e472 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -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': 'Еда',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 1a4330519..8dc5ade1c 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 9875c3e57..ef87149b9 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 50350dc67..ea6267c77 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -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': 'Їжа',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index 1873d24c0..fd34df34e 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index cff32ab16..9653537c4 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -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': '餐飲',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index d94f922b5..188f12a22 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -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': '餐饮',
diff --git a/shared/src/roadtrip/roadtrip.schema.ts b/shared/src/roadtrip/roadtrip.schema.ts
index 6daf64eec..1eac31b1c 100644
--- a/shared/src/roadtrip/roadtrip.schema.ts
+++ b/shared/src/roadtrip/roadtrip.schema.ts
@@ -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;
+/** 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;
+
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;