From accdd5905e2acb34850c9b1f15b34cb9cc4fb803 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 12:28:30 +0200 Subject: [PATCH] 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': '其他路线',