mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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 }),
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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': 'طعام',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Φαγητό',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '食事',
|
||||
|
||||
@@ -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': '식사',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Еда',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Їжа',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '餐飲',
|
||||
|
||||
@@ -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': '餐饮',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user