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.
This commit is contained in:
Maurice
2026-09-14 12:28:30 +02:00
parent f4bbb0fe5d
commit accdd5905e
33 changed files with 1504 additions and 63 deletions
@@ -110,11 +110,23 @@ function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<Roadtrip
visible: searchState.results,
insertIndexFor: vi.fn(() => 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(<RoadtripCorridorPanel corridor={nothing} routes={routes([day(1, 1)])} />)
// 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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
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(<RoadtripCorridorPanel corridor={reader} routes={routes([day(1, 1)])} />)
expect(searchRow()).toHaveLength(1)
unmount()
const onAddManual = vi.fn()
const c = corridor()
wrap(
<RoadtripCorridorPanel
corridor={c}
routes={routes([day(1, 1)])}
onAddPoi={vi.fn()}
onAddManual={onAddManual}
/>,
)
// 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(<RoadtripCorridorPanel corridor={dayless} routes={routes([])} onAddPoi={vi.fn()} onAddManual={vi.fn()} />)
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(
<RoadtripCorridorPanel
corridor={c}
routes={routes([day(1, 1)])}
onAddPoi={onAddPoi}
onFocusPoint={onFocusPoint}
/>,
)
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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// 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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
@@ -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
* `<li>` 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 (
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
<ResultBadge category={poi.category} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{poi.name}
</div>
{poi.pluginId && <p className="truncate text-caption text-content-muted">{poi.pluginId}</p>}
{/* 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. */}
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
<span className={POI_CHIP}>
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
</span>
<span className={POI_CHIP}>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* 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 ? (
<span className="flex flex-wrap items-baseline gap-x-1.5">
{poi.charging.sockets.slice(0, 3).map(s => (
<span key={s.type} className="text-content-muted">
{SOCKET_LABEL[s.type] ?? s.type}
{s.kw ? ` ${s.kw} kW` : ''}
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
</span>
))}
<Block
type={onFocus ? 'button' : undefined}
onClick={onFocus}
// The hover surface stays the whole row: this only carries the focus ring, so
// a keyboard lands on the same thing the pointer highlights.
className={`flex min-w-0 flex-1 items-center gap-3 text-start ${onFocus ? 'rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' : ''}`}
>
<ResultBadge category={poi.category} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{poi.name}
</div>
{poi.pluginId && <p className="truncate text-caption text-content-muted">{poi.pluginId}</p>}
{/* 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. */}
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
<span className={POI_CHIP}>
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
</span>
) : null}
{poi.charging?.fee === false ? (
<span className="text-success">{t('roadtrip.poi.free')}</span>
) : null}
<span className={POI_CHIP}>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* 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 ? (
<span className="flex flex-wrap items-baseline gap-x-1.5">
{poi.charging.sockets.slice(0, 3).map(s => (
<span key={s.type} className="text-content-muted">
{SOCKET_LABEL[s.type] ?? s.type}
{s.kw ? ` ${s.kw} kW` : ''}
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
</span>
))}
</span>
) : null}
{poi.charging?.fee === false ? (
<span className="text-success">{t('roadtrip.poi.free')}</span>
) : null}
</div>
</div>
</div>
</Block>
{/* 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}
/>
))}
</ul>
@@ -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
</div>
</div>
{/* 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. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
className="flex h-[32px] w-full items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} aria-hidden />}
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
</button>
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. */}
<div className="flex gap-2">
<button
type="button"
onClick={search.search}
disabled={!canSearch}
className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="shrink-0 animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} className="shrink-0" aria-hidden />}
{/* The label grows into "Searching 3 of 12" while a run is on. Sharing the
row it has to shorten rather than push its neighbour off the panel, and
the panel is resizable down to a width where that matters. */}
<span className="min-w-0 truncate">
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
</span>
</button>
{/* 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 ? (
<button
type="button"
onClick={() => setManualOpen(true)}
className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg border border-edge bg-surface-card text-body font-semibold text-content transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<Plus size={15} strokeWidth={2.2} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{t('roadtrip.poi.addManual')}</span>
</button>
) : null}
</div>
</div>
{/* 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 ? (
<div className="flex-shrink-0 border-b border-edge-faint p-3">
{/* 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. */}
<div className="mb-2 flex items-center gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>
{t('roadtrip.poi.found', { count: search.results.length })}
</span>
<button
type="button"
onClick={corridor.clear}
className="ms-auto inline-flex h-[22px] shrink-0 items-center gap-1 rounded-lg px-1.5 font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
style={{ fontSize: FS.control }}
>
<X size={12} strokeWidth={2.2} aria-hidden />
{t('roadtrip.poi.clearResults')}
</button>
</div>
<div className="relative">
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
<input
@@ -593,12 +681,25 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, trip
dayId={corridor.day?.dayId ?? null}
insertIndexFor={corridor.insertIndexFor}
onAddPoi={onAddPoi}
onFocusPoint={onFocusPoint}
/>
))}
</>
)}
</div>
</div>
{manualOpen && onAddManual ? (
<RoadtripManualStopModal
routes={routes}
dayId={corridor.day?.dayId ?? null}
targetFor={manualStopTargetFor}
onClose={() => 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}
</div>
)
}
@@ -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<typeof import('../../api/client')>()),
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<string, unknown> = {}) => ({
name: 'Supercharger Rasthof',
address: 'A1, Dammer Berge',
lat: 52.5,
lng: 8.2,
osm_id: 'node/9',
...over,
})
function setup(over: Partial<React.ComponentProps<typeof RoadtripManualStopModal>> = {}) {
const onSubmit = vi.fn()
const onClose = vi.fn()
const targetFor = vi.fn(() => ({ dayId: 1, position: 2, offRouteKm: 0.4 }))
render(
<TranslationProvider>
<RoadtripManualStopModal
routes={threeLegs()}
dayId={1}
targetFor={targetFor}
onClose={onClose}
onSubmit={onSubmit}
{...over}
/>
</TranslationProvider>,
)
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<void> {
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()
})
})
@@ -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<string, unknown>): 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<PlaceHit[]>([])
const [listOpen, setListOpen] = useState(false)
const [highlight, setHighlight] = useState(-1)
const [loading, setLoading] = useState(false)
const [answered, setAnswered] = useState(false)
const [picked, setPicked] = useState<PlaceHit | null>(null)
const [chosenLeg, setChosenLeg] = useState('')
const [offRouteKm, setOffRouteKm] = useState<number | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<ManualStopTarget | null>(() => {
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<HTMLInputElement>): 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 (
<Modal
isOpen
onClose={onClose}
title={t('roadtrip.poi.addManual')}
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="h-[34px] rounded-lg border border-edge bg-surface-card px-3 text-body font-medium text-content transition-colors hover:bg-surface-hover"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={submit}
disabled={!picked || !target}
className="h-[34px] rounded-lg bg-accent px-3.5 text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{t('common.add')}
</button>
</div>
)}
>
<div className="flex flex-col gap-3.5">
<p className="text-caption text-content-muted">{t('roadtrip.poi.manualHint')}</p>
<div ref={wrapRef} className="relative">
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
<input
type="text"
value={query}
onChange={e => {
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 ? (
<button
type="button"
onClick={clearPick}
aria-label={t('common.clear')}
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={13} aria-hidden />
</button>
) : null}
{listOpen && (loading || results.length > 0 || answered) ? (
<div className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 max-h-[240px] overflow-y-auto rounded-xl border border-edge bg-surface-elevated shadow-dropdown">
{loading && results.length === 0 ? (
<p className="px-3 py-2.5 text-caption text-content-faint">{t('common.loading')}</p>
) : null}
{!loading && results.length === 0 && answered ? (
<p className="px-3 py-2.5 text-caption text-content-faint">{t('roadtrip.poi.manualNoResults')}</p>
) : null}
{results.map((hit, i) => (
<button
key={`${hit.osm_id || hit.name}:${hit.lat},${hit.lng}`}
type="button"
onClick={() => pick(hit)}
onMouseEnter={() => setHighlight(i)}
className={`flex w-full items-start gap-2 px-3 py-2 text-start transition-colors ${i === highlight ? 'bg-surface-hover' : ''}`}
>
<MapPin size={12} className="mt-0.5 shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-content" style={{ fontSize: FS.name }}>{hit.name}</span>
{hit.address && hit.address !== hit.name ? (
<span className="block truncate text-caption text-content-faint">{hit.address}</span>
) : null}
</span>
</button>
))}
</div>
) : null}
</div>
{/* 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 ? (
<div className="flex flex-col gap-2">
<span className="font-geist font-semibold uppercase tracking-[0.15em] text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.poi.addBetween')}
</span>
{legs.length > 0 ? (
<CustomSelect
value={leg}
onChange={value => setChosenLeg(String(value))}
options={legs.map(l => ({ value: l.value, label: l.label, badge: l.badge }))}
size="sm"
menuFit="content"
/>
) : (
<p className="text-caption text-content-muted">
{t('roadtrip.poi.manualAppend', { number: appendDayNumber })}
</p>
)}
{/* 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 ? (
<p className="text-caption text-warning">
{t('roadtrip.poi.manualOffRoute', { distance: formatDistance(offRouteKm, distanceUnit) })}
</p>
) : null}
</div>
) : null}
</div>
</Modal>
)
}
@@ -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
}
@@ -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.
@@ -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<string>('')
const [categories, setCategories] = useState<string[]>(['fuel'])
const [widthKm, setWidthKm] = useState<number>(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,
}
}
+4
View File
@@ -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. */}
@@ -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<Record<string, unknown>>,
insertIndexFor: vi.fn(() => 1),
stopsAlongKm: [] as number[],
clear: vi.fn(),
}
const alt = {
open: null as null | Record<string, unknown>,
@@ -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> = {}): 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,
+79 -1
View File
@@ -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,
+8
View File
@@ -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': 'مسارات أخرى',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Άλλες διαδρομές',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': '別のルート',
+7
View File
@@ -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': '다른 경로',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Другие маршруты',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Інші маршрути',
+8
View File
@@ -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',
+7
View File
@@ -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': '其他路線',
+7
View File
@@ -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': '其他路线',