From f4bbb0fe5d92909496857f6cde954d12a958cf54 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 12:28:12 +0200 Subject: [PATCH 1/5] fix(roadtrip): the alternatives list compared two routers' drive times The ways of driving one leg come from OSRM, except the toll-free or motorway-free offer beside them: the shipped OSRM hosts are built without excludable road classes and answer exclude= with HTTP 400, so that one is asked of the avoidance router instead. Their figures were then read against each other. The quickest was elected across both, which is what decides the road drawn as the one being driven, and every other road was labelled with how far behind that election it sat. The two engines agree on the road to a tenth of a percent and not at all on how fast it may be driven: over twelve European legs the per-leg time ran from 14.7 % under OSRM's to 13.2 % over, with the sign following the region. On a Spanish motorway leg that gap alone is enough to crown a toll-free detour and call the motorway somebody is on the slow way round. An offer now carries the engine that timed it. It takes no part in electing the quickest, no difference is printed across the two, and the bar says where the figure came from. Each route still reports its own time; only the subtraction is refused. An instance running its own OSRM through the MLD pipeline answers exclude itself, so there both figures are one engine's and the comparison works exactly as before. --- client/src/components/Map/RouteCalculator.ts | 24 +++++- client/src/components/Map/valhallaRoute.ts | 12 ++- .../Roadtrip/RoadtripAlternativesBar.test.tsx | 16 ++++ .../Roadtrip/RoadtripAlternativesBar.tsx | 14 +++- .../Roadtrip/alternativeOverlays.test.ts | 77 +++++++++++++++++++ .../Roadtrip/alternativeOverlays.ts | 33 +++++++- 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 + 29 files changed, 188 insertions(+), 11 deletions(-) diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 482321232..3638696c5 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -649,6 +649,19 @@ export interface RouteAlternative { divergence: { lat: number; lng: number } | null /** Set when this way exists because a road class was left out of it. */ avoids?: AvoidClass + /** + * Which engine priced this route, when it was not the one that priced the rest. + * + * Absent for everything OSRM answered, which is every route in a list except the + * avoidance offer on a default install. It exists because the two engines do not + * agree on speed: measured over twelve European legs the per-leg drive times run + * from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region: + * Valhalla is faster on Spanish autovía and slower through a city. So a figure from + * one of them subtracted from a figure from the other is not a difference in + * driving time, it is the gap between two speed models, and the reader has no way + * of telling the two apart. + */ + engine?: 'valhalla' } /** @@ -684,10 +697,12 @@ const SAME_ROAD_KM = 0.5 * Two lines are the same road. * * Length and duration used to decide this, and stopped being enough when a second engine - * started answering. Valhalla prices roads differently — measured over ten European legs - * its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same - * road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery. - * Two identical blue lines on the map is exactly what that looks like. + * started answering. Valhalla prices roads differently: measured over twelve European + * legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back + * thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical + * blue lines on the map is exactly what that looks like. Duration is the worse of the + * two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 % + * over, with the sign set by the region rather than by the road. * * The geometry does not care which engine drew it, so it decides whenever there is one. * The numbers stay as the fallback for a line with no shape to compare. @@ -778,6 +793,7 @@ async function valhallaExcluding( distance: leg.distance, duration: leg.duration, divergence: null, + engine: 'valhalla', } } diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index 4bbdbde7c..fe736c0fd 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -22,10 +22,14 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types' * - it refuses a point it cannot bind to a road, where OSRM still snaps one. The * Stephansdom, in a pedestrian zone, answers `No path could be found for input` * until a search radius is allowed; - * - it prices roads differently. Over ten European legs its drive times sat 5.4 % - * above OSRM's on average and 36 % above on one, and those times feed the arrival - * clocks of a road trip day. Mixing engines inside one chain would move times - * nobody asked to move. + * - it prices roads differently, and not by a constant anybody could correct for. + * Over twelve European legs the per-leg drive time ran from 14.7 % under OSRM's to + * 13.2 % over, and the sign follows the region rather than the road: on Spanish + * autovía Valhalla is the faster of the two on every leg measured, through a city + * it is half again slower. Those times feed the arrival clocks of a road trip day, + * so mixing engines inside one chain would move times nobody asked to move, and + * subtracting one engine's figure from the other's, which is what an offer beside + * a route invites, measures the gap between two speed models and nothing else. * * Which leaves exactly the shape this module serves: two points, one avoidance, asked * on demand, drawn as an offer next to the route rather than replacing it. diff --git a/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx b/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx index e1e299513..fa9034809 100644 --- a/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx @@ -25,6 +25,7 @@ const overlay = (over: Partial = {}): AlternativeOverlay => duration: 10_800, distance: 290_000, slowerThanQuickest: 0, + otherEngine: false, labelBg: '#0a84ff', at: { lat: 53, lng: 11 }, ...over, @@ -142,4 +143,19 @@ describe('RoadtripAlternativesBar', () => { fireEvent.click(screen.getByRole('button', { name: /close/i })) expect(onClose).toHaveBeenCalled() }) + + it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => { + render( + , + ) + expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1) + }) }) diff --git a/client/src/components/Roadtrip/RoadtripAlternativesBar.tsx b/client/src/components/Roadtrip/RoadtripAlternativesBar.tsx index 53d213c15..7b9ce1473 100644 --- a/client/src/components/Roadtrip/RoadtripAlternativesBar.tsx +++ b/client/src/components/Roadtrip/RoadtripAlternativesBar.tsx @@ -1,6 +1,7 @@ import React from 'react' -import { Shuffle, X, AlertTriangle } from 'lucide-react' +import { Shuffle, X, AlertTriangle, Info } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' +import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import { formatDurationShort } from './roadtripModel' @@ -92,6 +93,17 @@ export default function RoadtripAlternativesBar({ : t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })} + {/* Only where the drive time on the map came from the other engine. The + road and the length are the same question either way; the time is not, + and two engines' times sitting next to each other on the map invite a + subtraction that means nothing. Saying so here, where the choice is + actually made, is cheaper than a second request per offer to restate + the whole list in one engine's terms. */} + {alt.otherEngine ? ( + + + + ) : null} ))} diff --git a/client/src/components/Roadtrip/alternativeOverlays.test.ts b/client/src/components/Roadtrip/alternativeOverlays.test.ts index 9aa94da40..3a2a78a44 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.test.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.test.ts @@ -143,4 +143,81 @@ describe('buildAlternativeOverlays', () => { ) expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1) }) + + it('FE-ALTOVL-011: a route the other engine priced cannot win the quickest election', () => { + // The shape a default install actually produces: OSRM answered with the one road + // it likes, and the toll-free offer beside it came from the avoidance router, + // whose speed model reads a Spanish motorway a seventh faster. Electing across + // the two paints the detour blue and calls the road being driven the slow way + // round, on nothing but that gap. + const out = buildAlternativeOverlays( + [ + alt({ duration: 11_880, coordinates: line(20, 0) }), + alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }), + ], + LABELS, + ) + expect(out[0].color).toBe(ALT_PRIMARY) + expect(out[1].color).toBe(ALT_SECONDARY) + expect(out[0].note).toBe('Fastest') + expect(out[1].note).toBe('No tolls') + }) + + it('FE-ALTOVL-012: no difference is printed across two engines', () => { + const out = buildAlternativeOverlays( + [ + alt({ duration: 11_880, coordinates: line(20, 0) }), + alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }), + ], + LABELS, + ) + expect(out[0].slowerThanQuickest).toBe(0) + expect(out[1].slowerThanQuickest).toBe(0) + expect(out[0].otherEngine).toBe(false) + expect(out[1].otherEngine).toBe(true) + // Each route still reports its own time; it is only the subtraction that is refused. + expect(out[0].duration).toBe(11_880) + expect(out[1].duration).toBe(10_140) + }) + + it('FE-ALTOVL-013: an instance whose own router answers still gets a real difference', () => { + // A self-hosted OSRM built through the MLD pipeline answers `exclude` itself, so + // the avoidance offer carries no engine of its own and the two figures are the + // same engine's. That comparison is sound and must keep working. + const out = buildAlternativeOverlays( + [ + alt({ duration: 3600, coordinates: line(20, 0) }), + alt({ duration: 5400, coordinates: line(20, 1), avoids: 'toll' }), + ], + LABELS, + ) + expect(out[1].slowerThanQuickest).toBe(1800) + expect(out.every(o => o.otherEngine === false)).toBe(true) + }) + + it('FE-ALTOVL-014: a list with nothing comparable in it still names one road primary', () => { + const out = buildAlternativeOverlays( + [ + alt({ duration: 3600, coordinates: line(20, 0), engine: 'valhalla' }), + alt({ duration: 5400, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }), + ], + LABELS, + ) + expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1) + expect(out[0].color).toBe(ALT_PRIMARY) + expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true) + }) + + it('FE-ALTOVL-015: the road being driven stays blue whatever the other engine says', () => { + const out = buildAlternativeOverlays( + [ + alt({ duration: 11_880, coordinates: line(20, 0), current: true }), + alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }), + ], + LABELS, + ) + expect(out[0].color).toBe(ALT_PRIMARY) + expect(out[0].note).toBe('Current') + expect(out[1].color).toBe(ALT_SECONDARY) + }) }) diff --git a/client/src/components/Roadtrip/alternativeOverlays.ts b/client/src/components/Roadtrip/alternativeOverlays.ts index df97f689f..5171a99ab 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.ts @@ -16,6 +16,13 @@ export interface AlternativeOverlay { distance: number /** Seconds more than the quickest offer; negative never happens by construction. */ slowerThanQuickest: number + /** + * True when this route's figures came from the other engine, so they cannot be read + * against the rest of the list. Nothing derived from a comparison is filled in for + * such a route: it takes no part in electing the quickest and its + * `slowerThanQuickest` stays zero. + */ + otherEngine: boolean labelBg: string /** Where to hang the label — a point on this route and on no other. */ at: { lat: number; lng: number } @@ -91,7 +98,21 @@ export function buildAlternativeOverlays( // The quickest of what came back, which is what Apple calls out — not necessarily the // first entry, since the road currently driven is put at the top when there is one. - const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0) + // + // Only among the routes one engine priced. The avoidance offer on a default install + // comes from the second engine, whose speed model differs by up to a seventh either + // way depending on the region, so letting it into this election decides which road is + // blue and how much slower every other road is called on nothing but that gap. On a + // Spanish motorway leg that is enough to crown a toll-free B-road detour and label + // the motorway somebody is actually driving as the slower way round. + // + // Index 0 is the fallback rather than -1: the first entry is the road being driven + // when there is one and the router's own pick otherwise, so if a list ever held + // nothing but second-engine routes, that is still the one to call primary. + const comparable = routes.map((r, i) => (r.engine ? -1 : i)).filter(i => i >= 0) + const quickest = comparable.length + ? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0]) + : 0 const anyCurrent = routes.some(r => r.current) return routes.map((route, index) => { @@ -100,6 +121,7 @@ export function buildAlternativeOverlays( // Blue is the road you are on: the one currently driven, or the router's own pick // when nothing has been bent. Everything else is the pale blue of an offer. const primary = route.current || (!anyCurrent && index === quickest) + const otherEngine = !!route.engine return { index, coordinates: route.coordinates, @@ -119,7 +141,14 @@ export function buildAlternativeOverlays( : index === quickest ? labels.fastest : '', duration: route.duration, distance: route.distance, - slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)), + // Left at zero for a route the other engine priced, and for every route when + // that is the only thing to measure against. The bar reads a zero as "no + // difference worth printing" and falls back to naming what the road is, which + // for these is always the class left out of it. + slowerThanQuickest: otherEngine || !!routes[quickest].engine + ? 0 + : Math.max(0, Math.round(route.duration - routes[quickest].duration)), + otherEngine, labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG, at: at ?? { lat: 0, lng: 0 }, } diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index eb0214c0a..1f3cdf9e2 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -204,6 +204,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'الأسرع', 'roadtrip.alt.slower': 'أبطأ بـ {time}', 'roadtrip.alt.quicker': 'أسرع بـ {time}', + 'roadtrip.alt.otherEngine': 'الزمن محسوب بمحرك التجنب، لا بالمحرك الرئيسي.', 'roadtrip.day': 'اليوم {number}', 'roadtrip.quietDay.empty': 'لا توجد محطات بعد — أفلِت واحدة هنا', 'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 02089c4e5..dc2e04542 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -209,6 +209,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Mais rápida', 'roadtrip.alt.slower': '{time} mais lenta', 'roadtrip.alt.quicker': '{time} mais rápida', + 'roadtrip.alt.otherEngine': 'Tempo do roteador de desvios, não do principal.', 'roadtrip.day': 'Dia {number}', 'roadtrip.quietDay.empty': 'Ainda sem paradas — solte uma aqui', 'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 24be49233..d97841fba 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -211,6 +211,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'La més ràpida', 'roadtrip.alt.slower': '{time} més lenta', 'roadtrip.alt.quicker': '{time} més ràpida', + 'roadtrip.alt.otherEngine': 'Temps de l’encaminador d’evitació, no del principal.', 'roadtrip.day': 'Dia {number}', 'roadtrip.quietDay.empty': 'Encara sense parades: deixa’n una aquí', 'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 3c115cdba..12f597426 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -207,6 +207,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Nejrychlejší', 'roadtrip.alt.slower': 'o {time} déle', 'roadtrip.alt.quicker': 'o {time} rychleji', + 'roadtrip.alt.otherEngine': 'Čas spočítal server pro objížďky, ne ten hlavní.', 'roadtrip.day': 'Den {number}', 'roadtrip.quietDay.empty': 'Zatím žádné zastávky — přetáhněte sem', 'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index ff4e88a0c..d19f2fc89 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -206,6 +206,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Schnellste', 'roadtrip.alt.slower': '{time} länger', 'roadtrip.alt.quicker': '{time} kürzer', + 'roadtrip.alt.otherEngine': 'Zeit vom Ausweich-Router, nicht vom Haupt-Router.', 'roadtrip.day': 'Tag {number}', 'roadtrip.quietDay.empty': 'Noch keine Stopps — hier ablegen', 'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index bfec101a3..607f13b0d 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -203,6 +203,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Fastest', 'roadtrip.alt.slower': '{time} slower', 'roadtrip.alt.quicker': '{time} quicker', + 'roadtrip.alt.otherEngine': 'Timed by the avoidance router, not the main one.', 'roadtrip.day': 'Day {number}', 'roadtrip.quietDay.empty': 'No stops yet — drop one here', 'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 4dc64dcb9..f9f7353a6 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'La más rápida', 'roadtrip.alt.slower': '{time} más', 'roadtrip.alt.quicker': '{time} menos', + 'roadtrip.alt.otherEngine': 'Tiempo del enrutador de evitación, no del principal.', 'roadtrip.day': 'Día {number}', 'roadtrip.quietDay.empty': 'Sin paradas todavía: suelta una aquí', 'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 29353a7e0..1c9a81abf 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'La plus rapide', 'roadtrip.alt.slower': '{time} de plus', 'roadtrip.alt.quicker': '{time} de moins', + 'roadtrip.alt.otherEngine': 'Temps du routeur de contournement, pas du principal.', 'roadtrip.day': 'Jour {number}', 'roadtrip.quietDay.empty': 'Aucun arrêt — déposez-en un ici', 'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 22c443bcb..1fc7dc20d 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -212,6 +212,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Η ταχύτερη', 'roadtrip.alt.slower': '{time} πιο αργή', 'roadtrip.alt.quicker': '{time} πιο γρήγορη', + 'roadtrip.alt.otherEngine': 'Χρόνος από τον δρομολογητή αποφυγής, όχι τον κύριο.', 'roadtrip.day': 'Ημέρα {number}', 'roadtrip.quietDay.empty': 'Καμία στάση ακόμη — αφήστε μία εδώ', 'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index af2f577eb..d38b07667 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -208,6 +208,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Leggyorsabb', 'roadtrip.alt.slower': '{time}-cel lassabb', 'roadtrip.alt.quicker': '{time}-cel gyorsabb', + 'roadtrip.alt.otherEngine': 'Az időt a kerülő útvonaltervező számolta, nem a fő.', 'roadtrip.day': '{number}. nap', 'roadtrip.quietDay.empty': 'Még nincs megálló — húzzon ide egyet', 'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 1b746dae6..1fff5806b 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Tercepat', 'roadtrip.alt.slower': '{time} lebih lama', 'roadtrip.alt.quicker': '{time} lebih cepat', + 'roadtrip.alt.otherEngine': 'Waktu dihitung mesin penghindaran, bukan mesin utama.', 'roadtrip.day': 'Hari {number}', 'roadtrip.quietDay.empty': 'Belum ada perhentian — jatuhkan di sini', 'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 9d7498128..0fb7ff695 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'La più veloce', 'roadtrip.alt.slower': '{time} in più', 'roadtrip.alt.quicker': '{time} in meno', + 'roadtrip.alt.otherEngine': 'Tempo dal router delle esclusioni, non dal principale.', 'roadtrip.day': 'Giorno {number}', 'roadtrip.quietDay.empty': 'Ancora nessuna sosta: trascinane una qui', 'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index e68b03e41..e94f710df 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -207,6 +207,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': '最速', 'roadtrip.alt.slower': '{time} 遅い', 'roadtrip.alt.quicker': '{time} 速い', + 'roadtrip.alt.otherEngine': '所要時間は主エンジンではなく回避用エンジンでの計算です。', 'roadtrip.day': '{number} 日目', 'roadtrip.quietDay.empty': 'まだ立ち寄り先がありません。ここにドロップ', 'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index fe4be5971..e6699fd73 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -206,6 +206,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': '가장 빠름', 'roadtrip.alt.slower': '{time} 더 걸림', 'roadtrip.alt.quicker': '{time} 더 빠름', + 'roadtrip.alt.otherEngine': '기본 엔진이 아니라 회피 엔진이 계산한 시간입니다.', 'roadtrip.day': '{number}일차', 'roadtrip.quietDay.empty': '아직 경유지가 없습니다. 여기에 놓으세요', 'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 0e2d09307..c3bc29768 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Snelste', 'roadtrip.alt.slower': '{time} langzamer', 'roadtrip.alt.quicker': '{time} sneller', + 'roadtrip.alt.otherEngine': 'Tijd van de vermijdingsrouter, niet van de hoofdrouter.', 'roadtrip.day': 'Dag {number}', 'roadtrip.quietDay.empty': 'Nog geen stops — laat er hier een vallen', 'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index f1d35f8c8..53b796e0d 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -208,6 +208,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Najszybsza', 'roadtrip.alt.slower': '{time} dłużej', 'roadtrip.alt.quicker': '{time} krócej', + 'roadtrip.alt.otherEngine': 'Czas policzony silnikiem omijania, nie głównym.', 'roadtrip.day': 'Dzień {number}', 'roadtrip.quietDay.empty': 'Brak przystanków — upuść tutaj', 'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index e07f94e56..a8a7faf1d 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -210,6 +210,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Самый быстрый', 'roadtrip.alt.slower': 'на {time} дольше', 'roadtrip.alt.quicker': 'на {time} быстрее', + 'roadtrip.alt.otherEngine': 'Время посчитал маршрутизатор обхода, не основной.', 'roadtrip.day': 'День {number}', 'roadtrip.quietDay.empty': 'Остановок пока нет — перетащите сюда', 'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 47a1d1fc7..97cab4a5b 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -207,6 +207,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Snabbast', 'roadtrip.alt.slower': '{time} långsammare', 'roadtrip.alt.quicker': '{time} snabbare', + 'roadtrip.alt.otherEngine': 'Tid från undvikandemotorn, inte huvudmotorn.', 'roadtrip.day': 'Dag {number}', 'roadtrip.quietDay.empty': 'Inga stopp än – släpp ett här', 'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index b1d109999..2d7d95985 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -209,6 +209,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'En hızlı', 'roadtrip.alt.slower': '{time} daha uzun', 'roadtrip.alt.quicker': '{time} daha kısa', + 'roadtrip.alt.otherEngine': 'Süre, ana motorla değil kaçınma motoruyla hesaplandı.', 'roadtrip.day': '{number}. gün', 'roadtrip.quietDay.empty': 'Henüz mola yok — buraya bırakın', 'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 0a8b59f4c..4a47124e8 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -209,6 +209,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Найшвидший', 'roadtrip.alt.slower': 'на {time} довше', 'roadtrip.alt.quicker': 'на {time} швидше', + 'roadtrip.alt.otherEngine': 'Час порахував маршрутизатор обходів, а не основний.', 'roadtrip.day': 'День {number}', 'roadtrip.quietDay.empty': 'Зупинок ще немає — перетягніть сюди', 'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 4396cc815..939000bf0 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -208,6 +208,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': 'Nhanh nhất', 'roadtrip.alt.slower': 'chậm hơn {time}', 'roadtrip.alt.quicker': 'nhanh hơn {time}', + 'roadtrip.alt.otherEngine': 'Giờ do máy chủ định tuyến tránh đường tính, không phải máy chính.', 'roadtrip.day': 'Ngày {number}', 'roadtrip.quietDay.empty': 'Chưa có điểm dừng — thả vào đây', 'roadtrip.quietDay.one': 'Hiện chỉ có {name} — thả một điểm dừng vào đây', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 08a393e12..1b127b71a 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -197,6 +197,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': '最快', 'roadtrip.alt.slower': '慢 {time}', 'roadtrip.alt.quicker': '快 {time}', + 'roadtrip.alt.otherEngine': '駕駛時間由避開專用的路徑服務計算,無法與其他項比較。', 'roadtrip.day': '第 {number} 天', 'roadtrip.quietDay.empty': '尚無停靠點 — 拖到這裡', 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠點拖到這裡', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 8cbca9a94..f13eccc88 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -197,6 +197,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.alt.fastest': '最快', 'roadtrip.alt.slower': '慢 {time}', 'roadtrip.alt.quicker': '快 {time}', + 'roadtrip.alt.otherEngine': '用时由避开引擎计算,而非主引擎。', 'roadtrip.day': '第 {number} 天', 'roadtrip.quietDay.empty': '暂无停靠点 — 拖到这里', 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠点拖到这里', From accdd5905e2acb34850c9b1f15b34cb9cc4fb803 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 12:28:30 +0200 Subject: [PATCH 2/5] feat(roadtrip): add a stop the search never found, and clear the ones it did Four things testers asked for in the "Along the route" panel. Add manually. The corridor search reads OpenStreetMap, and a good share of the chargers standing at a motorway junction are not in it, Tesla Superchargers among them. The way round it was to leave road trip mode, add the place under Days, drag it onto the right day, come back and mark it a charging stop. The Search button now shares its row with a second one that asks the two questions those four steps answered: which place, and where on the drive. The place comes from a debounced typeahead over the place search; the position from anchorFor, the same projection a via is placed with, and it is offered as a dropdown of every leg of every routed day so it can be overridden. Unlike a via, a place far from the drawn line is not refused, only flagged: that place is the reason this dialog exists. From there it hands over to the stop popup a corridor hit already goes through, so the kind and the dwell are decided once for both paths and the write stays saveStopDraft. Clear results. A search that turned up the wrong thing left its hits in the list and its pins on the map, and the only way out was switching to the day plan and back. The search hook has exposed a clear() since it was written and nothing ever called it. A control in the results header does now, and takes the four narrowing states with it, because they were set for a question that no longer has an answer. Charging as the default for an electric car. "Looking for" opened on Fuel whatever the vehicle. It opens on Charging when the trip's vehicle is electric, and is unchanged for combustion, for an empty value and for a vehicle nobody set, so nothing moves for anybody who never opened the dialog. Seeded once, never over a category the traveller picked or a search in flight. Clicking a result moves the map to it. The row had no click handler at all; only the trailing Add button did anything. The badge and the text are one activatable target with that button as its sibling, which keeps the li a list item and avoids a button inside a button, and it goes through the same focusRoadtripPoint the rail uses, which closes the refuel and alternatives panels first because those outrank the camera. --- .../Roadtrip/RoadtripCorridorPanel.test.tsx | 109 +++++ .../Roadtrip/RoadtripCorridorPanel.tsx | 221 +++++++--- .../Roadtrip/RoadtripManualStopModal.test.tsx | 287 +++++++++++++ .../Roadtrip/RoadtripManualStopModal.tsx | 392 ++++++++++++++++++ client/src/components/Roadtrip/manualStop.ts | 30 ++ .../Roadtrip/useRoadtripCorridor.test.ts | 90 +++- .../Roadtrip/useRoadtripCorridor.ts | 69 ++- client/src/pages/TripPlannerPage.tsx | 4 + .../useTripPlanner.roadtrip.test.tsx | 104 +++++ .../src/pages/tripPlanner/useTripPlanner.ts | 80 +++- shared/src/i18n/ar/roadtrip.ts | 8 + shared/src/i18n/br/roadtrip.ts | 8 + shared/src/i18n/ca/roadtrip.ts | 8 + shared/src/i18n/cs/roadtrip.ts | 8 + shared/src/i18n/de/roadtrip.ts | 8 + shared/src/i18n/en/roadtrip.ts | 8 + shared/src/i18n/es/roadtrip.ts | 8 + shared/src/i18n/fr/roadtrip.ts | 8 + shared/src/i18n/gr/roadtrip.ts | 8 + shared/src/i18n/hu/roadtrip.ts | 8 + shared/src/i18n/id/roadtrip.ts | 8 + shared/src/i18n/it/roadtrip.ts | 8 + shared/src/i18n/ja/roadtrip.ts | 8 + shared/src/i18n/ko/roadtrip.ts | 7 + shared/src/i18n/nl/roadtrip.ts | 8 + shared/src/i18n/pl/roadtrip.ts | 8 + shared/src/i18n/ru/roadtrip.ts | 8 + shared/src/i18n/sv/roadtrip.ts | 8 + shared/src/i18n/tr/roadtrip.ts | 8 + shared/src/i18n/uk/roadtrip.ts | 8 + shared/src/i18n/vi/roadtrip.ts | 8 + shared/src/i18n/zh-TW/roadtrip.ts | 7 + shared/src/i18n/zh/roadtrip.ts | 7 + 33 files changed, 1504 insertions(+), 63 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripManualStopModal.tsx create mode 100644 client/src/components/Roadtrip/manualStop.ts diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index a40a5005b..9cd7f0d8a 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -110,11 +110,23 @@ function corridor(over: Partial = {}, search: Partial 1), stopsAlongKm: [0, 100], + clear: vi.fn(), ...over, search: searchState, } } +/** + * The buttons of the "what to look for" card's last row, in order. + * + * By position rather than by label: which half is which is the point of the row, and a + * label is a translation that would make this a test about wording. + */ +const searchRow = (): HTMLButtonElement[] => { + const searchButton = screen.getByRole('button', { name: 'Search' }) + return [...(searchButton.parentElement?.querySelectorAll('button') ?? [])] as HTMLButtonElement[] +} + describe('RoadtripCorridorPanel', () => { it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => { const c = corridor() @@ -356,6 +368,103 @@ describe('RoadtripCorridorPanel', () => { expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument() }) + it('FE-ROADTRIP-PANEL-024: what was found carries the way to throw it away', () => { + const nothing = corridor() + const { unmount } = wrap() + // Before a search there is nothing to count and nothing to clear, and a dead control + // is worse than none. + expect(screen.queryByText(/on the way/)).not.toBeInTheDocument() + unmount() + + // Two fuel hits and a campsite, so the total over the filters is a different + // sentence from any one group's count and can anchor the query on its own. + const c = corridor({ categories: ['fuel', 'campsite'] }, { + results: [ + poi({ osm_id: 'a', name: 'Aral' }), + poi({ osm_id: 'b', name: 'Shell' }), + poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite' }), + ], + }) + wrap() + + const header = screen.getByText('3 on the way').closest('div')! + fireEvent.click(within(header).getByRole('button')) + expect(c.clear).toHaveBeenCalledTimes(1) + // Clearing asks the place search for nothing at all. + expect(c.search.search).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-PANEL-025: the search shares its row with a manual add, and only where one may add', () => { + const reader = corridor() + const { unmount } = wrap() + expect(searchRow()).toHaveLength(1) + unmount() + + const onAddManual = vi.fn() + const c = corridor() + wrap( + , + ) + + // One row, two halves of it, not a second button under the first. + const [searchButton, manual] = searchRow() + expect(manual).toBeDefined() + expect(searchButton.className).toContain('flex-1') + expect(manual.className).toContain('flex-1') + + // It opens the dialog. Nothing is added until a place and a position are picked + // there, and the kind of stop is still the stop popup's question. + fireEvent.click(manual) + expect(onAddManual).not.toHaveBeenCalled() + expect(screen.getByLabelText('Search places...')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-PANEL-026: with no drive on the trip there is nowhere to add one by hand', () => { + const dayless = corridor({ day: undefined }) + wrap() + + expect(searchRow()).toHaveLength(1) + }) + + it('FE-ROADTRIP-PANEL-027: clicking a row brings that hit into view, and adding it does not', () => { + const onFocusPoint = vi.fn() + const onAddPoi = vi.fn() + const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', lat: 53.14, lng: 9.82 })] }) + wrap( + , + ) + + fireEvent.click(screen.getByText('Aral')) + expect(onFocusPoint).toHaveBeenCalledWith(53.14, 9.82) + expect(onAddPoi).not.toHaveBeenCalled() + + // The Add button is the row's sibling and not a button inside a button, so adding a + // stop does not also move the camera out from under the list. + onFocusPoint.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onAddPoi).toHaveBeenCalledTimes(1) + expect(onFocusPoint).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-PANEL-028: a row is inert where there is no map to move', () => { + const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] }) + wrap() + + // No handler, no button: a control that does nothing is one a keyboard user reaches + // for and finds empty. + expect(screen.getByText('Aral').closest('button')).toBeNull() + }) + it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => { const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] }) wrap() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 8f8df5aeb..36f8f9588 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -10,9 +10,11 @@ import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' +import RoadtripManualStopModal from './RoadtripManualStopModal' import { serviceColor } from './roadtripModel' import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds' import { FS } from './typeScale' +import type { ManualStopPlace, ManualStopTarget } from './manualStop' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' import type { CorridorPoi } from './useCorridorPois' import type { RoadtripRoutes } from './useRoadtripRoutes' @@ -28,6 +30,18 @@ interface RoadtripCorridorPanelProps { dayId?: number | null, position?: number | null, ) => void + /** + * Brings a hit into view on the map. A row is where you decide a place is worth + * looking at, and looking at it means seeing which side of the road it is on. + */ + onFocusPoint?: (lat: number, lng: number) => void + /** + * Adds a place the search never found, at a position on the drive. Given only to + * somebody who may add places, which is what keeps the button off a reader's panel. + */ + onAddManual?: (place: ManualStopPlace, target: ManualStopTarget) => void + /** Where a freely chosen point belongs on the drive, from the planner's projection. */ + manualStopTargetFor?: (lat: number, lng: number) => ManualStopTarget | null } /** @@ -96,51 +110,69 @@ function ResultBadge({ category }: { category: string }): React.ReactElement { */ const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted' -function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement { +function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => void; onFocus?: () => void }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + /** + * The badge and the text are one target, the Add button beside them another. + * + * Not the row itself and not a button around the lot: an Add button inside a row + * button is markup neither a browser nor a screen reader can make sense of, and the + * `
  • ` has to stay one for the list around it to still be a list. Same shape + * `AutomaticDayStop` uses: an element that is a button only when there is somewhere + * for it to go, so a row without the map behind it is not a dead control. + */ + const Block = onFocus ? 'button' : 'div' return (
  • - -
    -
    - {poi.name} -
    - {poi.pluginId &&

    {poi.pluginId}

    } - {/* Two facts, two chips. Where it is off the road and how far into the drive it - comes are separate answers, and as two phrases sharing a line they read as one - run-on sentence about the same thing. A chip each gives them an edge, and the - row still wraps cleanly at a narrow width because nothing has to break around - a separator. */} -
    - - {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - - - {poi.alongKm < 0.5 - ? t('roadtrip.poi.atStart') - : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} - - {/* What the charger offers, where OSM says. Socket names are proper nouns and - stay as they are; the numbers around them are what decides whether a car can - use it at all. A station that says nothing shows nothing rather than a row - of dashes, because "not stated" is not "no". */} - {poi.charging?.sockets.length ? ( - - {poi.charging.sockets.slice(0, 3).map(s => ( - - {SOCKET_LABEL[s.type] ?? s.type} - {s.kw ? ` ${s.kw} kW` : ''} - {s.count && s.count > 1 ? ` ×${s.count}` : ''} - - ))} + + +
    +
    + {poi.name} +
    + {poi.pluginId &&

    {poi.pluginId}

    } + {/* Two facts, two chips. Where it is off the road and how far into the drive it + comes are separate answers, and as two phrases sharing a line they read as one + run-on sentence about the same thing. A chip each gives them an edge, and the + row still wraps cleanly at a narrow width because nothing has to break around + a separator. */} +
    + + {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - ) : null} - {poi.charging?.fee === false ? ( - {t('roadtrip.poi.free')} - ) : null} + + {poi.alongKm < 0.5 + ? t('roadtrip.poi.atStart') + : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} + + {/* What the charger offers, where OSM says. Socket names are proper nouns and + stay as they are; the numbers around them are what decides whether a car can + use it at all. A station that says nothing shows nothing rather than a row + of dashes, because "not stated" is not "no". */} + {poi.charging?.sockets.length ? ( + + {poi.charging.sockets.slice(0, 3).map(s => ( + + {SOCKET_LABEL[s.type] ?? s.type} + {s.kw ? ` ${s.kw} kW` : ''} + {s.count && s.count > 1 ? ` ×${s.count}` : ''} + + ))} + + ) : null} + {poi.charging?.fee === false ? ( + {t('roadtrip.poi.free')} + ) : null} +
    -
    + {/* Always there, quiet until the row is under the pointer: a button that only exists on hover is one a keyboard user has to find by faith. */} {onAdd ? (() => { @@ -168,12 +200,13 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re } /** One category's hits, so a mixed search reads as several short lists instead of one long one. */ -function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { +function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusPoint }: { category: string pois: CorridorPoi[] dayId: number | null insertIndexFor: RoadtripCorridor['insertIndexFor'] onAddPoi: RoadtripCorridorPanelProps['onAddPoi'] + onFocusPoint: RoadtripCorridorPanelProps['onFocusPoint'] }): React.ReactElement { const { t } = useTranslation() const meta = CATEGORY_META[category] @@ -232,6 +265,7 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { poi={poi} // Added where it will be driven past, not at the end of the day. onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined} + onFocus={onFocusPoint ? () => onFocusPoint(poi.lat, poi.lng) : undefined} /> ))} @@ -253,10 +287,20 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { * header rather than among the filters — it is the question's subject, not one of its * conditions. */ -export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, tripId, canImport }: RoadtripCorridorPanelProps): React.ReactElement { +export default function RoadtripCorridorPanel({ + corridor, routes, onAddPoi, onFocusPoint, onAddManual, manualStopTargetFor, tripId, canImport, +}: RoadtripCorridorPanelProps): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const { search } = corridor + /** + * Whether the manual dialog is up. + * + * Local, and the dialog is mounted rather than merely hidden: it is a way of adding + * one stop, not a setting, and unmounting it on close is what stops a half-typed + * search from being there the next time somebody opens it. + */ + const [manualOpen, setManualOpen] = useState(false) const dayOptions = routes.days.map(d => ({ value: String(d.dayId), @@ -371,26 +415,51 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, trip
    - {/* One word, so it can be read at the size a primary action deserves. The panel - it sits in is headed "along the route" and every control above it narrows the - same search; repeating that on the button only made it small. + {/* One word each, so both can be read at the size an action deserves. The panel + they sit in is headed "along the route" and every control above them narrows + the same search; repeating that on a button only made it small. - Its height is the controls' height, not a size of its own. At 38px it stood a - head above the segmented rows it follows and read as a second panel rather - than as the end of this one. */} - + Their height is the controls' height, not a size of their own. At 38px the + search stood a head above the segmented rows it follows and read as a second + panel rather than as the end of this one. + + Two halves of one row, because the search finds most of what is out there and + not all of it: a good share of the chargers standing at a junction are in + nobody's OpenStreetMap extract. The second button is the way to those, and it + sits beside the search rather than under it so neither is the afterthought. */} +
    + + {/* The day is part of the offer: with no drive on the trip at all there is + nowhere for a stop to go, and a dialog that can only be cancelled is worse + than a button that waited. */} + {onAddPoi && onAddManual && corridor.day ? ( + + ) : null} +
    {/* How the run is getting on — a row of its own while it lasts, with the share @@ -439,6 +508,25 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, trip appears once there is something to narrow. */} {search.results.length > 0 ? (
    + {/* What was found, and the way back out of it. A search that turned up the + wrong thing left its hits here and its pins on the map with no way to + dismiss them: the only way out was switching to the day plan and back. + Here rather than beside the search button, because throwing an answer + away is only ever meaningful once there is one. */} +
    + + {t('roadtrip.poi.found', { count: search.results.length })} + + +
    ))} )}
    + + {manualOpen && onAddManual ? ( + setManualOpen(false)} + // Closed before the handover: what opens next is the stop popup, and two + // dialogs over each other is not a choice anybody made. + onSubmit={(place, target) => { setManualOpen(false); onAddManual(place, target) }} + /> + ) : null} ) } diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx new file mode 100644 index 000000000..41c548c6a --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx @@ -0,0 +1,287 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { TranslationProvider } from '../../i18n' +import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' + +const { search } = vi.hoisted(() => ({ search: vi.fn() })) +// Only the place search is replaced; the rest of the module is what the stores around +// this dialog import, and cutting it down breaks them rather than isolating anything. +vi.mock('../../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + mapsApi: { search }, +})) +// The hint is the trip's own doing and has its own suite; here it only has to arrive. +vi.mock('../../hooks/useLocationBias', () => ({ + useLocationBias: () => ({ point: { lat: 53, lng: 10, radius: 5000 } }), +})) + +import RoadtripManualStopModal from './RoadtripManualStopModal' + +/** The debounce the dialog waits out before it asks anything. */ +const DEBOUNCE = 320 + +const stop = (id: number, name: string, ownerDayId: number, ownerIndex: number): RoadtripStop => ({ + assignmentId: id, + ownerDayId, + ownerIndex, + placeId: id * 10, + name, + lat: 53.5, + lng: 9.9, + time: null, + dwellMinutes: null, + legMode: null, + incomingLegMode: null, + stopType: null, +}) + +const day = (dayId: number, dayNumber: number, names: string[], routed = true): RoadtripDay => ({ + dayId, + dayNumber, + date: null, + title: null, + stops: names.map((name, i) => stop(dayId * 10 + i, name, dayId, i)), + legs: [], + schedule: { entries: [], warnings: [] }, + legVias: [], driveWarnings: [], dayWarning: null, + geometry: (routed ? [[53.55, 9.99], [52.52, 13.4]] : []) as [number, number][], + distance: 0, + duration: 0, +}) + +const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ + days, + lines: [], lineDays: [], + accessLines: [], + vias: [], + segments: [], + totalDistance: 0, + totalDuration: 0, + totalStops: 0, + quietDays: [], + loading: false, +}) + +/** Two routed days, four stops, three legs: each leg named differently. */ +const threeLegs = () => routes([ + day(1, 1, ['Hamburg', 'Berlin', 'Dresden']), + day(2, 2, ['Munich', 'Salzburg']), +]) + +const hit = (over: Record = {}) => ({ + name: 'Supercharger Rasthof', + address: 'A1, Dammer Berge', + lat: 52.5, + lng: 8.2, + osm_id: 'node/9', + ...over, +}) + +function setup(over: Partial> = {}) { + const onSubmit = vi.fn() + const onClose = vi.fn() + const targetFor = vi.fn(() => ({ dayId: 1, position: 2, offRouteKm: 0.4 })) + render( + + + , + ) + return { onSubmit, onClose, targetFor } +} + +/** The place box, by the placeholder every place search in TREK carries. */ +const queryBox = () => screen.getByLabelText('Search places...') + +/** Type, then wait out the debounce and whatever the answer resolves into. */ +async function type(value: string, wait = DEBOUNCE): Promise { + fireEvent.change(queryBox(), { target: { value } }) + await act(async () => { vi.advanceTimersByTime(wait) }) +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + search.mockReset() + search.mockResolvedValue({ places: [hit()], source: 'openstreetmap' }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('RoadtripManualStopModal', () => { + it('FE-ROADTRIP-MANUAL-001: two letters are not a question worth asking', async () => { + // A single common word without a region is refused upstream as too expensive to + // answer, and asking anyway spends a request to be told so. + setup() + await type('Su', DEBOUNCE * 2) + + expect(search).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-MANUAL-002: the search waits for a pause in the typing', async () => { + setup() + + fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) + await act(async () => { vi.advanceTimersByTime(DEBOUNCE - 120) }) + expect(search).not.toHaveBeenCalled() + + await act(async () => { vi.advanceTimersByTime(120) }) + expect(search).toHaveBeenCalledTimes(1) + // The query, the locale, and the hint that decides which of a thousand identically + // named places is meant. + expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.objectContaining({ lat: 53, lng: 10 })) + }) + + it('FE-ROADTRIP-MANUAL-003: keystroke after keystroke is one question, not one each', async () => { + setup() + + fireEvent.change(queryBox(), { target: { value: 'Sup' } }) + await act(async () => { vi.advanceTimersByTime(100) }) + fireEvent.change(queryBox(), { target: { value: 'Super' } }) + await act(async () => { vi.advanceTimersByTime(100) }) + fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) + await act(async () => { vi.advanceTimersByTime(DEBOUNCE) }) + + expect(search).toHaveBeenCalledTimes(1) + expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.anything()) + }) + + it('FE-ROADTRIP-MANUAL-004: a row the map could not place is never offered', async () => { + // It cannot become a stop, and offering it only to refuse the click is a dead end + // with nothing to explain it. + search.mockResolvedValue({ + places: [ + // Nothing at all where a coordinate should be. `Number(null)` is 0, and 0/0 is a + // real place off the coast of Africa. + hit({ name: 'Nowhere', lat: null, lng: null }), + hit({ name: 'Nonsense', lat: 'somewhere', lng: 'thereabouts' }), + // A row with neither a name nor an address has nothing to show in a list. + hit({ name: null, address: null }), + hit(), + ], + source: 'openstreetmap', + }) + setup() + await type('Supercharger') + + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + expect(screen.queryByText('Nowhere')).not.toBeInTheDocument() + expect(screen.queryByText('Nonsense')).not.toBeInTheDocument() + expect(screen.getAllByText('A1, Dammer Berge')).toHaveLength(1) + }) + + it('FE-ROADTRIP-MANUAL-005: picking one takes the leg the projection worked out', async () => { + const { onSubmit, targetFor } = setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // Measured by the planner's own projection, which is also what places a via. + expect(targetFor).toHaveBeenCalledWith(52.5, 8.2) + // Day 1, position 2: the leg between its second and third stop. + expect(screen.getByText('Halfway, Berlin to Dresden')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Supercharger Rasthof', lat: 52.5, lng: 8.2, osm_id: 'node/9' }), + { dayId: 1, position: 2, offRouteKm: 0.4 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-006: the leg it offers can be overruled, and the override is what lands', async () => { + // A drive that passes the same junction twice can only be guessed at once, and the + // traveller is the one who knows which time they mean to stop. + const { onSubmit } = setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // CustomSelect renders its options into a portal, so the trigger comes first. + fireEvent.click(screen.getByText('Halfway, Berlin to Dresden')) + fireEvent.click(screen.getByText('Halfway, Munich to Salzburg')) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Supercharger Rasthof' }), + expect.objectContaining({ dayId: 2, position: 1 }), + ) + }) + + it('FE-ROADTRIP-MANUAL-007: a place well off the road is accepted, and said so rather than refused', async () => { + // The whole point of the dialog is the charger the corridor search missed, which is + // exactly the one sitting further off the line than a via would be allowed. + const targetFor = vi.fn(() => ({ dayId: 2, position: 1, offRouteKm: 12.4 })) + const { onSubmit } = setup({ targetFor }) + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // A note, not a refusal: found by its tone rather than its wording, which is a + // translation and not what this test is about. + expect(document.querySelector('p.text-warning')).toBeInTheDocument() + + const add = screen.getByRole('button', { name: 'Add' }) + expect(add).toBeEnabled() + fireEvent.click(add) + expect(onSubmit).toHaveBeenCalledWith( + expect.anything(), + { dayId: 2, position: 1, offRouteKm: 12.4 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-008: a place on the road is not flagged for being there', async () => { + setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + expect(document.querySelector('p.text-warning')).toBeNull() + }) + + it('FE-ROADTRIP-MANUAL-009: with nothing routed it goes at the end of the day, and names no leg', async () => { + const unrouted = routes([day(5, 3, ['Hamburg', 'Berlin'], false)]) + const targetFor = vi.fn(() => null) + const { onSubmit } = setup({ routes: unrouted, dayId: 5, targetFor }) + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // No drive means no order, and a leg offered anyway would be an invention. + expect(screen.queryByText(/Halfway,/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.anything(), + { dayId: 5, position: 2, offRouteKm: 0 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-010: nothing can be added until a place has been picked', async () => { + const { onSubmit, onClose } = setup() + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-MANUAL-011: a search that fails says nothing was found rather than nothing at all', async () => { + search.mockRejectedValue(new Error('offline')) + setup() + await type('Supercharger') + + // The rejection is swallowed on purpose (it is a typeahead, not a submit), but the + // list has to stop saying it is loading. + await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx new file mode 100644 index 000000000..7e1365eeb --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx @@ -0,0 +1,392 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MapPin, Search, X } from 'lucide-react' +import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n/TranslationContext' +import { useLocationBias } from '../../hooks/useLocationBias' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance } from '../../utils/units' +import { FS } from './typeScale' +import type { ManualStopPlace, ManualStopTarget } from './manualStop' +import type { RoadtripRoutes } from './useRoadtripRoutes' + +interface RoadtripManualStopModalProps { + routes: RoadtripRoutes + /** The day the panel is looking at: where the stop goes while nothing has routed. */ + dayId: number | null + /** Where a freely chosen point belongs on the drive, from the planner's own projection. */ + targetFor?: (lat: number, lng: number) => ManualStopTarget | null + onClose: () => void + /** Hands the place and its position on, so the stop popup asks the kind and the dwell. */ + onSubmit: (place: ManualStopPlace, target: ManualStopTarget) => void +} + +/** How long after the last keystroke the search runs. */ +const DEBOUNCE_MS = 320 +/** Below this, a query matches half a country and the index refuses it as too broad. */ +const MIN_QUERY = 3 +/** + * Past this far from the road, the projected leg is worth a second look, in kilometres. + * + * The same figure `addRoadtripVia` refuses a click at, and here it only prompts, which + * is the whole difference between placing a via and adding a stop the search missed. + */ +const OFF_ROUTE_NOTE_KM = 2 + +/** + * One row of the place search. + * + * The endpoint answers with provider-shaped records (Google and the OSM layer carry + * different fields), so the contract keeps them as open records and each caller reads + * what it needs. Narrowed once in `toHit` below rather than carried around untyped. + */ +interface PlaceHit { + name: string + address: string | null + lat: number + lng: number + osm_id: string + website: string | null + phone: string | null + category: string | null +} + +const text = (value: unknown): string | null => + typeof value === 'string' && value.trim() ? value : null + +/** + * A search row, or nothing at all. + * + * A row without usable coordinates is dropped here instead of at the click: it cannot + * become a stop, and offering it only to refuse it is a dead end with no explanation. + */ +function toHit(row: Record): PlaceHit | null { + // `Number(null)` is 0, and 0/0 is a real coordinate in the Gulf of Guinea: a row with + // no location at all would otherwise become a stop off the coast of Africa. + if (row.lat == null || row.lng == null || row.lat === '' || row.lng === '') return null + const lat = Number(row.lat) + const lng = Number(row.lng) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null + const address = text(row.address) + const name = text(row.name) ?? address + if (!name) return null + return { + name, + address, + lat, + lng, + // Both providers, in the order the rest of the client reads them. + osm_id: text(row.osm_id) ?? text(row.google_place_id) ?? '', + website: text(row.website), + phone: text(row.phone), + category: text(row.category), + } +} + +/** + * Adding a stop the corridor search never found. + * + * The search reads OpenStreetMap, and a good share of the chargers actually standing at + * a motorway junction are not in it, most Tesla Superchargers among them. The way round + * it was to leave road trip mode, add the place under Days, drag it onto the right day, + * come back and mark it a charging stop. + * + * So this asks the two questions that answer for those four steps (which place, and + * where on the drive) and then hands over to the same popup a corridor hit goes through. + * What kind of stop it is and how long it takes are decided there, once, for both paths. + */ +export default function RoadtripManualStopModal({ + routes, dayId, targetFor, onClose, onSubmit, +}: RoadtripManualStopModalProps): React.ReactElement { + const { t, locale } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // Without a trip context the hint is empty and the search runs as it always has. + const { point: locationBias } = useLocationBias() + + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [listOpen, setListOpen] = useState(false) + const [highlight, setHighlight] = useState(-1) + const [loading, setLoading] = useState(false) + const [answered, setAnswered] = useState(false) + const [picked, setPicked] = useState(null) + const [chosenLeg, setChosenLeg] = useState('') + const [offRouteKm, setOffRouteKm] = useState(null) + + const wrapRef = useRef(null) + const debounceRef = useRef | null>(null) + + useEffect(() => { + if (!listOpen) return + const onPointer = (e: MouseEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) setListOpen(false) + } + document.addEventListener('mousedown', onPointer) + return () => document.removeEventListener('mousedown', onPointer) + }, [listOpen]) + + // Debounced, and with a cancelled flag rather than a bare `.then(setState)`: the + // endpoint takes no signal, so the only way a stale answer cannot overwrite a newer + // one is for the run it belongs to to know it has been superseded. + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + const trimmed = query.trim() + if (trimmed.length < MIN_QUERY || trimmed === picked?.name) { + setResults([]) + setAnswered(false) + return + } + let cancelled = false + debounceRef.current = setTimeout(() => { + setLoading(true) + mapsApi.search(trimmed, locale, locationBias) + .then(data => { + if (cancelled) return + setResults(data.places.map(toHit).filter((hit): hit is PlaceHit => hit !== null)) + setHighlight(-1) + setAnswered(true) + }) + .catch(() => { + if (cancelled) return + setResults([]) + setAnswered(true) + }) + .finally(() => { if (!cancelled) setLoading(false) }) + }, DEBOUNCE_MS) + return () => { + cancelled = true + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [query, picked, locale, locationBias]) + + /** + * Every leg of every routed day, flat. + * + * Flat rather than per day because a stop belongs to the drive and not to whichever + * card the panel happens to be showing: the charger the search missed is as likely to + * be on tomorrow's stretch as on today's. The labels are the section picker's own: + * the day carries the number, the leg names the two stops it sits between. + */ + const legs = useMemo(() => { + const out: (ManualStopTarget & { value: string; label: string; badge: string })[] = [] + for (const day of routes.days) { + // A day that has not routed has no order to place anything in. + if (day.geometry.length < 2) continue + for (let i = 0; i < day.stops.length - 1; i++) { + out.push({ + value: `${day.dayId}:${i}`, + label: t('roadtrip.poi.midLeg', { + from: day.stops[i]?.name ?? '', + to: day.stops[i + 1]?.name ?? '', + }), + badge: t('roadtrip.day', { number: day.dayNumber }), + dayId: day.dayId, + position: i + 1, + offRouteKm: 0, + }) + } + } + return out + }, [routes.days, t]) + + /** + * Where it goes while the trip has no drawn route yet: the end of the day the panel is + * on. No leg is named, because there is no order to name one in. + */ + const appendTarget = useMemo(() => { + if (legs.length > 0 || dayId == null) return null + const day = routes.days.find(d => d.dayId === dayId) + return { dayId, position: day?.stops.length ?? 0, offRouteKm: 0 } + }, [legs.length, dayId, routes.days]) + + const appendDayNumber = routes.days.find(d => d.dayId === dayId)?.dayNumber ?? 0 + + const pick = useCallback((hit: PlaceHit) => { + setPicked(hit) + setQuery(hit.name) + setListOpen(false) + setResults([]) + const found = targetFor?.(hit.lat, hit.lng) ?? null + setOffRouteKm(found?.offRouteKm ?? null) + // The projection is the DEFAULT and never a gate. The charger this dialog exists for + // is exactly the one sitting a little too far off the line, so a distance that would + // refuse a via is accepted here and only preselects the leg it landed nearest. + const match = found && legs.find(l => l.dayId === found.dayId && l.position === found.position) + // Nothing matching falls back to the first leg, the way an unchosen one already does. + setChosenLeg(match ? match.value : '') + }, [targetFor, legs]) + + const clearPick = useCallback(() => { + setPicked(null) + setQuery('') + setResults([]) + setAnswered(false) + setOffRouteKm(null) + setChosenLeg('') + }, []) + + /** + * The leg in force: the one chosen, or the first on offer. + * + * Derived rather than seeded into state, so the control always shows a real answer. + * A dropdown sitting empty under a heading that says "add between" asks a question it + * has already been given the answer to. + */ + const leg = legs.some(l => l.value === chosenLeg) ? chosenLeg : (legs[0]?.value ?? '') + const target = legs.find(l => l.value === leg) ?? appendTarget + + const submit = (): void => { + if (!picked || !target) return + onSubmit({ + name: picked.name, + lat: picked.lat, + lng: picked.lng, + address: picked.address, + website: picked.website, + phone: picked.phone, + osm_id: picked.osm_id, + category: picked.category, + }, { dayId: target.dayId, position: target.position, offRouteKm: offRouteKm ?? 0 }) + } + + const onKey = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape' && listOpen) { + // Kept from closing the dialog underneath: the list is what Escape was aimed at. + e.stopPropagation() + setListOpen(false) + return + } + if (!listOpen || results.length === 0) return + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) } + else if (e.key === 'Enter' && highlight >= 0) { + e.preventDefault() + const hit = results[highlight] + if (hit) pick(hit) + } + } + + return ( + + + + + )} + > +
    +

    {t('roadtrip.poi.manualHint')}

    + +
    + + { + setQuery(e.target.value) + setListOpen(true) + if (picked) { setPicked(null); setOffRouteKm(null) } + }} + onFocus={() => setListOpen(true)} + onKeyDown={onKey} + // The dialog exists to be typed into; anything else here is a second step. + autoFocus + placeholder={t('places.search')} + aria-label={t('places.search')} + className="h-[36px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-body text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent" + /> + {query ? ( + + ) : null} + + {listOpen && (loading || results.length > 0 || answered) ? ( +
    + {loading && results.length === 0 ? ( +

    {t('common.loading')}

    + ) : null} + {!loading && results.length === 0 && answered ? ( +

    {t('roadtrip.poi.manualNoResults')}

    + ) : null} + {results.map((hit, i) => ( + + ))} +
    + ) : null} +
    + + {/* Where it goes. Preselected from the projection, and changeable: a drive that + passes the same junction twice can only be guessed at once, and the traveller + is the one who knows which time they mean to stop. + + Absent altogether when there is nowhere to put anything, rather than an empty + control: a trip whose days hold fewer than two stops has no drive yet. */} + {legs.length > 0 || appendTarget ? ( +
    + + {t('roadtrip.poi.addBetween')} + + {legs.length > 0 ? ( + setChosenLeg(String(value))} + options={legs.map(l => ({ value: l.value, label: l.label, badge: l.badge }))} + size="sm" + menuFit="content" + /> + ) : ( +

    + {t('roadtrip.poi.manualAppend', { number: appendDayNumber })} +

    + )} + {/* Said quietly rather than refused. It is the reason the leg above may be the + wrong one, and the only way the reader can know to change it. */} + {picked && offRouteKm !== null && offRouteKm > OFF_ROUTE_NOTE_KM ? ( +

    + {t('roadtrip.poi.manualOffRoute', { distance: formatDistance(offRouteKm, distanceUnit) })} +

    + ) : null} +
    + ) : null} +
    +
    + ) +} diff --git a/client/src/components/Roadtrip/manualStop.ts b/client/src/components/Roadtrip/manualStop.ts new file mode 100644 index 000000000..b3b2d8bd1 --- /dev/null +++ b/client/src/components/Roadtrip/manualStop.ts @@ -0,0 +1,30 @@ +/** + * What the manual-add dialog and the planner agree on. + * + * Their own module rather than the dialog's, so the planner hook can take the shapes + * without importing a component: the file stays free of React and of the api client, the + * way `roadtripModel` and `corridor` do. + */ + +/** Where a stop added by hand goes: a card of the rail, and the position in its chain. */ +export interface ManualStopTarget { + dayId: number + /** Counted along the card's stops, the same index space a corridor hit is placed at. */ + position: number + /** How far the place sits from the road being driven; zero when nothing has routed. */ + offRouteKm: number +} + +/** A place chosen by hand, in the shape the stop popup and the place write want. */ +export interface ManualStopPlace { + name: string + lat: number + lng: number + address: string | null + website: string | null + phone: string | null + /** Empty for a provider with no id of its own, which is what the rest of TREK does. */ + osm_id: string + /** The kind the popup starts on, when the provider named one. */ + category: string | null +} diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts index 6ddf8dcd4..85b4fd522 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts @@ -3,8 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { CorridorPoi, CorridorSearch } from './useCorridorPois' import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes' -const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() })) +const { useCorridorPois, stored } = vi.hoisted(() => ({ + useCorridorPois: vi.fn(), + // The stored road trip preferences, read through the same selector the app uses so + // `useVehicleRange` does its own folding of the four vehicle states. + stored: { settings: {} as { roadtrip_vehicle?: string } }, +})) vi.mock('./useCorridorPois', () => ({ useCorridorPois })) +vi.mock('../../hooks/useRoadtripSettings', () => ({ + useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings), +})) import { useRoadtripCorridor } from './useRoadtripCorridor' @@ -54,6 +62,7 @@ function searchWith(results: CorridorPoi[]): CorridorSearch { beforeEach(() => { useCorridorPois.mockReset() + stored.settings = {} }) describe('useRoadtripCorridor', () => { @@ -160,6 +169,85 @@ describe('useRoadtripCorridor', () => { expect(result.current.visible.map(p => p.osm_id)).toEqual(['near']) }) + it('FE-ROADTRIP-CORRIDORSTATE-030: clearing drops the hits and every narrowing set on them', () => { + // Nothing could do this before: a search that turned up the wrong thing kept its + // list and its pins, and the way out was switching to the day plan and back. + const state = searchWith([poi({ osm_id: 'a', name: 'Aral' })]) + useCorridorPois.mockReturnValue(state) + const { result } = renderHook(() => useRoadtripCorridor(routes([day()]))) + + act(() => { + result.current.setNameFilter('aral') + result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) + result.current.setSocketFilter('ccs') + result.current.setMinKw(150) + }) + act(() => { result.current.clear() }) + + expect(state.clear).toHaveBeenCalledTimes(1) + expect(result.current.nameFilter).toBe('') + expect(result.current.section).toBeNull() + expect(result.current.socketFilter).toBe('') + expect(result.current.minKw).toBe(0) + // The question stays: clearing the categories too would make asking the same thing + // again a handful of clicks. + expect(result.current.categories).toEqual(['fuel']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-031: an electric car looks for chargers instead of pumps', () => { + useCorridorPois.mockReturnValue(searchWith([])) + stored.settings = { roadtrip_vehicle: 'electric' } + + const { result } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + + expect(result.current.categories).toEqual(['charging']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-032: every other vehicle state keeps the fuel it always had', () => { + // Four states are stored: electric, combustion, an empty string written by the + // dialog, and no answer at all. `useVehicleRange` folds the last two into null, and + // moving the unset case would change the panel for every traveller who never opened + // the driving settings. + useCorridorPois.mockReturnValue(searchWith([])) + for (const roadtrip_vehicle of ['combustion', '', undefined]) { + stored.settings = roadtrip_vehicle === undefined ? {} : { roadtrip_vehicle } + const { result, unmount } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + expect(result.current.categories).toEqual(['fuel']) + unmount() + } + }) + + it('FE-ROADTRIP-CORRIDORSTATE-033: a kind the traveller picked is never overruled by the preference', () => { + // The preference is loaded per trip and lands a moment after the panel does. A + // category somebody switched off coming back by itself makes the picker unusable. + useCorridorPois.mockReturnValue(searchWith([])) + const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + + act(() => { result.current.toggleCategory('rest_area') }) + stored.settings = { roadtrip_vehicle: 'electric' } + rerender() + + expect(result.current.categories).toEqual(['fuel', 'rest_area']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-034: a preference arriving over a search does not empty it', () => { + // `useCorridorPois` drops its results whenever the categories change, so a late + // preference must not reach either a run in flight or a list already answered. + useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true }) + const running = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + stored.settings = { roadtrip_vehicle: 'electric' } + running.rerender() + expect(running.result.current.categories).toEqual(['fuel']) + running.unmount() + + stored.settings = {} + useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })])) + const answered = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + stored.settings = { roadtrip_vehicle: 'electric' } + answered.rerender() + expect(answered.result.current.categories).toEqual(['fuel']) + }) + it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => { // The section names a day as well as an index, so switching days cannot silently // apply yesterday's break to today's drive. diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.ts b/client/src/components/Roadtrip/useRoadtripCorridor.ts index 2ae2290f9..31e6549c9 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.ts @@ -3,6 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor import { sectionAnchors, insertIndexForAlong } from './roadtripModel' import type { SectionAnchor } from './roadtripModel' import { projectOntoRoute, type LatLng } from './corridor' +import { useVehicleRange } from './useVehicleRange' import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes' // The categories come from the one table now, which is also the only place that knows @@ -54,6 +55,16 @@ export interface RoadtripCorridor { insertIndexFor: (poi: CorridorPoi) => number /** How far along the drive each of the day's stops sits, in the same units as a hit. */ stopsAlongKm: number[] + /** + * Back to "nothing asked yet": the hits go, and with them the pins on the map. + * + * Both halves, deliberately. `search.clear()` alone leaves the four narrowing states + * standing, so a plug type or a minimum power set for the last question would quietly + * shrink the answer to the next one. The categories and the corridor width stay put: + * they are the question rather than the answer, and clearing them would turn asking + * the same thing again into a handful of clicks. + */ + clear: () => void } /** @@ -63,7 +74,7 @@ export interface RoadtripCorridor { * answer if you cannot see which one is on your side of the road — so the results have to * live above both the panel and the map rather than inside the panel. */ -export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { +export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | string | null): RoadtripCorridor { const [dayId, setDayId] = useState('') const [categories, setCategories] = useState(['fuel']) const [widthKm, setWidthKm] = useState(5) @@ -100,10 +111,42 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { const search = useCorridorPois(line, categories, widthKm) + /** + * Whether what the panel looks for has been decided yet. + * + * The vehicle is a stored, trip-scoped preference, so it is not known at first render; + * the default below therefore cannot be a `useState` initialiser. Once it has been + * applied (or once the traveller has picked the kinds themselves) it is never + * applied again: a category somebody switched off on purpose coming back by itself + * would make the picker unusable. + */ + const categoriesSeeded = useRef(false) + const toggleCategory = useCallback((key: string) => { + categoriesSeeded.current = true setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])) }, []) + /** + * An electric car looks for chargers, not for pumps. + * + * Only for `electric`. The preference has four stored states and `useVehicleRange` + * folds three of them into null (a combustion sibling written as an empty string, no + * answer at all, and the moment before the preference has loaded), and none of those + * is a reason to change what a panel looks for. Every traveller who never opened the + * driving settings keeps the fuel they have always had. + */ + const { vehicleKind } = useVehicleRange(tripId) + useEffect(() => { + if (categoriesSeeded.current || vehicleKind !== 'electric') return + categoriesSeeded.current = true + // Never over a running or finished search. `useCorridorPois` drops its results + // whenever the categories change, so a preference that lands a second after the + // panel did would empty a list somebody is already reading. + if (search.loading || search.results.length > 0) return + setCategories(['charging']) + }, [vehicleKind, search.loading, search.results.length]) + // A filter kept across a new search would hide the fresh answer behind the old question. // Cleared when a search STARTS, not whenever results change: hits arrive box by box and // publish as they land, so resetting on every batch wiped out anything typed while the @@ -188,6 +231,29 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { [stopsAlongKm], ) + /** + * Throwing the answer away, which nothing could do before. + * + * A search that turned up the wrong thing left its hits in the list and its pins on + * the map, and the only way out was switching to the day plan and back. The search's + * own `clear` aborts what is in flight and empties the results (`visible` collapses + * with them and the map re-merges, so no layer needs telling), and the four narrowing + * states go too, because they were set for a question that no longer has an answer. + * + * Pulled out of `search` into its own name rather than depended on as `search.clear`: + * the object is rebuilt on every publish of the hook below, so a dependency on it + * would rebuild this callback on every box that answers, while the function itself + * never changes. + */ + const clearResults = search.clear + const clear = useCallback(() => { + clearResults() + setNameFilter('') + setSection(null) + setSocketFilter('') + setMinKw(0) + }, [clearResults]) + return { dayId: day ? String(day.dayId) : '', setDayId, @@ -211,5 +277,6 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { visible, insertIndexFor, stopsAlongKm, + clear, } } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 769cdef12..b6434fd53 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -277,6 +277,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopFill, saveRoadtripLimit, roadtripSettingsLoading, storedAssignments, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, + manualStopTargetFor, addManualRoadtripStop, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, @@ -730,6 +731,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { corridor={roadtripCorridor} routes={roadtripRoutes} onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined} + onAddManual={can('place_edit', trip) ? addManualRoadtripStop : undefined} + manualStopTargetFor={manualStopTargetFor} + onFocusPoint={focusRoadtripPoint} /> {/* Under the search, because the limits are read while looking at what the drive is doing rather than set up front. */} diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 833343af6..f1ed10d0f 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -17,6 +17,7 @@ import { } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' import { dayColor } from '../../components/Roadtrip/dayColors' +import type { ManualStopPlace } from '../../components/Roadtrip/manualStop' /** * The road trip half of the planner hook. @@ -134,6 +135,7 @@ const rt = vi.hoisted(() => { visible: [] as Array>, insertIndexFor: vi.fn(() => 1), stopsAlongKm: [] as number[], + clear: vi.fn(), } const alt = { open: null as null | Record, @@ -1262,6 +1264,108 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => { }) }) +describe('useTripPlanner road trip: a stop added by hand', () => { + /** One routed day of two stops, and a second day beside it. */ + const routedDay = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }] + } + + const place = (over: Partial = {}): ManualStopPlace => ({ + name: 'Supercharger Dammer Berge', + lat: 52.9, + lng: 11.4, + address: 'A1', + website: null, + phone: null, + osm_id: 'node/9', + category: null, + ...over, + }) + + it('FE-TP-ROAD-075: a hand-picked place is measured onto the drive it is nearest', async () => { + routedDay() + const { result } = await renderRoadtrip() + + // Named by the card the stops are DRAWN on and the position in it, which is the + // index space a stop is placed at, not the one a via is stored in. + expect(result.current.manualStopTargetFor(53.0, 11.5)).toMatchObject({ dayId: 5, position: 1 }) + }) + + it('FE-TP-ROAD-076: a place well off the road is placed all the same', async () => { + // A via is refused past two kilometres and stays refused. The stop this answers for + // is the charger the corridor search missed, which is exactly the one sitting + // further off the drawn line than that: refusing it would refuse the request. + routedDay() + const { result } = await renderRoadtrip() + + const target = result.current.manualStopTargetFor(41.9, 12.5) + expect(target?.offRouteKm).toBeGreaterThan(2) + expect(target).toMatchObject({ dayId: 5 }) + + await act(async () => { await result.current.addRoadtripVia(41.9, 12.5) }) + expect(rt.vias.add).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-077: a day with no drawn line is nowhere to measure against', async () => { + routedDay() + rt.routes.days = [{ dayId: 5, dayNumber: 1, stops: [], geometry: [] }] + const { result } = await renderRoadtrip() + + expect(result.current.manualStopTargetFor(53.0, 11.5)).toBeNull() + }) + + it('FE-TP-ROAD-078: adding one by hand opens the popup a corridor hit goes through', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 3.2 }) + }) + + expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 1, dayNumber: 1 }) + expect(result.current.stopDraft?.poi).toMatchObject({ + name: 'Supercharger Dammer Berge', osm_id: 'node/9', offRouteKm: 3.2, alongKm: 0, + }) + // Neither the full form nor a write: the kind of stop and the dwell are still to be + // chosen, in the one dialog that asks them. + expect(result.current.showPlaceForm).toBe(false) + expect(actions.addPlace).not.toHaveBeenCalled() + // A charger is not somewhere to sleep. + expect(result.current.stopDraft?.overnight).toBeUndefined() + }) + + it('FE-TP-ROAD-079: somewhere to sleep opens in overnight mode, the same as a corridor hit', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place({ category: 'hotel' }), { dayId: 5, position: 1, offRouteKm: 0.2 }) + }) + + // The night ends on the next day of the trip, which is what a night usually means. + expect(result.current.stopDraft?.overnight?.defaultEndDayId).toBe(6) + }) + + it('FE-TP-ROAD-080: a reader may not add one by hand either', async () => { + asReader('place_edit') + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 0 }) + }) + + expect(result.current.stopDraft).toBeNull() + }) +}) + /** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */ const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({ placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index fd8c498d5..f5254e560 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -48,6 +48,7 @@ import { reanchorAfterRemove, reanchorByStopOrder, isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' +import type { ManualStopPlace, ManualStopTarget } from '../../components/Roadtrip/manualStop' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -646,7 +647,9 @@ export function useTripPlanner() { tripAccommodations, ) // Lives here rather than in the panel because the map draws what it finds. - const roadtripCorridor = useRoadtripCorridor(roadtripRoutes) + // The trip comes with it for the vehicle: an electric car looks for chargers rather + // than for pumps, and that preference is stored per trip. + const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId) // Applying a track is a long job — a routing round trip per refinement — so it lives // above the dialog: a component that unmounted halfway would leave the day holding // half a chain of vias. @@ -1493,6 +1496,80 @@ export function useTripPlanner() { return best }, [roadtripRoutes.days]) + /** + * Where a place chosen by hand belongs in the drive, as a card and a position in it. + * + * `anchorFor` answers in the space a via is STORED in: the day the anchor stop belongs + * to, and its index there. A stop is placed at a position counted along the card it is + * drawn on, which is the same thing on every day that does not drive past midnight and + * a different one on the days that do (`nightSpill.ts`). Translating between the two + * happens here, once, rather than at whichever surface asked. + * + * Deliberately with no distance limit, unlike `addRoadtripVia`: the place this answers + * for is the charger the corridor search did not find, which is exactly the one sitting + * further off the drawn line than a via is allowed to be. The projection is the default + * the dialog offers, never a gate it applies. + */ + const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { + const anchor = anchorFor(lat, lng) + if (!anchor) return null + for (const day of roadtripRoutes.days) { + const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) + if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm } + } + // No card draws that stop, which happens while the rail is between rebuilds. Its own + // numbers are the best answer there is, and `roadtripInsertion` measures them against + // the card again when the stop actually lands. + return { dayId: anchor.dayId, position: anchor.afterIndex + 1, offRouteKm: anchor.offRouteKm } + }, [anchorFor, roadtripRoutes.days]) + + /** + * A place looked up by hand, on its way to the popup a corridor hit goes through. + * + * Not a write of its own. The popup is where the kind of stop and the dwell are + * decided, it defaults both from the category, and everything after it (the place, the + * assignment at the right position, the via re-anchoring, the re-route) is already + * right there and pinned by tests. The search finding a charger and somebody typing its + * name must not become two ways of adding one. + */ + const addManualRoadtripStop = useCallback((place: ManualStopPlace, target: ManualStopTarget) => { + if (!can('place_edit', trip)) return + const card = roadtripRoutes.days.find(day => day.dayId === target.dayId) + // Through `roadtripInsertion` like every other path into this popup, so a night drive + // files the stop under the day it is stored on rather than under the card it is drawn + // on. A day with nothing drawn yet has no card to measure against and keeps the + // position it was given. + const insert = (card && roadtripInsertion(card, target.position)) || { dayId: target.dayId, position: target.position } + const category = place.category ?? '' + setStopDraft({ + poi: { + osm_id: place.osm_id, + name: place.name, + lat: place.lat, + lng: place.lng, + category, + poi_type: category, + address: place.address, + website: place.website, + phone: place.phone, + opening_hours: null, + cuisine: null, + source: 'manual', + offRouteKm: target.offRouteKm, + // Nothing measured this one along the drive: its position came from the leg it + // was put on rather than from a distance. No arrival time is offered either, for + // the same reason: a guess printed as a clock reading is worse than a blank. + alongKm: 0, + }, + ...insert, + dayNumber: card?.dayNumber ?? days.find(day => day.id === insert.dayId)?.day_number ?? 0, + // Only for somewhere you could sleep, and the same branch the corridor path takes. + // A charger never is one; diverging here would be a second answer to a question + // that already has one. + ...(isOvernightCategory(category) ? { overnight: overnightOptions(insert.dayId) } : {}), + }) + }, [roadtripRoutes.days, days, overnightOptions, can, trip]) + const addRoadtripVia = useCallback(async (lat: number, lng: number) => { if (!can('day_edit', trip)) return const best = anchorFor(lat, lng) @@ -2267,6 +2344,7 @@ export function useTripPlanner() { roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed, saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries, + manualStopTargetFor, addManualRoadtripStop, refuel, askRefuel, acceptRefuel, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 1f3cdf9e2..fff52aed7 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -193,6 +193,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} عن المسار', 'roadtrip.poi.atStart': 'قرب نقطة البداية', 'roadtrip.poi.alongRoute': 'بعد {distance}', + 'roadtrip.poi.clearResults': 'مسح النتائج', + 'roadtrip.poi.addManual': 'إضافة يدوية', + 'roadtrip.poi.manualHint': + 'لا يجد البحث عن الأماكن كل محطة شحن أو وقود. ابحث عن واحدة بالاسم وحدّد موضعها في الرحلة.', + 'roadtrip.poi.addBetween': 'الإضافة بين محطتين', + 'roadtrip.poi.manualOffRoute': '{distance} عن المسار، لذا تحقق من المرحلة التي تناسبها.', + 'roadtrip.poi.manualAppend': 'لا يوجد مسار بعد، لذا تُضاف في نهاية اليوم {number}.', + 'roadtrip.poi.manualNoResults': 'لم يُعثر على أي مكان.', 'roadtrip.summary.distance': 'المسافة', 'roadtrip.summary.driving': 'مدة القيادة', 'roadtrip.alt.ask': 'مسارات أخرى', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index dc2e04542..d5ec484f9 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fora da rota', 'roadtrip.poi.atStart': 'perto do início', 'roadtrip.poi.alongRoute': 'após {distance}', + 'roadtrip.poi.clearResults': 'Limpar resultados', + 'roadtrip.poi.addManual': 'Adicionar manualmente', + 'roadtrip.poi.manualHint': + 'A busca de locais não encontra todos os pontos de recarga nem todos os postos. Procure um pelo nome e diga em que ponto do trajeto ele entra.', + 'roadtrip.poi.addBetween': 'Adicionar entre', + 'roadtrip.poi.manualOffRoute': '{distance} fora da rota, então confira a que trecho ele pertence.', + 'roadtrip.poi.manualAppend': 'Nenhuma rota foi calculada ainda, então ele entra no fim do dia {number}.', + 'roadtrip.poi.manualNoResults': 'Nenhum local encontrado.', 'roadtrip.summary.distance': 'Distância', 'roadtrip.summary.driving': 'Tempo de direção', 'roadtrip.alt.ask': 'Outras rotas', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index d97841fba..a8dab1538 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -200,6 +200,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fora de la ruta', 'roadtrip.poi.atStart': 'a prop de l’inici', 'roadtrip.poi.alongRoute': 'després de {distance}', + 'roadtrip.poi.clearResults': 'Esborra resultats', + 'roadtrip.poi.addManual': 'Afegeix manualment', + 'roadtrip.poi.manualHint': + 'La cerca de llocs no troba tots els punts de recàrrega ni totes les benzineres. Busca’n un pel nom i digues en quin punt del trajecte va.', + 'roadtrip.poi.addBetween': 'Afegeix entre', + 'roadtrip.poi.manualOffRoute': '{distance} fora de la ruta: comprova a quin trajecte pertany.', + 'roadtrip.poi.manualAppend': 'Encara no hi ha cap ruta calculada, així que va al final del dia {number}.', + 'roadtrip.poi.manualNoResults': 'No s’ha trobat cap lloc.', 'roadtrip.summary.distance': 'Distància', 'roadtrip.summary.driving': 'Temps de conducció', 'roadtrip.alt.ask': 'Altres rutes', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 12f597426..b6ff5b5e0 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} od trasy', 'roadtrip.poi.atStart': 'blízko startu', 'roadtrip.poi.alongRoute': 'po {distance}', + 'roadtrip.poi.clearResults': 'Vymazat výsledky', + 'roadtrip.poi.addManual': 'Přidat ručně', + 'roadtrip.poi.manualHint': + 'Vyhledávání míst nenajde každou nabíječku nebo pumpu. Najděte ji podle názvu a určete, kam na cestě patří.', + 'roadtrip.poi.addBetween': 'Přidat mezi', + 'roadtrip.poi.manualOffRoute': '{distance} od trasy, ověřte proto, do kterého úseku patří.', + 'roadtrip.poi.manualAppend': 'Zatím není spočítaná žádná trasa, přidá se proto na konec dne {number}.', + 'roadtrip.poi.manualNoResults': 'Žádná místa nenalezena.', 'roadtrip.summary.distance': 'Vzdálenost', 'roadtrip.summary.driving': 'Doba jízdy', 'roadtrip.alt.ask': 'Jiné trasy', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index d19f2fc89..9f8af3219 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -195,6 +195,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} abseits der Route', 'roadtrip.poi.atStart': 'am Startpunkt', 'roadtrip.poi.alongRoute': 'nach {distance}', + 'roadtrip.poi.clearResults': 'Ergebnisse löschen', + 'roadtrip.poi.addManual': 'Selbst hinzufügen', + 'roadtrip.poi.manualHint': + 'Die Ortssuche findet nicht jede Ladesäule und nicht jede Tankstelle. Suche einen Ort per Name und lege fest, wo unterwegs er hingehört.', + 'roadtrip.poi.addBetween': 'Einfügen zwischen', + 'roadtrip.poi.manualOffRoute': '{distance} abseits der Route, prüfe also, zu welcher Etappe er gehört.', + 'roadtrip.poi.manualAppend': 'Noch ist keine Route berechnet, also kommt der Stopp ans Ende von Tag {number}.', + 'roadtrip.poi.manualNoResults': 'Keine Orte gefunden.', 'roadtrip.summary.distance': 'Strecke', 'roadtrip.summary.driving': 'Fahrzeit', 'roadtrip.alt.ask': 'Andere Wege', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 607f13b0d..754c158bf 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -192,6 +192,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} off the route', 'roadtrip.poi.atStart': 'near the start', 'roadtrip.poi.alongRoute': 'after {distance}', + 'roadtrip.poi.clearResults': 'Clear results', + 'roadtrip.poi.addManual': 'Add manually', + 'roadtrip.poi.manualHint': + 'The place search does not find every charger or pump. Look one up by name and say where on the drive it goes.', + 'roadtrip.poi.addBetween': 'Add between', + 'roadtrip.poi.manualOffRoute': '{distance} from the route, so check which leg it belongs on.', + 'roadtrip.poi.manualAppend': 'Nothing has routed yet, so it goes at the end of day {number}.', + 'roadtrip.poi.manualNoResults': 'No places found.', 'roadtrip.summary.distance': 'Distance', 'roadtrip.summary.driving': 'Driving time', 'roadtrip.alt.ask': 'Other ways', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index f9f7353a6..92b1e29c4 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fuera de la ruta', 'roadtrip.poi.atStart': 'cerca del inicio', 'roadtrip.poi.alongRoute': 'tras {distance}', + 'roadtrip.poi.clearResults': 'Borrar resultados', + 'roadtrip.poi.addManual': 'Añadir a mano', + 'roadtrip.poi.manualHint': + 'La búsqueda de lugares no encuentra todos los puntos de recarga ni todas las gasolineras. Busca uno por su nombre e indica en qué punto del recorrido va.', + 'roadtrip.poi.addBetween': 'Añadir entre', + 'roadtrip.poi.manualOffRoute': '{distance} fuera de la ruta: comprueba a qué tramo pertenece.', + 'roadtrip.poi.manualAppend': 'Aún no hay ninguna ruta calculada, así que va al final del día {number}.', + 'roadtrip.poi.manualNoResults': 'No se encontró ningún lugar.', 'roadtrip.summary.distance': 'Distancia', 'roadtrip.summary.driving': 'Tiempo de conducción', 'roadtrip.alt.ask': 'Otras rutas', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 1c9a81abf..4d066d88c 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} hors itinéraire', 'roadtrip.poi.atStart': 'près du départ', 'roadtrip.poi.alongRoute': 'après {distance}', + 'roadtrip.poi.clearResults': 'Effacer les résultats', + 'roadtrip.poi.addManual': 'Ajouter manuellement', + 'roadtrip.poi.manualHint': + 'La recherche de lieux ne trouve pas toutes les bornes ni toutes les stations-service. Cherchez-en une par son nom et indiquez où elle se place sur le trajet.', + 'roadtrip.poi.addBetween': 'Ajouter entre', + 'roadtrip.poi.manualOffRoute': '{distance} de l’itinéraire, vérifiez donc sur quel trajet il se place.', + 'roadtrip.poi.manualAppend': 'Aucun itinéraire n’est encore calculé, il ira donc à la fin du jour {number}.', + 'roadtrip.poi.manualNoResults': 'Aucun lieu trouvé.', 'roadtrip.summary.distance': 'Distance', 'roadtrip.summary.driving': 'Temps de conduite', 'roadtrip.alt.ask': 'Autres itinéraires', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 1fc7dc20d..d3f61ca3e 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -201,6 +201,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} από τη διαδρομή', 'roadtrip.poi.atStart': 'κοντά στην αφετηρία', 'roadtrip.poi.alongRoute': 'μετά από {distance}', + 'roadtrip.poi.clearResults': 'Καθαρισμός αποτελεσμάτων', + 'roadtrip.poi.addManual': 'Χειροκίνητη προσθήκη', + 'roadtrip.poi.manualHint': + 'Η αναζήτηση τοποθεσιών δεν βρίσκει κάθε φορτιστή ή πρατήριο καυσίμων. Βρείτε ένα με το όνομά του και δηλώστε πού μπαίνει στη διαδρομή.', + 'roadtrip.poi.addBetween': 'Προσθήκη ανάμεσα', + 'roadtrip.poi.manualOffRoute': '{distance} από τη διαδρομή, οπότε ελέγξτε σε ποιο σκέλος ανήκει.', + 'roadtrip.poi.manualAppend': 'Δεν έχει υπολογιστεί ακόμη διαδρομή, οπότε μπαίνει στο τέλος της ημέρας {number}.', + 'roadtrip.poi.manualNoResults': 'Δεν βρέθηκαν τοποθεσίες.', 'roadtrip.summary.distance': 'Απόσταση', 'roadtrip.summary.driving': 'Χρόνος οδήγησης', 'roadtrip.alt.ask': 'Άλλες διαδρομές', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index d38b07667..916aa0e1a 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} az útvonaltól', 'roadtrip.poi.atStart': 'a kiindulás közelében', 'roadtrip.poi.alongRoute': '{distance} után', + 'roadtrip.poi.clearResults': 'Találatok törlése', + 'roadtrip.poi.addManual': 'Hozzáadás kézzel', + 'roadtrip.poi.manualHint': + 'A helykeresés nem talál meg minden töltőt vagy benzinkutat. Keresd meg név szerint, és add meg, hová kerüljön az úton.', + 'roadtrip.poi.addBetween': 'Beszúrás két megálló közé', + 'roadtrip.poi.manualOffRoute': '{distance} az útvonaltól, ezért nézd meg, melyik szakaszhoz tartozik.', + 'roadtrip.poi.manualAppend': 'Még nincs kiszámolt útvonal, ezért a(z) {number}. nap végére kerül.', + 'roadtrip.poi.manualNoResults': 'Nincs találat.', 'roadtrip.summary.distance': 'Távolság', 'roadtrip.summary.driving': 'Vezetési idő', 'roadtrip.alt.ask': 'Más útvonalak', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 1fff5806b..37c9d557b 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} dari rute', 'roadtrip.poi.atStart': 'dekat titik awal', 'roadtrip.poi.alongRoute': 'setelah {distance}', + 'roadtrip.poi.clearResults': 'Hapus hasil', + 'roadtrip.poi.addManual': 'Tambah manual', + 'roadtrip.poi.manualHint': + 'Pencarian tempat tidak menemukan setiap pengisian daya atau pompa bahan bakar. Cari satu menurut nama dan tentukan di bagian perjalanan mana ia ditempatkan.', + 'roadtrip.poi.addBetween': 'Tambahkan di antara', + 'roadtrip.poi.manualOffRoute': '{distance} dari rute, jadi periksa ia masuk ke ruas yang mana.', + 'roadtrip.poi.manualAppend': 'Belum ada rute yang dihitung, jadi ia masuk di akhir hari {number}.', + 'roadtrip.poi.manualNoResults': 'Tidak ada tempat yang ditemukan.', 'roadtrip.summary.distance': 'Jarak', 'roadtrip.summary.driving': 'Waktu berkendara', 'roadtrip.alt.ask': 'Rute lain', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 0fb7ff695..20917f1c5 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fuori percorso', 'roadtrip.poi.atStart': 'vicino alla partenza', 'roadtrip.poi.alongRoute': 'dopo {distance}', + 'roadtrip.poi.clearResults': 'Svuota risultati', + 'roadtrip.poi.addManual': 'Aggiungi a mano', + 'roadtrip.poi.manualHint': + 'La ricerca dei luoghi non trova ogni colonnina o distributore. Cercane uno per nome e indica dove si inserisce nel viaggio.', + 'roadtrip.poi.addBetween': 'Inserisci tra', + 'roadtrip.poi.manualOffRoute': '{distance} fuori percorso: controlla a quale tratto appartiene.', + 'roadtrip.poi.manualAppend': 'Nessun percorso è ancora calcolato, quindi va alla fine del giorno {number}.', + 'roadtrip.poi.manualNoResults': 'Nessun luogo trovato.', 'roadtrip.summary.distance': 'Distanza', 'roadtrip.summary.driving': 'Tempo di guida', 'roadtrip.alt.ask': 'Altri percorsi', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index e94f710df..dd0b3707b 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'ルートから {distance}', 'roadtrip.poi.atStart': '出発地の近く', 'roadtrip.poi.alongRoute': '{distance} 地点', + 'roadtrip.poi.clearResults': '結果を消去', + 'roadtrip.poi.addManual': '手動で追加', + 'roadtrip.poi.manualHint': + '場所の検索では、すべての充電スポットや給油所は見つかりません。名前で探して、道中のどこに入れるかを指定してください。', + 'roadtrip.poi.addBetween': '追加する区間', + 'roadtrip.poi.manualOffRoute': 'ルートから {distance} 離れています。どの区間に入れるか確認してください。', + 'roadtrip.poi.manualAppend': 'まだルートがないため、{number} 日目の最後に追加します。', + 'roadtrip.poi.manualNoResults': '場所が見つかりませんでした。', 'roadtrip.summary.distance': '距離', 'roadtrip.summary.driving': '運転時間', 'roadtrip.alt.ask': '別のルート', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index e6699fd73..d10f31031 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -195,6 +195,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '경로에서 {distance}', 'roadtrip.poi.atStart': '출발지 근처', 'roadtrip.poi.alongRoute': '{distance} 지점', + 'roadtrip.poi.clearResults': '결과 지우기', + 'roadtrip.poi.addManual': '직접 추가', + 'roadtrip.poi.manualHint': '장소 검색이 모든 충전소나 주유소를 찾아내지는 못합니다. 이름으로 직접 찾아 주행 중 어디에 들어갈지 지정하세요.', + 'roadtrip.poi.addBetween': '사이에 추가', + 'roadtrip.poi.manualOffRoute': '경로에서 {distance} 떨어져 있으니 어느 구간에 속하는지 확인하세요.', + 'roadtrip.poi.manualAppend': '아직 계산된 경로가 없어 {number}일차 마지막에 추가됩니다.', + 'roadtrip.poi.manualNoResults': '장소를 찾지 못했습니다.', 'roadtrip.summary.distance': '거리', 'roadtrip.summary.driving': '운전 시간', 'roadtrip.alt.ask': '다른 경로', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index c3bc29768..82ceb0c08 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} van de route', 'roadtrip.poi.atStart': 'bij het startpunt', 'roadtrip.poi.alongRoute': 'na {distance}', + 'roadtrip.poi.clearResults': 'Resultaten wissen', + 'roadtrip.poi.addManual': 'Handmatig toevoegen', + 'roadtrip.poi.manualHint': + 'De plaatszoekfunctie vindt niet elk laadpunt of tankstation. Zoek er zelf een op naam en geef aan waar in de rit hij hoort.', + 'roadtrip.poi.addBetween': 'Toevoegen tussen', + 'roadtrip.poi.manualOffRoute': '{distance} van de route, controleer dus bij welke rit hij hoort.', + 'roadtrip.poi.manualAppend': 'Er is nog geen route berekend, dus hij komt aan het einde van dag {number}.', + 'roadtrip.poi.manualNoResults': 'Geen plaatsen gevonden.', 'roadtrip.summary.distance': 'Afstand', 'roadtrip.summary.driving': 'Rijtijd', 'roadtrip.alt.ask': 'Andere routes', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 53b796e0d..a507cdbc0 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} od trasy', 'roadtrip.poi.atStart': 'blisko startu', 'roadtrip.poi.alongRoute': 'po {distance}', + 'roadtrip.poi.clearResults': 'Wyczyść wyniki', + 'roadtrip.poi.addManual': 'Dodaj ręcznie', + 'roadtrip.poi.manualHint': + 'Wyszukiwanie miejsc nie znajdzie każdej ładowarki ani stacji paliw. Wyszukaj ją po nazwie i wskaż, gdzie na trasie ma się znaleźć.', + 'roadtrip.poi.addBetween': 'Dodaj między', + 'roadtrip.poi.manualOffRoute': '{distance} od trasy, więc sprawdź, do którego odcinka należy.', + 'roadtrip.poi.manualAppend': 'Nie wyznaczono jeszcze żadnej trasy, więc trafi na koniec dnia {number}.', + 'roadtrip.poi.manualNoResults': 'Nie znaleziono miejsc.', 'roadtrip.summary.distance': 'Dystans', 'roadtrip.summary.driving': 'Czas jazdy', 'roadtrip.alt.ask': 'Inne trasy', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index a8a7faf1d..c2d7f39ef 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} от маршрута', 'roadtrip.poi.atStart': 'у начала маршрута', 'roadtrip.poi.alongRoute': 'через {distance}', + 'roadtrip.poi.clearResults': 'Очистить результаты', + 'roadtrip.poi.addManual': 'Добавить вручную', + 'roadtrip.poi.manualHint': + 'Поиск мест находит не каждую зарядку или заправку. Найдите место по названию и укажите, куда в поездке его поставить.', + 'roadtrip.poi.addBetween': 'Добавить между', + 'roadtrip.poi.manualOffRoute': '{distance} от маршрута, проверьте, к какому участку оно относится.', + 'roadtrip.poi.manualAppend': 'Маршрутов пока нет, поэтому место встанет в конце дня {number}.', + 'roadtrip.poi.manualNoResults': 'Места не найдены.', 'roadtrip.summary.distance': 'Расстояние', 'roadtrip.summary.driving': 'Время в пути', 'roadtrip.alt.ask': 'Другие маршруты', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 97cab4a5b..030a7036b 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} från rutten', 'roadtrip.poi.atStart': 'nära starten', 'roadtrip.poi.alongRoute': 'efter {distance}', + 'roadtrip.poi.clearResults': 'Rensa träffar', + 'roadtrip.poi.addManual': 'Lägg till manuellt', + 'roadtrip.poi.manualHint': + 'Platssökningen hittar inte alla laddare eller bensinstationer. Sök upp en på namn och ange var i körningen den ska ligga.', + 'roadtrip.poi.addBetween': 'Lägg till mellan', + 'roadtrip.poi.manualOffRoute': '{distance} från rutten, så kontrollera vilken sträcka den hör till.', + 'roadtrip.poi.manualAppend': 'Ingen rutt är beräknad än, så den läggs sist på dag {number}.', + 'roadtrip.poi.manualNoResults': 'Inga platser hittades.', 'roadtrip.summary.distance': 'Sträcka', 'roadtrip.summary.driving': 'Körtid', 'roadtrip.alt.ask': 'Andra vägar', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 2d7d95985..6468f0e9c 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'rotadan {distance}', 'roadtrip.poi.atStart': 'başlangıca yakın', 'roadtrip.poi.alongRoute': '{distance} sonra', + 'roadtrip.poi.clearResults': 'Sonuçları temizle', + 'roadtrip.poi.addManual': 'Elle ekle', + 'roadtrip.poi.manualHint': + 'Yer araması her şarj noktasını veya benzin istasyonunu bulamaz. Adıyla arayın ve sürüşün neresine gireceğini söyleyin.', + 'roadtrip.poi.addBetween': 'Araya ekle', + 'roadtrip.poi.manualOffRoute': 'Rotadan {distance}, bu yüzden hangi etaba ait olduğunu kontrol edin.', + 'roadtrip.poi.manualAppend': 'Henüz rota hesaplanmadı, bu yüzden {number}. günün sonuna eklenir.', + 'roadtrip.poi.manualNoResults': 'Yer bulunamadı.', 'roadtrip.summary.distance': 'Mesafe', 'roadtrip.summary.driving': 'Sürüş süresi', 'roadtrip.alt.ask': 'Başka yollar', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 4a47124e8..fae085096 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} від маршруту', 'roadtrip.poi.atStart': 'біля початку', 'roadtrip.poi.alongRoute': 'через {distance}', + 'roadtrip.poi.clearResults': 'Очистити результати', + 'roadtrip.poi.addManual': 'Додати вручну', + 'roadtrip.poi.manualHint': + 'Пошук місць знаходить не кожну зарядку чи заправку. Знайдіть потрібну за назвою та вкажіть, де в дорозі вона має бути.', + 'roadtrip.poi.addBetween': 'Додати між', + 'roadtrip.poi.manualOffRoute': '{distance} від маршруту, тож перевірте, до якого відрізка це місце належить.', + 'roadtrip.poi.manualAppend': 'Маршруту ще немає, тож це місце потрапить у кінець дня {number}.', + 'roadtrip.poi.manualNoResults': 'Місць не знайдено.', 'roadtrip.summary.distance': 'Відстань', 'roadtrip.summary.driving': 'Час у дорозі', 'roadtrip.alt.ask': 'Інші маршрути', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 939000bf0..f6762630d 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'cách lộ trình {distance}', 'roadtrip.poi.atStart': 'gần điểm xuất phát', 'roadtrip.poi.alongRoute': 'sau {distance}', + 'roadtrip.poi.clearResults': 'Xóa kết quả', + 'roadtrip.poi.addManual': 'Thêm thủ công', + 'roadtrip.poi.manualHint': + 'Tìm kiếm địa điểm không tìm ra mọi trạm sạc hay trạm xăng. Hãy tra theo tên và cho biết nó nằm ở đâu trên hành trình.', + 'roadtrip.poi.addBetween': 'Thêm vào giữa', + 'roadtrip.poi.manualOffRoute': 'Cách lộ trình {distance}, hãy kiểm tra xem nó thuộc chặng nào.', + 'roadtrip.poi.manualAppend': 'Chưa có lộ trình nào được tính, nên nó sẽ nằm ở cuối ngày {number}.', + 'roadtrip.poi.manualNoResults': 'Không tìm thấy địa điểm nào.', 'roadtrip.summary.distance': 'Quãng đường', 'roadtrip.summary.driving': 'Thời gian lái xe', 'roadtrip.alt.ask': 'Lộ trình khác', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 1b127b71a..48b444273 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -186,6 +186,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '偏離路線 {distance}', 'roadtrip.poi.atStart': '靠近起點', 'roadtrip.poi.alongRoute': '{distance} 處', + 'roadtrip.poi.clearResults': '清除結果', + 'roadtrip.poi.addManual': '手動新增', + 'roadtrip.poi.manualHint': '地點搜尋不一定能找到每個充電站或加油站。請自行用名稱搜尋,並指定它在行程中的位置。', + 'roadtrip.poi.addBetween': '新增於兩站之間', + 'roadtrip.poi.manualOffRoute': '偏離路線 {distance},請確認它屬於哪一段。', + 'roadtrip.poi.manualAppend': '目前尚無路線,因此會加在第 {number} 天的最後。', + 'roadtrip.poi.manualNoResults': '找不到地點。', 'roadtrip.summary.distance': '距離', 'roadtrip.summary.driving': '駕駛時間', 'roadtrip.alt.ask': '其他路線', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f13eccc88..2b7347034 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -186,6 +186,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '偏离路线 {distance}', 'roadtrip.poi.atStart': '靠近起点', 'roadtrip.poi.alongRoute': '{distance} 处', + 'roadtrip.poi.clearResults': '清除结果', + 'roadtrip.poi.addManual': '手动添加', + 'roadtrip.poi.manualHint': '地点搜索不会找到所有充电站或加油站。按名称自行搜索,并指定它在行程中的位置。', + 'roadtrip.poi.addBetween': '添加到两站之间', + 'roadtrip.poi.manualOffRoute': '偏离路线 {distance},请确认它属于哪个路段。', + 'roadtrip.poi.manualAppend': '还没有路线,因此它将添加到第 {number} 天的末尾。', + 'roadtrip.poi.manualNoResults': '未找到地点。', 'roadtrip.summary.distance': '距离', 'roadtrip.summary.driving': '驾驶时间', 'roadtrip.alt.ask': '其他路线', From 00d00be1c0db975fdbff796d1bd906521c3c6bbc Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 12:28:47 +0200 Subject: [PATCH 3/5] feat(roadtrip): what a charger costs, before it is a stop A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free. --- .../components/Roadtrip/ChargingInfo.test.tsx | 52 ++++++++++- .../src/components/Roadtrip/ChargingInfo.tsx | 26 +++++- .../Roadtrip/RoadtripStopPopup.test.tsx | 81 ++++++++++++++++- .../components/Roadtrip/RoadtripStopPopup.tsx | 21 +++++ client/src/repo/chargingRepo.test.ts | 53 +++++++++++ client/src/repo/chargingRepo.ts | 41 +++++++-- .../roadtrip/charging-lookup.controller.ts | 38 ++++++++ server/src/nest/roadtrip/charging.mcp.ts | 8 ++ server/src/nest/roadtrip/charging.service.ts | 24 ++++- server/src/nest/roadtrip/roadtrip.module.ts | 3 +- server/tests/e2e/roadtrip.e2e.test.ts | 20 +++++ server/tests/unit/nest/charging.test.ts | 89 ++++++++++++++++++- 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/charging.schema.spec.ts | 24 +++++ shared/src/roadtrip/charging.schema.ts | 25 ++++++ 37 files changed, 507 insertions(+), 21 deletions(-) create mode 100644 client/src/repo/chargingRepo.test.ts create mode 100644 server/src/nest/roadtrip/charging-lookup.controller.ts create mode 100644 shared/src/roadtrip/charging.schema.spec.ts diff --git a/client/src/components/Roadtrip/ChargingInfo.test.tsx b/client/src/components/Roadtrip/ChargingInfo.test.tsx index e46a27dfa..87d48bee8 100644 --- a/client/src/components/Roadtrip/ChargingInfo.test.tsx +++ b/client/src/components/Roadtrip/ChargingInfo.test.tsx @@ -1,13 +1,14 @@ import React from 'react' -import { describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n' import ChargingInfo from './ChargingInfo' import { chargingRepo } from '../../repo/chargingRepo' vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) })) -vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } })) +vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } })) const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false } +beforeEach(() => { vi.clearAllMocks() }) describe('Charging information', () => { it('shows availability, attribution and missing prices without inventing a price', async () => { vi.mocked(chargingRepo.read).mockResolvedValue(info) @@ -23,4 +24,49 @@ describe('Charging information', () => { expect(await screen.findByText('Status outdated')).toBeInTheDocument() expect(screen.queryByText('2/4')).not.toBeInTheDocument() }) + + // A station found along the route has no place row yet, and the availability and the + // price are what somebody wants before deciding to add it. + it('FE-ROADTRIP-CHARGING-010: a station that is not a stop yet is asked for by coordinate', async () => { + vi.mocked(chargingRepo.readAt).mockResolvedValue(info) + render() + + expect(await screen.findByText('2/4 available')).toBeInTheDocument() + expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord') + expect(chargingRepo.read).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-CHARGING-011: pointed at another station, it asks again', async () => { + // The coordinate is the identity here. Left out of the effect's dependencies the + // panel would keep showing the first charger's availability for the second one. + vi.mocked(chargingRepo.readAt).mockResolvedValue(info) + const { rerender } = render() + await screen.findByText('2/4 available') + + rerender() + + await waitFor(() => expect(chargingRepo.readAt).toHaveBeenLastCalledWith(1, 49.013, 12.101, 'Autohof Sued')) + expect(chargingRepo.readAt).toHaveBeenCalledTimes(2) + }) + + it('FE-ROADTRIP-CHARGING-012: a saved stop is still asked for by place id', async () => { + vi.mocked(chargingRepo.read).mockResolvedValue(info) + render() + + expect(await screen.findByText('2/4')).toBeInTheDocument() + expect(chargingRepo.read).toHaveBeenCalledWith(1, 7) + expect(chargingRepo.readAt).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-CHARGING-013: an unidentified station is never shown as free', async () => { + // `available` is null whenever the answer is not both fresh and countable, and an + // unknown status is not spare capacity. Outside the covered regions this is the + // usual answer, so it is the one the dialog has to get right. + vi.mocked(chargingRepo.readAt).mockResolvedValue({ ...info, status: 'ambiguous', available: null, total: 0, station: null }) + render() + + // Said twice over: once where the free count would be, once where the price would. + expect(await screen.findAllByText('No reliable data available')).toHaveLength(2) + expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument() + }) }) diff --git a/client/src/components/Roadtrip/ChargingInfo.tsx b/client/src/components/Roadtrip/ChargingInfo.tsx index 1b35767ad..2dfc81e03 100644 --- a/client/src/components/Roadtrip/ChargingInfo.tsx +++ b/client/src/components/Roadtrip/ChargingInfo.tsx @@ -7,7 +7,20 @@ import { chargingRepo } from '../../repo/chargingRepo' import { isEffectivelyOffline } from '../../sync/networkMode' import { Tooltip } from '../shared/Tooltip' -export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) { +/** + * Which station the panel is asking about, one way or the other. + * + * A stop on the trip is named by its `places` row. A station found along the route has + * no row yet, and the availability and the price are exactly what somebody wants before + * deciding to add it, so that one is named by where it is instead. The two are mutually + * exclusive: the optional `undefined` members are what stop a caller passing both and + * leaving it to the component to guess which it meant. + */ +type ChargingTarget = + | { placeId: number; lat?: undefined; lng?: undefined; name?: undefined } + | { placeId?: undefined; lat: number; lng: number; name: string } + +export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) { const tripId = useTripStore(s => s.trip?.id) const { t } = useTranslation() const [info, setInfo] = useState(null) @@ -17,7 +30,12 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu setInfo(null); setLoading(true) const refresh = async () => { if (!tripId || document.hidden) return - try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) } + try { + const value = placeId != null + ? await chargingRepo.read(tripId, placeId) + : await chargingRepo.readAt(tripId, lat!, lng!, name!) + if (active) setInfo(value) + } catch { if (active) setInfo(null) } finally { if (active) setLoading(false) } } @@ -28,7 +46,9 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu window.addEventListener('online', refresh) document.addEventListener('visibilitychange', refresh) return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) } - }, [tripId, placeId]) + // The identity of the station, whichever way it was given: a changed coordinate is a + // different charger and has to re-ask, exactly as a changed place id does. + }, [tripId, placeId, lat, lng, name]) const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000 const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown') const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? [] diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx index 7cd672bdb..8ba54af35 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx @@ -1,12 +1,28 @@ import React from 'react' -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { TranslationProvider } from '../../i18n' import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup' +import { chargingRepo } from '../../repo/chargingRepo' import type { CorridorPoi } from './useCorridorPois' +// The dialog carries the charging panel, which reads the open availability data for a +// station that is not on the trip yet. Both of its collaborators are stubbed: the trip +// is whichever one is open, and the data comes off the network. +vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) })) +vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } })) + const wrap = (ui: React.ReactElement) => render({ui}) +const charging = { + checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord', + source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', + updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, + tariffs: [], pricesUnavailable: false, +} + +beforeEach(() => { vi.clearAllMocks() }) + function poi(over: Partial = {}): CorridorPoi { return { osm_id: 'node:9', @@ -116,15 +132,24 @@ describe('RoadtripStopPopup', () => { ['sights', 'Sights'], ] + /** + * One kind pill, by the name on it. + * + * Filtered on `aria-pressed` rather than taken as the only match: for a charging hit + * the dialog also carries the charging panel, whose `` reads "Charging" too. + */ + const kindPill = (label: string): HTMLElement => + screen.getAllByRole('button', { name: new RegExp(label) }).filter(el => el.hasAttribute('aria-pressed'))[0] + it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => { const { unmount } = wrap() - expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true') + expect(kindPill(label)).toHaveAttribute('aria-pressed', 'true') // Every other kind is off, so the preselect cannot be reading the wrong row. Scoped // to the kind buttons: the dwell presets use `aria-pressed` for their own state. for (const [, other] of CORRIDOR_KINDS) { if (other === label) continue - expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false') + expect(kindPill(other)).toHaveAttribute('aria-pressed', 'false') } unmount() }) @@ -270,6 +295,56 @@ describe('RoadtripStopPopup', () => { } }) + // ── Charging, before the add ─────────────────────────────────────────────── + // + // The data was only reachable once the charger was on the trip, which is the wrong + // way round: how busy the place is and what it charges is what decides whether to + // add it at all. + + const chargingDraft = () => draft({ poi: poi({ category: 'charging', name: 'Ladepark Nord', lat: 48.137, lng: 11.575 }) }) + + it('FE-ROADTRIP-STOPPOPUP-030: a charging hit is asked about by where it is', async () => { + vi.mocked(chargingRepo.readAt).mockResolvedValue(charging) + wrap() + + // By coordinate and name: there is no place row to name it by yet. + await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord')) + expect(await screen.findByText('2/4 available')).toBeInTheDocument() + // The licence line travels with the numbers wherever they are shown. + expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com') + }) + + it('FE-ROADTRIP-STOPPOPUP-031: a fuel stop costs nothing', () => { + wrap() + + expect(chargingRepo.readAt).not.toHaveBeenCalled() + expect(screen.queryByText('Published tariffs')).not.toBeInTheDocument() + }) + + it('FE-ROADTRIP-STOPPOPUP-032: the question follows the kind that is picked', async () => { + vi.mocked(chargingRepo.readAt).mockResolvedValue(charging) + wrap() + expect(chargingRepo.readAt).not.toHaveBeenCalled() + + fireEvent.click(kindPill('Charging')) + await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1)) + + fireEvent.click(kindPill('Fuel')) + expect(screen.queryByText('2/4 available')).not.toBeInTheDocument() + }) + + it('FE-ROADTRIP-STOPPOPUP-033: one station, one question, however much else is changed', async () => { + // A cache miss upstream costs several requests against a shared public registry, so + // the dialog asks once and re-rendering it for an unrelated answer must not re-ask. + vi.mocked(chargingRepo.readAt).mockResolvedValue(charging) + wrap() + await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole('button', { name: /45 min/ })) + + expect(chargingRepo.readAt).toHaveBeenCalledTimes(1) + }) + it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => { // An onSaveNight-less caller gets the dialog it always had, rather than a mode that // leads nowhere. diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index f910b35c3..230ce3fb6 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -3,6 +3,7 @@ import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react import Modal from '../shared/Modal' import CustomTimePicker from '../shared/CustomTimePicker' import StayPortals from './StayPortals' +import ChargingInfo from './ChargingInfo' import { useTranslation } from '../../i18n/TranslationContext' import { safeExternalHref } from '../../utils/safeUrl' import { formatDurationShort } from './roadtripModel' @@ -266,6 +267,26 @@ export default function RoadtripStopPopup({ ))} + + {/* How busy the charging area is and what it charges, while the stop is still + being considered rather than once it is on the trip. Only for the kind that + has an answer: a petrol station or a bakery asks nothing, so picking either + of those costs no request at all. + + One panel for one station, for as long as the dialog is open. The corridor + list deliberately does not do this per row: it publishes results box by + box, each row would own its own refresh timer, and a station in a dense + row of chargers is the case the upstream match refuses as ambiguous. */} + {stopType === 'charging' ? ( +
    + + {t('roadtrip.charging.availability')} + +
    + +
    +
    + ) : null} )} diff --git a/client/src/repo/chargingRepo.test.ts b/client/src/repo/chargingRepo.test.ts new file mode 100644 index 000000000..032a2c385 --- /dev/null +++ b/client/src/repo/chargingRepo.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { chargingRepo } from './chargingRepo' +import { isEffectivelyOffline } from '../sync/networkMode' +import { apiClient } from '../api/client' + +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) })) +vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn() } })) + +const info = { + checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord', + source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', + updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, + tariffs: [], pricesUnavailable: false, +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + vi.mocked(apiClient.post).mockResolvedValue({ data: info }) +}) + +describe('charging data for a station that is not a stop yet', () => { + it('FE-ROADTRIP-CHARGEREPO-001: asks the trip-scoped route with the coordinate and the name', async () => { + expect(await chargingRepo.readAt(1, 48.137, 11.575, 'Ladepark Nord')).toEqual(info) + expect(apiClient.post).toHaveBeenCalledWith('/trips/1/roadtrip/charging-lookup', { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' }) + // Never the saved-stop route: that one takes a place id, which a hit found along + // the route does not have. + expect(apiClient.get).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-CHARGEREPO-002: the same station twice is one request, a different one is another', async () => { + const first = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark') + const again = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark') + expect(await first).toEqual(await again) + expect(apiClient.post).toHaveBeenCalledTimes(1) + // A charger 200 m down the road is a different charger, cache or no cache. + await chargingRepo.readAt(2, 48.202, 11.2, 'Ladepark') + expect(apiClient.post).toHaveBeenCalledTimes(2) + }) + + it('FE-ROADTRIP-CHARGEREPO-003: offline it refuses rather than queueing a read', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(true) + await expect(chargingRepo.readAt(3, 48.3, 11.3, 'Ladepark')).rejects.toThrow('Offline') + expect(apiClient.post).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-CHARGEREPO-004: an answer that is not the contract is rejected, not rendered', async () => { + // Availability is nullable on purpose and the panel branches on it; a string where + // a count belongs must not reach it. + vi.mocked(apiClient.post).mockResolvedValue({ data: { ...info, available: 'lots' } }) + await expect(chargingRepo.readAt(4, 48.4, 11.4, 'Ladepark')).rejects.toThrow() + }) +}) diff --git a/client/src/repo/chargingRepo.ts b/client/src/repo/chargingRepo.ts index e0632eba0..8c845187f 100644 --- a/client/src/repo/chargingRepo.ts +++ b/client/src/repo/chargingRepo.ts @@ -3,15 +3,42 @@ import { apiClient } from '../api/client' import { isEffectivelyOffline } from '../sync/networkMode' const pending = new Map }>() + +/** + * One request per station per minute, however many places ask for it. + * + * The rail renders a badge for every charging stop, the inspector a panel for the same + * one and the add dialog a third for a station that is not on the trip yet, each on + * its own refresh timer. The window is a shade under the server's own minute of cache, + * so a refresh that gets through lands on a fresh answer rather than the cached one. + * + * A rejection stays in the map for the full window on purpose: a station the upstream + * registry cannot answer for should not be asked again a second later. + */ +function coalesce(key: string, load: () => Promise): Promise { + const cached = pending.get(key) + if (cached && cached.until > Date.now()) return cached.value + if (pending.size > 200) pending.clear() + const value = load() + pending.set(key, { until: Date.now() + 55000, value }) + return value +} + export const chargingRepo = { async read(tripId: number, placeId: number) { if (isEffectivelyOffline()) throw new Error('Offline') - const key = `${tripId}/${placeId}` - const cached = pending.get(key) - if (cached && cached.until > Date.now()) return cached.value - if (pending.size > 200) pending.clear() - const value = apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data)) - pending.set(key, { until: Date.now() + 55000, value }) - return value + return coalesce(`${tripId}/${placeId}`, () => + apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data))) + }, + /** + * The same answer for a station the trip has not saved yet, which is the only way to + * ask before adding it: the saved-stop route takes a place id, and a hit found along + * the route has none. Cached under the coordinate rather than an id, so looking at + * the same charger twice is one request. + */ + async readAt(tripId: number, lat: number, lng: number, name: string) { + if (isEffectivelyOffline()) throw new Error('Offline') + return coalesce(`${tripId}/at:${lat},${lng}:${name}`, () => + apiClient.post(`/trips/${tripId}/roadtrip/charging-lookup`, { lat, lng, name }).then(reply => chargingInfoSchema.parse(reply.data))) }, } diff --git a/server/src/nest/roadtrip/charging-lookup.controller.ts b/server/src/nest/roadtrip/charging-lookup.controller.ts new file mode 100644 index 000000000..da59f02cd --- /dev/null +++ b/server/src/nest/roadtrip/charging-lookup.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { chargingLookupSchema } from '@trek/shared'; +import { createZodDto } from 'nestjs-zod'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { ChargingService } from './charging.service'; + +/** Exported so the parity test can hold the route and the MCP tool to the same contract. */ +export class ChargingLookupDto extends createZodDto(chargingLookupSchema) {} + +/** + * Charging availability for a station nobody has added yet. + * + * A sibling of `ChargingController` rather than another verb on it, because the two + * ask in different ways: that one names a row in `places`, this one hands over a + * coordinate. Same service, same cache, same answer. + * + * Trip-scoped on purpose, even though the trip is not part of the question. Leaving + * `:tripId` out would take `TripAccessGuard` with it, and what is left is an open + * proxy in front of somebody else's public API: anyone with the URL could aim it + * anywhere on the planet at whatever rate they liked. Nothing asks this while not + * planning a trip, so the scope costs the caller nothing. + * + * A POST for a read follows `RoadtripSearchController`: the input is a coordinate pair + * and a name, which belongs in a body behind a contract rather than in a query string. + */ +@Controller('api/trips/:tripId/roadtrip/charging-lookup') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class ChargingLookupController { + constructor(private readonly charging: ChargingService) {} + @Post() + @HttpCode(200) + lookup(@Body() input: ChargingLookupDto) { return this.charging.lookup(input.lat, input.lng, input.name); } +} diff --git a/server/src/nest/roadtrip/charging.mcp.ts b/server/src/nest/roadtrip/charging.mcp.ts index 5317d35d0..a6d50e357 100644 --- a/server/src/nest/roadtrip/charging.mcp.ts +++ b/server/src/nest/roadtrip/charging.mcp.ts @@ -1,3 +1,4 @@ +import { chargingLookupSchema } from '@trek/shared'; import { z } from 'zod'; import { ADDON_IDS } from '../../addons'; import { noAccess } from '../../mcp/tools/_shared'; @@ -17,4 +18,11 @@ export class ChargingMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); return ok(await this.charging.read(tripId, placeId)); } + @Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.', + inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape }, + annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok(await this.charging.lookup(lat, lng, name)); + } } diff --git a/server/src/nest/roadtrip/charging.service.ts b/server/src/nest/roadtrip/charging.service.ts index 07a30f0d6..6523926fd 100644 --- a/server/src/nest/roadtrip/charging.service.ts +++ b/server/src/nest/roadtrip/charging.service.ts @@ -5,7 +5,8 @@ import { DatabaseService } from '../database/database.service'; import { readCappedJson } from '../../utils/cappedFetch'; import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers'; -const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false }); +/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */ +export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false }); async function request(path: string) { const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' }); if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); } @@ -22,11 +23,28 @@ export class ChargingService { const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); if (!place) throw new HttpException({ error: 'Place not found' }, 404); if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown'); - const key = `${place.lat},${place.lng}:${place.name}`; + return this.lookup(place.lat, place.lng, place.name); + } + /** + * The same answer for a station the trip has not saved yet. + * + * A hit found along the route has no `places` row, so it cannot be asked for by id, + * but the match never needed one: a coordinate and a name is the whole input. Sharing + * `cached` with the saved-stop path matters twice over. The answer a charger gives + * while it is being considered is the answer it gives once it is on the trip, and the + * minute of cover is what keeps a second look from becoming a second round of + * upstream requests. + * + * The cache is keyed on the coordinate exactly as it arrived, not on a rounded one: + * a stop added from a corridor hit carries the coordinate the hit had, so the two + * paths land on the same entry without either of them knowing about the other. + */ + lookup(lat: number, lng: number, name: string): Promise { + const key = `${lat},${lng}:${name}`; const cached = this.cached.get(key); if (cached && cached.expires > Date.now()) return cached.value; if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!); - const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable')); + const value = this.load(lat, lng, name).catch(() => empty('unavailable')); this.cached.set(key, { expires: Date.now() + 60000, value }); return value; } diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 22603ad9f..2b42d83ab 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -1,6 +1,7 @@ import { ChargingMcp } from './charging.mcp'; import { ChargingService } from './charging.service'; import { ChargingController } from './charging.controller'; +import { ChargingLookupController } from './charging-lookup.controller'; import { GoogleRouteService } from './google-route.service'; import { GoogleRouteController } from './google-route.controller'; import { GoogleRouteMcp } from './google-route.mcp'; @@ -47,7 +48,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp'; PluginsRuntimeModule, MapsModule, PlacesModule, AssignmentsModule, ], - controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], + controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp, RoadtripSearchService, RoadtripHazardsService, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 70ab12a9e..fd3324462 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -146,6 +146,26 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { expect(read).toHaveBeenCalledTimes(1); } finally { read.mockRestore(); } }); + it('gates the coordinate lookup like the saved stop and validates before fetching', async () => { + const lookup = vi.spyOn(app.get(ChargingService), 'lookup').mockResolvedValue({} as never); + const body = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' }; + const post = () => request(server).post('/api/trips/5/roadtrip/charging-lookup').set('Cookie', cookie()); + try { + await request(server).post('/api/trips/5/roadtrip/charging-lookup').send(body).expect(401); + await request(server).post('/api/trips/6/roadtrip/charging-lookup').set('Cookie', cookie()).send(body).expect(404); + await post().send({ ...body, lat: 91 }).expect(400); + // A list is the shape a caller would reach for to fan a whole corridor out in one + // request. There is no rate limiter here and each miss costs the upstream registry + // several requests, so the contract refuses it before anything leaves the server. + await post().send([body]).expect(400); + expect(lookup).not.toHaveBeenCalled(); + await post().send(body).expect(200); + expect(lookup).toHaveBeenCalledWith(48.137, 11.575, 'Ladepark Nord'); + setAddon(false); + await post().send(body).expect(404); + expect(lookup).toHaveBeenCalledTimes(1); + } finally { lookup.mockRestore(); } + }); it('validates Google route preview and import before executing either service', async () => { const routes = app.get(GoogleRouteService); const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] }); diff --git a/server/tests/unit/nest/charging.test.ts b/server/tests/unit/nest/charging.test.ts index 71355473a..926bb4ea6 100644 --- a/server/tests/unit/nest/charging.test.ts +++ b/server/tests/unit/nest/charging.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ChargingService } from '../../../src/nest/roadtrip/charging.service'; +import { chargingLookupSchema } from '@trek/shared'; +import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service'; +import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller'; +import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers'; const now = Date.parse('2026-09-12T12:00:00Z'); @@ -59,3 +63,86 @@ describe('Open charging data', () => { expect(fetcher).toHaveBeenCalledTimes(3); }); }); + +/** + * The same data for a station nobody has added yet (#1797 feedback: "it would be useful + * to see this data also before the add"). One service, one cache, one answer: the only + * difference is how the station is named. + */ +describe('Open charging data before the stop exists', () => { + const answers = (tariffs: unknown) => vi.fn(async (url: string) => { + if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] })); + if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 })); + return new Response(JSON.stringify(tariffs)); + }); + + it('answers for a coordinate and lets the saved stop reuse that answer', async () => { + const fetcher = answers({ items: [tariff], total_count: 1 }); + vi.stubGlobal('fetch', fetcher); + const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never); + + const ahead = await service.lookup(48, 11, 'Test'); + expect(ahead.status).toBe('ok'); + // The count of individual charge points, which is what "how many chargers" means. + expect(ahead).toMatchObject({ total: 3, available: 1, unknown: 1 }); + expect(ahead.tariffs[0].components[0]).toMatchObject({ kind: 'ENERGY', taxIncluded: true }); + // Added to the trip, the same coordinate and name key the same cache entry, so the + // stop on the rail does not pay for the answer the dialog already got. + expect(await service.read(1, 2)).toEqual(ahead); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('never reports a station it could not identify as free', async () => { + // Two operators within the radius is the dense case a station found along a route + // routinely is. Returning the nearest one would attach another operator's prices. + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] })); + return new Response(JSON.stringify({ items: [station, { ...station, id: 'other', operator: { name: 'Other' } }], total_count: 2 })); + })); + const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Charging'); + expect(info.status).toBe('ambiguous'); + expect(info.available).toBeNull(); + }); + + it('turns an unreachable source into an empty answer, not a failed request', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 503 }))); + const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Test'); + expect(info).toEqual({ ...empty('unavailable'), checkedAt: info.checkedAt }); + }); + + it('does not go to the database for a station that is not in it', async () => { + vi.stubGlobal('fetch', answers({ items: [], total_count: 0 })); + const get = vi.fn(); + await new ChargingService({ get } as never).lookup(48, 11, 'Test'); + expect(get).not.toHaveBeenCalled(); + }); + + it('serves the route and the MCP tool from one contract and one access check', async () => { + const charging = { read: vi.fn(), lookup: vi.fn(async () => empty('unknown')) }; + const db = { canAccessTrip: vi.fn(() => false) }; + const tool = new ChargingMcp(charging as never, db as never, {} as never); + const input = { lat: 48.1, lng: 11.5, name: 'Ladepark Nord' }; + + // A trip the caller cannot reach is refused before anything is fetched, exactly as + // TripAccessGuard refuses it on the route. + expect((await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never)).isError).toBe(true); + expect(charging.lookup).not.toHaveBeenCalled(); + db.canAccessTrip.mockReturnValue(true); + await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never); + await new ChargingLookupController(charging as never).lookup(input as unknown as ChargingLookupDto); + + expect(charging.lookup.mock.calls).toEqual([[48.1, 11.5, 'Ladepark Nord'], [48.1, 11.5, 'Ladepark Nord']]); + // Both surfaces validate through the shared contract rather than a restatement of + // it, so a bound or a length limit cannot be tightened on one side only. + for (const bad of [{ ...input, lat: 91 }, { ...input, lng: -181 }, { ...input, name: 'x'.repeat(201) }]) { + expect(ChargingLookupDto.schema.safeParse(bad).success).toBe(false); + expect(chargingLookupSchema.safeParse(bad).success).toBe(false); + } + expect(ChargingLookupDto.schema.safeParse(input).success).toBe(true); + const declared = getEntry(ChargingMcp as unknown as ClassRef, 'lookup')?.options as { inputSchema: Record }; + expect(Object.keys(declared.inputSchema).sort()).toEqual(['lat', 'lng', 'name', 'tripId']); + for (const [field, type] of Object.entries(chargingLookupSchema.shape)) { + expect(declared.inputSchema[field]).toBe(type); + } + }); +}); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index fff52aed7..c669b25f7 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.prices': 'التعريفات المنشورة', 'roadtrip.charging.note': 'تعريفة منشورة؛ قد تُطبق ضرائب وشروط ورسوم إضافية. قد يختلف سعر بطاقة الشحن.', 'roadtrip.charging.coverage': 'بيانات مفتوحة؛ تختلف التغطية حسب المنطقة والمشغل.', + 'roadtrip.charging.availability': 'التوفر والتعريفات', 'roadtrip.import.title': 'استيراد مسار Google Maps', 'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.', 'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index d5ec484f9..7fad194a3 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.', 'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.', + 'roadtrip.charging.availability': 'Disponibilidade e tarifas', 'roadtrip.import.title': 'Importar rota do Google Maps', 'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.', 'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index a8dab1538..4576f0de0 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tarifa publicada; pot haver-hi impostos, condicions i càrrecs addicionals. El preu de la targeta pot variar.', 'roadtrip.charging.coverage': 'Dades obertes; cobertura segons regió i operador.', + 'roadtrip.charging.availability': 'Disponibilitat i tarifes', 'roadtrip.import.title': 'Importa una ruta de Google Maps', 'roadtrip.import.note': 'Les parades s’afegeixen al dia triat. TREK recalcula la ruta.', 'roadtrip.import.unresolved': 'Ubicació no trobada; aquesta parada s’ometrà.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index b6ff5b5e0..564fc8350 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Zveřejněný tarif; mohou platit daně, podmínky a další poplatky. Cena vaší karty se může lišit.', 'roadtrip.charging.coverage': 'Otevřená data; pokrytí závisí na regionu a provozovateli.', + 'roadtrip.charging.availability': 'Dostupnost a tarify', 'roadtrip.import.title': 'Importovat trasu z Google Maps', 'roadtrip.import.note': 'Zastávky se přidají ke zvolenému dni. TREK trasu přepočítá.', 'roadtrip.import.unresolved': 'Místo nenalezeno; tato zastávka bude přeskočena.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 9f8af3219..7ffcd01b4 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Veröffentlichter Tarif; Steuern, Bedingungen und Zusatzgebühren beachten. Dein Ladekartenpreis kann abweichen.', 'roadtrip.charging.coverage': 'Offene Daten; Abdeckung abhängig von Region und Betreiber.', + 'roadtrip.charging.availability': 'Verfügbarkeit und Tarife', 'roadtrip.import.title': 'Google-Maps-Route importieren', 'roadtrip.import.note': 'Stopps werden an den gewählten Tag angehängt. TREK berechnet die Route neu.', 'roadtrip.import.unresolved': 'Ort nicht gefunden; dieser Stopp wird übersprungen.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 754c158bf..486e3f8f0 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Published tariff; taxes, conditions and extra fees may apply. Your charging-card price can differ.', 'roadtrip.charging.coverage': 'Open data; coverage varies by region and operator.', + 'roadtrip.charging.availability': 'Availability and prices', 'roadtrip.import.title': 'Import Google Maps route', 'roadtrip.import.note': 'Stops are appended to the selected day. TREK recalculates the route.', 'roadtrip.import.unresolved': 'Location unresolved; this stop will be skipped.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 92b1e29c4..7e5ce6164 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tarifa publicada; pueden aplicarse impuestos, condiciones y cargos adicionales. El precio de tu tarjeta puede variar.', 'roadtrip.charging.coverage': 'Datos abiertos; cobertura según región y operador.', + 'roadtrip.charging.availability': 'Disponibilidad y tarifas', 'roadtrip.import.title': 'Importar ruta de Google Maps', 'roadtrip.import.note': 'Las paradas se añaden al día elegido. TREK recalcula la ruta.', 'roadtrip.import.unresolved': 'Ubicación no encontrada; se omitirá esta parada.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 4d066d88c..da34c9b64 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tarif publié ; taxes, conditions et frais supplémentaires possibles. Le prix de votre carte peut différer.', 'roadtrip.charging.coverage': 'Données ouvertes ; couverture variable selon la région et l’opérateur.', + 'roadtrip.charging.availability': 'Disponibilité et tarifs', 'roadtrip.import.title': 'Importer un itinéraire Google Maps', 'roadtrip.import.note': 'Les étapes sont ajoutées au jour choisi. TREK recalcule le trajet.', 'roadtrip.import.unresolved': 'Lieu introuvable ; cette étape sera ignorée.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index d3f61ca3e..c28011992 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Δημοσιευμένη χρέωση· ενδέχεται να ισχύουν φόροι, όροι και πρόσθετα τέλη. Η τιμή της κάρτας σας μπορεί να διαφέρει.', 'roadtrip.charging.coverage': 'Ανοικτά δεδομένα· η κάλυψη εξαρτάται από την περιοχή και τον πάροχο.', + 'roadtrip.charging.availability': 'Διαθεσιμότητα και χρεώσεις', 'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps', 'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.', 'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 916aa0e1a..596de5a32 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.prices': 'Közzétett díjak', 'roadtrip.charging.note': 'Közzétett díj; adók, feltételek és felárak lehetnek. A töltőkártya ára eltérhet.', 'roadtrip.charging.coverage': 'Nyílt adatok; a lefedettség régiónként és szolgáltatónként változik.', + 'roadtrip.charging.availability': 'Elérhetőség és díjak', 'roadtrip.import.title': 'Google Maps-útvonal importálása', 'roadtrip.import.note': 'A megállók a kiválasztott naphoz kerülnek. A TREK újratervezi az útvonalat.', 'roadtrip.import.unresolved': 'A hely nem található; ez a megálló kimarad.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 37c9d557b..c48173316 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tarif publik; pajak, syarat, dan biaya tambahan mungkin berlaku. Harga kartu pengisian Anda bisa berbeda.', 'roadtrip.charging.coverage': 'Data terbuka; cakupan bergantung wilayah dan operator.', + 'roadtrip.charging.availability': 'Ketersediaan dan tarif', 'roadtrip.import.title': 'Impor rute Google Maps', 'roadtrip.import.note': 'Perhentian ditambahkan ke hari yang dipilih. TREK menghitung ulang rute.', 'roadtrip.import.unresolved': 'Lokasi tidak ditemukan; perhentian ini akan dilewati.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 20917f1c5..18c6afac2 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Tariffa pubblicata; verificare tasse, condizioni e supplementi. Il prezzo della tua tessera può variare.', 'roadtrip.charging.coverage': 'Dati aperti; copertura variabile per regione e gestore.', + 'roadtrip.charging.availability': 'Disponibilità e tariffe', 'roadtrip.import.title': 'Importa percorso Google Maps', 'roadtrip.import.note': 'Le tappe vengono aggiunte al giorno scelto. TREK ricalcola il percorso.', 'roadtrip.import.unresolved': 'Posizione non trovata; questa tappa verrà saltata.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index dd0b3707b..18b712f42 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': '公開料金です。税金、条件、追加料金をご確認ください。充電カードの料金は異なる場合があります。', 'roadtrip.charging.coverage': 'オープンデータ。地域や事業者によって対応範囲が異なります。', + 'roadtrip.charging.availability': '空き状況と料金', 'roadtrip.import.title': 'Google Mapsのルートをインポート', 'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。', 'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index d10f31031..14c1f0f83 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': '공개 요금입니다. 세금, 조건, 추가 요금이 적용될 수 있으며 충전 카드 요금은 다를 수 있습니다.', 'roadtrip.charging.coverage': '공개 데이터로 지역과 운영사에 따라 범위가 다릅니다.', + 'roadtrip.charging.availability': '이용 가능 여부와 요금', 'roadtrip.import.title': 'Google Maps 경로 가져오기', 'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.', 'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 82ceb0c08..e7b1502b6 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Gepubliceerd tarief; belastingen, voorwaarden en toeslagen kunnen gelden. Je laadpasprijs kan afwijken.', 'roadtrip.charging.coverage': 'Open data; dekking verschilt per regio en aanbieder.', + 'roadtrip.charging.availability': 'Beschikbaarheid en tarieven', 'roadtrip.import.title': 'Google Maps-route importeren', 'roadtrip.import.note': 'Stops worden aan de gekozen dag toegevoegd. TREK berekent de route opnieuw.', 'roadtrip.import.unresolved': 'Locatie niet gevonden; deze stop wordt overgeslagen.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index a507cdbc0..cd0932c15 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Opublikowana taryfa; możliwe podatki, warunki i dodatkowe opłaty. Cena Twojej karty może się różnić.', 'roadtrip.charging.coverage': 'Otwarte dane; zasięg zależy od regionu i operatora.', + 'roadtrip.charging.availability': 'Dostępność i taryfy', 'roadtrip.import.title': 'Importuj trasę z Google Maps', 'roadtrip.import.note': 'Przystanki zostaną dodane do wybranego dnia. TREK przeliczy trasę.', 'roadtrip.import.unresolved': 'Nie znaleziono miejsca; ten przystanek zostanie pominięty.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index c2d7f39ef..738dd18a5 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Опубликованный тариф; возможны налоги, условия и доплаты. Цена по вашей карте может отличаться.', 'roadtrip.charging.coverage': 'Открытые данные; охват зависит от региона и оператора.', + 'roadtrip.charging.availability': 'Доступность и тарифы', 'roadtrip.import.title': 'Импорт маршрута Google Maps', 'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.', 'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 030a7036b..1fc46ce45 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.prices': 'Publicerade priser', 'roadtrip.charging.note': 'Publicerat pris; skatter, villkor och tillägg kan gälla. Ditt laddkorts pris kan avvika.', 'roadtrip.charging.coverage': 'Öppna data; täckning varierar efter region och operatör.', + 'roadtrip.charging.availability': 'Tillgänglighet och priser', 'roadtrip.import.title': 'Importera Google Maps-rutt', 'roadtrip.import.note': 'Stoppen läggs till på vald dag. TREK beräknar rutten på nytt.', 'roadtrip.import.unresolved': 'Platsen hittades inte; stoppet hoppas över.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 6468f0e9c..36e5aa9e2 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Yayımlanan tarife; vergi, koşullar ve ek ücretler uygulanabilir. Şarj kartı fiyatınız farklı olabilir.', 'roadtrip.charging.coverage': 'Açık veri; kapsam bölge ve operatöre göre değişir.', + 'roadtrip.charging.availability': 'Müsaitlik ve tarifeler', 'roadtrip.import.title': 'Google Maps rotasını içe aktar', 'roadtrip.import.note': 'Duraklar seçilen güne eklenir. TREK rotayı yeniden hesaplar.', 'roadtrip.import.unresolved': 'Konum bulunamadı; bu durak atlanacak.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index fae085096..bacf5a02b 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Опублікований тариф; можливі податки, умови та доплати. Ціна за вашою карткою може відрізнятися.', 'roadtrip.charging.coverage': 'Відкриті дані; охоплення залежить від регіону й оператора.', + 'roadtrip.charging.availability': 'Доступність і тарифи', 'roadtrip.import.title': 'Імпорт маршруту Google Maps', 'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.', 'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index f6762630d..123c16147 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.note': 'Biểu giá công bố; có thể áp dụng thuế, điều kiện và phụ phí. Giá thẻ sạc của bạn có thể khác.', 'roadtrip.charging.coverage': 'Dữ liệu mở; phạm vi phụ thuộc khu vực và nhà vận hành.', + 'roadtrip.charging.availability': 'Tình trạng còn trống và biểu giá', 'roadtrip.import.title': 'Nhập tuyến đường Google Maps', 'roadtrip.import.note': 'Các điểm dừng được thêm vào ngày đã chọn. TREK tính lại tuyến đường.', 'roadtrip.import.unresolved': 'Không tìm thấy vị trí; điểm dừng này sẽ bị bỏ qua.', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 48b444273..8b0a889d9 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.prices': '公布的費率', 'roadtrip.charging.note': '公布的費率可能附帶稅費、條件和額外費用。您的充電卡價格可能不同。', 'roadtrip.charging.coverage': '開放資料,涵蓋範圍因地區和營運商而異。', + 'roadtrip.charging.availability': '可用狀態與費率', 'roadtrip.import.title': '匯入 Google Maps 路線', 'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。', 'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 2b7347034..445804b7d 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.charging.prices': '公布的费率', 'roadtrip.charging.note': '公布的费率可能附带税费、条件和额外费用。您的充电卡价格可能不同。', 'roadtrip.charging.coverage': '开放数据,覆盖范围因地区和运营商而异。', + 'roadtrip.charging.availability': '可用状态与费率', 'roadtrip.import.title': '导入 Google Maps 路线', 'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。', 'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。', diff --git a/shared/src/roadtrip/charging.schema.spec.ts b/shared/src/roadtrip/charging.schema.spec.ts new file mode 100644 index 000000000..8ee5152b0 --- /dev/null +++ b/shared/src/roadtrip/charging.schema.spec.ts @@ -0,0 +1,24 @@ +import { chargingLookupSchema } from './charging.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Charging station lookup by coordinate', () => { + const request = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' }; + it('accepts a bounded coordinate and a nameless station', () => { + expect(chargingLookupSchema.safeParse(request).success).toBe(true); + // OSM charging nodes are regularly untagged; the matcher has its own answer for + // that case, so an empty name must reach it rather than being refused here. + expect(chargingLookupSchema.safeParse({ ...request, name: '' }).success).toBe(true); + }); + it('refuses coordinates off the globe and an unbounded name', () => { + expect(chargingLookupSchema.safeParse({ ...request, lat: 91 }).success).toBe(false); + expect(chargingLookupSchema.safeParse({ ...request, lng: -181 }).success).toBe(false); + expect(chargingLookupSchema.safeParse({ ...request, lat: Number.NaN }).success).toBe(false); + expect(chargingLookupSchema.safeParse({ ...request, name: 'x'.repeat(201) }).success).toBe(false); + }); + it('has no batch form: the array a caller might send is not a station', () => { + // The cache key upstream is one station, and a cache miss costs several requests + // against a shared public registry. Anything list-shaped is refused outright. + expect(chargingLookupSchema.safeParse([request]).success).toBe(false); + }); +}); diff --git a/shared/src/roadtrip/charging.schema.ts b/shared/src/roadtrip/charging.schema.ts index df9369a56..9dd43dff3 100644 --- a/shared/src/roadtrip/charging.schema.ts +++ b/shared/src/roadtrip/charging.schema.ts @@ -30,3 +30,28 @@ export const chargingInfoSchema = z.object({ pricesUnavailable: z.boolean(), }); export type ChargingInfo = z.infer; + +/** + * A charging station addressed by where it is, rather than by a row in `places`. + * + * The saved-stop route answers for a place id, which a hit found along the route does + * not have yet, and "is anything free here, and what does it cost" is the question + * people want answered before adding the charger, not after. The match needs nothing + * more than this: a coordinate, and whatever the search called the place. + * + * One station per request, deliberately. A cache miss costs the upstream registry + * several requests, nothing rate-limits the route, and the caches on both sides of it + * are sized for the handful of stops a trip has. A batch would turn one click into a + * burst against shared public infrastructure. + */ +export const chargingLookupSchema = z.object({ + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), + /** + * An empty name is allowed. OSM charging nodes are frequently nameless, and the + * matcher already treats "nothing to go on" as its own case: it then insists the + * station be within 30 m instead of accepting the nearest one inside 100 m. + */ + name: z.string().max(200), +}); +export type ChargingLookup = z.infer; From b058b63f6e2293292dd0ca547a02b52a6b695615 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 13:20:21 +0200 Subject: [PATCH 4/5] fix(roadtrip): the manual-stop dialog looped on a hint rebuilt per render Three things the first CI run found, all of them in the new tests rather than in what they cover. The dialog's search effect depended on the location hint object. Its own hook memoises, so nothing looped in the app, but the effect sets state on a query too short to ask about, and a caller whose hook hands back a fresh literal per render turns that pair into a render loop that never yields. The suite mocked it exactly that way, and the worker hung before a single test reported, which is what took the client coverage job past half an hour. The hint is three numbers, so the effect now turns on those and reads the value out of a ref when the request goes out. The mock hands back one object, the way the real hook does. The suite also installed fake timers in a beforeEach, which deadlocks against React's scheduler and Testing Library's own waiting. Every wait is a waitFor now, and the two cases that have to prove something did not happen sit out a real pause. The debounce boundary is no longer asserted against a wall clock, because a busy runner stretches any such window. The charging fixtures are pinned to a fixed instant and the service reads the real clock against a twenty-minute freshness window, so every answer through it was stale by construction and available came back null. Date.now is pinned for those tests, never the timers, since the requests carry an AbortSignal.timeout that has to keep running. And the unidentified-station test counted its two "no reliable data" lines before the answer landed: the price line already says it while the request is out, so the count settled on one and passed for the wrong reason. --- .../components/Roadtrip/ChargingInfo.test.tsx | 5 +- .../Roadtrip/RoadtripManualStopModal.test.tsx | 55 ++++++++++++------- .../Roadtrip/RoadtripManualStopModal.tsx | 19 ++++++- server/tests/unit/nest/charging.test.ts | 17 +++++- 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/client/src/components/Roadtrip/ChargingInfo.test.tsx b/client/src/components/Roadtrip/ChargingInfo.test.tsx index 87d48bee8..864c3b615 100644 --- a/client/src/components/Roadtrip/ChargingInfo.test.tsx +++ b/client/src/components/Roadtrip/ChargingInfo.test.tsx @@ -66,7 +66,10 @@ describe('Charging information', () => { render() // Said twice over: once where the free count would be, once where the price would. - expect(await screen.findAllByText('No reliable data available')).toHaveLength(2) + // Counted only once the answer is in. The price line says the same thing while the + // request is still out, so a plain findAllByText settles on that one alone and the + // count would pass for the wrong reason. + await waitFor(() => expect(screen.getAllByText('No reliable data available')).toHaveLength(2)) expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument() }) }) diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx index 41c548c6a..95a81dc3c 100644 --- a/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx @@ -12,9 +12,10 @@ vi.mock('../../api/client', async (importOriginal) => ({ mapsApi: { search }, })) // The hint is the trip's own doing and has its own suite; here it only has to arrive. -vi.mock('../../hooks/useLocationBias', () => ({ - useLocationBias: () => ({ point: { lat: 53, lng: 10, radius: 5000 } }), -})) +// Hoisted to one object rather than built per call, the way the real hook's useMemo +// hands back the same one until the trip's places move. +const BIAS = { point: { lat: 53, lng: 10, radius: 5000 } } +vi.mock('../../hooks/useLocationBias', () => ({ useLocationBias: () => BIAS })) import RoadtripManualStopModal from './RoadtripManualStopModal' @@ -100,20 +101,32 @@ function setup(over: Partial screen.getByLabelText('Search places...') -/** Type, then wait out the debounce and whatever the answer resolves into. */ -async function type(value: string, wait = DEBOUNCE): Promise { - fireEvent.change(queryBox(), { target: { value } }) - await act(async () => { vi.advanceTimersByTime(wait) }) +/** + * Typing, on the real clock. + * + * Deliberately not on a fake one. A faked timer here has to be installed before the + * dialog mounts, and React's own scheduler and Testing Library's waiting helpers both + * run on the same timers, so the pair deadlocks the worker before a single test + * reports. Every assertion below is reached through `waitFor` instead, and the two + * that have to prove something did NOT happen wait out a real pause. + */ +async function type(value: string): Promise { + await act(async () => { fireEvent.change(queryBox(), { target: { value } }) }) } +/** A real pause, long enough for the debounce to have fired if it were going to. */ +const settle = (ms = DEBOUNCE + 200) => act(async () => { await new Promise(resolve => setTimeout(resolve, ms)) }) + +/** The rows of the suggestion list, once the dialog has asked and been answered. */ +const answered = () => waitFor(() => expect(search).toHaveBeenCalled()) + beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) search.mockReset() search.mockResolvedValue({ places: [hit()], source: 'openstreetmap' }) }) afterEach(() => { - vi.useRealTimers() + vi.restoreAllMocks() }) describe('RoadtripManualStopModal', () => { @@ -121,19 +134,23 @@ describe('RoadtripManualStopModal', () => { // A single common word without a region is refused upstream as too expensive to // answer, and asking anyway spends a request to be told so. setup() - await type('Su', DEBOUNCE * 2) + await type('Su') + await settle() expect(search).not.toHaveBeenCalled() }) it('FE-ROADTRIP-MANUAL-002: the search waits for a pause in the typing', async () => { setup() + await type('Supercharger') - fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) - await act(async () => { vi.advanceTimersByTime(DEBOUNCE - 120) }) + // Nothing goes out on the keystroke itself. The pause after it is measured on the + // real clock, so the test asks whether the request is debounced at all rather than + // whether the gap is exactly 320 ms: a busy runner can stretch any wall-clock + // window, and a boundary asserted against one is a flake waiting to happen. expect(search).not.toHaveBeenCalled() - await act(async () => { vi.advanceTimersByTime(120) }) + await answered() expect(search).toHaveBeenCalledTimes(1) // The query, the locale, and the hint that decides which of a thousand identically // named places is meant. @@ -143,13 +160,12 @@ describe('RoadtripManualStopModal', () => { it('FE-ROADTRIP-MANUAL-003: keystroke after keystroke is one question, not one each', async () => { setup() - fireEvent.change(queryBox(), { target: { value: 'Sup' } }) - await act(async () => { vi.advanceTimersByTime(100) }) - fireEvent.change(queryBox(), { target: { value: 'Super' } }) - await act(async () => { vi.advanceTimersByTime(100) }) - fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) - await act(async () => { vi.advanceTimersByTime(DEBOUNCE) }) + await type('Sup') + await type('Super') + await type('Supercharger') + await answered() + await settle() expect(search).toHaveBeenCalledTimes(1) expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.anything()) }) @@ -278,6 +294,7 @@ describe('RoadtripManualStopModal', () => { search.mockRejectedValue(new Error('offline')) setup() await type('Supercharger') + await answered() // The rejection is swallowed on purpose (it is a typeahead, not a submit), but the // list has to stop saying it is loading. diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx index 7e1365eeb..fa6f7f21f 100644 --- a/client/src/components/Roadtrip/RoadtripManualStopModal.tsx +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx @@ -126,6 +126,21 @@ export default function RoadtripManualStopModal({ return () => document.removeEventListener('mousedown', onPointer) }, [listOpen]) + /** + * The search hint, held by content rather than by identity. + * + * `useLocationBias` rebuilds its answer whenever the trip's places change, and the + * effect below has to re-ask when the hint really moves. Depending on the object + * itself makes that a question about identity instead: any caller whose hook hands + * back a fresh literal per render turns the effect into a render loop, because the + * effect sets state on a short query and the next render brings a new object. The + * hint is three numbers, so the key is those three numbers, and the value itself is + * read out of a ref at the moment the request actually goes out. + */ + const biasKey = locationBias ? `${locationBias.lat},${locationBias.lng},${locationBias.radius ?? ''}` : '' + const biasRef = useRef(locationBias) + biasRef.current = locationBias + // Debounced, and with a cancelled flag rather than a bare `.then(setState)`: the // endpoint takes no signal, so the only way a stale answer cannot overwrite a newer // one is for the run it belongs to to know it has been superseded. @@ -140,7 +155,7 @@ export default function RoadtripManualStopModal({ let cancelled = false debounceRef.current = setTimeout(() => { setLoading(true) - mapsApi.search(trimmed, locale, locationBias) + mapsApi.search(trimmed, locale, biasRef.current) .then(data => { if (cancelled) return setResults(data.places.map(toHit).filter((hit): hit is PlaceHit => hit !== null)) @@ -158,7 +173,7 @@ export default function RoadtripManualStopModal({ cancelled = true if (debounceRef.current) clearTimeout(debounceRef.current) } - }, [query, picked, locale, locationBias]) + }, [query, picked, locale, biasKey]) /** * Every leg of every routed day, flat. diff --git a/server/tests/unit/nest/charging.test.ts b/server/tests/unit/nest/charging.test.ts index 926bb4ea6..aa7b85dbe 100644 --- a/server/tests/unit/nest/charging.test.ts +++ b/server/tests/unit/nest/charging.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { chargingLookupSchema } from '@trek/shared'; import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service'; import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller'; @@ -70,6 +70,21 @@ describe('Open charging data', () => { * difference is how the station is named. */ describe('Open charging data before the stop exists', () => { + /** + * The clock, pinned to the instant the fixtures above are written at. + * + * `normalizeCharging` takes the current time as an argument and the tests further up + * pass it; the service does not, so it reads the real one, and its freshness window is + * twenty minutes. Left alone, every answer that goes through the service is stale by + * construction and `available` comes back null, which is the field this is here to + * prove survives the trip from the registry to the dialog. + * + * Only `Date.now` is pinned, never the timers: the outbound requests carry an + * `AbortSignal.timeout` that has to keep running. + */ + beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(now); }); + afterEach(() => { vi.restoreAllMocks(); }); + const answers = (tariffs: unknown) => vi.fn(async (url: string) => { if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] })); if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 })); From 54b94cf9e3896c0b5c5ad1861f494e6c5f30312e Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 13:22:26 +0200 Subject: [PATCH 5/5] docs(wiki): the manual stop, clearing a search, and charging before the add Three panel changes and one new MCP tool that the pages did not describe yet. Also records the two rules the code enforces and a reader would otherwise have to discover: the charging panel is fetched once per opened dialog rather than per row of a result list, and the lookup tool carries no power rating or socket count because those are OpenStreetMap tag data that travels with the corridor result instead. The wiki syncs on a push to main, so this rides along until the next release rather than appearing now. --- wiki/MCP-Addon-Tools.md | 2 ++ wiki/Trip-Planner-Overview.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index df086f90a..10dce5e43 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -249,3 +249,5 @@ Roadtrip corridor searches include installed search-provider plugins. Providers Google Maps directions links can be reviewed with preview_google_maps_route and saved with import_google_maps_route. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied. get_roadtrip_charging_info accepts tripId and placeId. It requires trip access and the Roadtrip addon. It reports matching failures, unavailable feeds, freshness, known free capacity and unknown status counts separately. Published tariff components include currency, tax handling and conditions; they do not estimate a user's roaming price. Data source: [MobiData BW OCPDB](https://api.mobidata-bw.de/), with per-source attribution from its public source registry. + +lookup_roadtrip_charging_info answers the same question for a station that is not on the trip, by coordinate and name rather than by place id, which is what a hit found along the route has. It takes tripId, lat, lng and name, requires the same trip access and the same addon, and returns the same fields with the same caveats. The trip is there for the access check only and does not narrow the search. One station per call: it shares its cache with the saved-stop tool, and the registry behind it is public infrastructure. It carries no power rating and no socket count; those are OpenStreetMap tag data and travel with the corridor search result instead. diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index f70b928c6..35d70f486 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -106,7 +106,11 @@ Driving settings includes **Show hazard areas**, off by default and shared by th Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`. -**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results. +**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results. It opens on Charging when the trip's vehicle is electric and on Fuel otherwise, and the choice is yours from then on. + +Clicking a result brings it into view on the map, which is how you tell which side of the road it is on. **Clear results** above the list empties both the list and its pins, along with the name, section, plug and power filters. + +**Add manually**, beside Search, is for the stop the search does not know about: a good share of the chargers standing at a junction are in no OpenStreetMap extract. Look the place up by name and TREK works out which leg of which day it belongs on, measured against the drawn route. **Add between** offers every leg of every routed day if that guess is wrong, and a place well away from the route is accepted rather than refused, with a note saying how far off it sits. From there it opens the same dialog a found result does, so the kind of stop and the time spent there are chosen in one place. > **AI / MCP:** `search_roadtrip_corridor` uses the same combined sources and reports `failedSources`. @@ -116,7 +120,9 @@ In Roadtrip, open the three-dot menu beside the day selector to import a Google Roadtrip charging stops show compact availability and published energy-price badges. The place detail panel adds source attribution, timestamps and tariff conditions. Data comes from the public MobiData BW OCPDB aggregation, including participating German operators and Swiss data, without an API key or account. Coverage varies; an unknown or stale status does not mean a charger is free. Prices are published source tariffs, not personal charging-card quotes. Data refreshes while Roadtrip is visible and is not advertised as live offline. -> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. +The same panel appears in the dialog that adds a station found along the route, so availability and price can be read before the stop exists. It is fetched once per opened dialog and only for a stop being added as a charging stop, never per row of the result list. + +> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. lookup_roadtrip_charging_info reads it for a station that is not on the trip, named by coordinate instead of by place id. GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable.