From 676c82a308ab4ff26644b2d70c7b4672b0fe673b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 5 Sep 2026 00:49:59 +0200 Subject: [PATCH] feat(roadtrip): remember which track a day follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/api/client.ts | 3 +- client/src/components/Map/RouteCalculator.ts | 15 ++++- .../Roadtrip/RoadtripCorridorPanel.tsx | 4 +- .../components/Roadtrip/RoadtripSidebar.tsx | 10 ++- .../Roadtrip/RoadtripTrackModal.test.tsx | 2 + .../Roadtrip/RoadtripTrackModal.tsx | 10 ++- .../Roadtrip/useFollowTrack.test.ts | 34 ++++++++-- .../src/components/Roadtrip/useFollowTrack.ts | 42 ++++++++++++- .../components/Roadtrip/useRoadtripRoutes.ts | 4 +- .../components/Roadtrip/useRoadtripVias.ts | 42 ++++++++++--- client/src/pages/TripPlannerPage.tsx | 1 + server/src/db/migrations.ts | 29 +++++++++ .../src/nest/roadtrip/roadtrip.controller.ts | 12 +++- server/src/nest/roadtrip/roadtrip.mcp.ts | 17 ++++- server/src/nest/roadtrip/roadtrip.service.ts | 47 +++++++++++++- .../tests/unit/nest/roadtrip.service.test.ts | 63 +++++++++++++++++++ shared/src/i18n/ar/roadtrip.ts | 1 + shared/src/i18n/br/roadtrip.ts | 1 + shared/src/i18n/ca/roadtrip.ts | 1 + shared/src/i18n/cs/roadtrip.ts | 1 + shared/src/i18n/de/roadtrip.ts | 1 + shared/src/i18n/en/roadtrip.ts | 1 + shared/src/i18n/es/roadtrip.ts | 1 + shared/src/i18n/fr/roadtrip.ts | 1 + shared/src/i18n/gr/roadtrip.ts | 1 + shared/src/i18n/hu/roadtrip.ts | 1 + shared/src/i18n/id/roadtrip.ts | 1 + shared/src/i18n/it/roadtrip.ts | 1 + shared/src/i18n/ja/roadtrip.ts | 1 + shared/src/i18n/ko/roadtrip.ts | 1 + shared/src/i18n/nl/roadtrip.ts | 1 + shared/src/i18n/pl/roadtrip.ts | 1 + shared/src/i18n/ru/roadtrip.ts | 1 + shared/src/i18n/sv/roadtrip.ts | 1 + shared/src/i18n/tr/roadtrip.ts | 1 + shared/src/i18n/uk/roadtrip.ts | 1 + shared/src/i18n/vi/roadtrip.ts | 1 + shared/src/i18n/zh-TW/roadtrip.ts | 1 + shared/src/i18n/zh/roadtrip.ts | 1 + shared/src/roadtrip/roadtrip.schema.ts | 29 +++++++++ 40 files changed, 356 insertions(+), 31 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 806ee6047..743bd0263 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 }), /** diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 85cbfb540..788b20e5a 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -31,10 +31,23 @@ const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = { * A configured base is expected to serve the standard OSRM layout, * `/route/v1//…`, 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]}` } /** diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index f950d4955..222e05033 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -236,7 +236,9 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro const socketsFound = useMemo(() => { const seen = new Set() 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 diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index ed2932bc5..2803e31e8 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -60,6 +60,8 @@ interface RoadtripSidebarProps { * what somebody reading the day needs to know. */ viaCounts?: Record + /** The name of the track each day follows, so the badge can say which road it is. */ + trackNames?: Record } const MODE_ICON: Record = { @@ -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 ? ( - +