Merge pull request #2366 from liketrek/fix/roadtrip-feedback

feat(roadtrip): the testers' round, and a defect it uncovered
This commit is contained in:
Maurice
2026-09-14 13:33:36 +02:00
committed by GitHub
55 changed files with 2260 additions and 98 deletions
+20 -4
View File
@@ -649,6 +649,19 @@ export interface RouteAlternative {
divergence: { lat: number; lng: number } | null
/** Set when this way exists because a road class was left out of it. */
avoids?: AvoidClass
/**
* Which engine priced this route, when it was not the one that priced the rest.
*
* Absent for everything OSRM answered, which is every route in a list except the
* avoidance offer on a default install. It exists because the two engines do not
* agree on speed: measured over twelve European legs the per-leg drive times run
* from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region:
* Valhalla is faster on Spanish autovía and slower through a city. So a figure from
* one of them subtracted from a figure from the other is not a difference in
* driving time, it is the gap between two speed models, and the reader has no way
* of telling the two apart.
*/
engine?: 'valhalla'
}
/**
@@ -684,10 +697,12 @@ const SAME_ROAD_KM = 0.5
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently — measured over ten European legs
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
* Two identical blue lines on the map is exactly what that looks like.
* started answering. Valhalla prices roads differently: measured over twelve European
* legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back
* thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical
* blue lines on the map is exactly what that looks like. Duration is the worse of the
* two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 %
* over, with the sign set by the region rather than by the road.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
@@ -778,6 +793,7 @@ async function valhallaExcluding(
distance: leg.distance,
duration: leg.duration,
divergence: null,
engine: 'valhalla',
}
}
+8 -4
View File
@@ -22,10 +22,14 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
* until a search radius is allowed;
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
* above OSRM's on average and 36 % above on one, and those times feed the arrival
* clocks of a road trip day. Mixing engines inside one chain would move times
* nobody asked to move.
* - it prices roads differently, and not by a constant anybody could correct for.
* Over twelve European legs the per-leg drive time ran from 14.7 % under OSRM's to
* 13.2 % over, and the sign follows the region rather than the road: on Spanish
* autovía Valhalla is the faster of the two on every leg measured, through a city
* it is half again slower. Those times feed the arrival clocks of a road trip day,
* so mixing engines inside one chain would move times nobody asked to move, and
* subtracting one engine's figure from the other's, which is what an offer beside
* a route invites, measures the gap between two speed models and nothing else.
*
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
* on demand, drawn as an offer next to the route rather than replacing it.
@@ -1,13 +1,14 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import ChargingInfo from './ChargingInfo'
import { chargingRepo } from '../../repo/chargingRepo'
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } }))
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false }
beforeEach(() => { vi.clearAllMocks() })
describe('Charging information', () => {
it('shows availability, attribution and missing prices without inventing a price', async () => {
vi.mocked(chargingRepo.read).mockResolvedValue(info)
@@ -23,4 +24,52 @@ describe('Charging information', () => {
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
})
// A station found along the route has no place row yet, and the availability and the
// price are what somebody wants before deciding to add it.
it('FE-ROADTRIP-CHARGING-010: a station that is not a stop yet is asked for by coordinate', async () => {
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord')
expect(chargingRepo.read).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CHARGING-011: pointed at another station, it asks again', async () => {
// The coordinate is the identity here. Left out of the effect's dependencies the
// panel would keep showing the first charger's availability for the second one.
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
const { rerender } = render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
await screen.findByText('2/4 available')
rerender(<TranslationProvider><ChargingInfo lat={49.013} lng={12.101} name="Autohof Sued" /></TranslationProvider>)
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenLastCalledWith(1, 49.013, 12.101, 'Autohof Sued'))
expect(chargingRepo.readAt).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-CHARGING-012: a saved stop is still asked for by place id', async () => {
vi.mocked(chargingRepo.read).mockResolvedValue(info)
render(<TranslationProvider><ChargingInfo placeId={7} compact /></TranslationProvider>)
expect(await screen.findByText('2/4')).toBeInTheDocument()
expect(chargingRepo.read).toHaveBeenCalledWith(1, 7)
expect(chargingRepo.readAt).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CHARGING-013: an unidentified station is never shown as free', async () => {
// `available` is null whenever the answer is not both fresh and countable, and an
// unknown status is not spare capacity. Outside the covered regions this is the
// usual answer, so it is the one the dialog has to get right.
vi.mocked(chargingRepo.readAt).mockResolvedValue({ ...info, status: 'ambiguous', available: null, total: 0, station: null })
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
// Said twice over: once where the free count would be, once where the price would.
// Counted only once the answer is in. The price line says the same thing while the
// request is still out, so a plain findAllByText settles on that one alone and the
// count would pass for the wrong reason.
await waitFor(() => expect(screen.getAllByText('No reliable data available')).toHaveLength(2))
expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument()
})
})
@@ -7,7 +7,20 @@ import { chargingRepo } from '../../repo/chargingRepo'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { Tooltip } from '../shared/Tooltip'
export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) {
/**
* Which station the panel is asking about, one way or the other.
*
* A stop on the trip is named by its `places` row. A station found along the route has
* no row yet, and the availability and the price are exactly what somebody wants before
* deciding to add it, so that one is named by where it is instead. The two are mutually
* exclusive: the optional `undefined` members are what stop a caller passing both and
* leaving it to the component to guess which it meant.
*/
type ChargingTarget =
| { placeId: number; lat?: undefined; lng?: undefined; name?: undefined }
| { placeId?: undefined; lat: number; lng: number; name: string }
export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore(s => s.trip?.id)
const { t } = useTranslation()
const [info, setInfo] = useState<Info | null>(null)
@@ -17,7 +30,12 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
setInfo(null); setLoading(true)
const refresh = async () => {
if (!tripId || document.hidden) return
try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) }
try {
const value = placeId != null
? await chargingRepo.read(tripId, placeId)
: await chargingRepo.readAt(tripId, lat!, lng!, name!)
if (active) setInfo(value)
}
catch { if (active) setInfo(null) }
finally { if (active) setLoading(false) }
}
@@ -28,7 +46,9 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
window.addEventListener('online', refresh)
document.addEventListener('visibilitychange', refresh)
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
}, [tripId, placeId])
// The identity of the station, whichever way it was given: a changed coordinate is a
// different charger and has to re-ask, exactly as a changed place id does.
}, [tripId, placeId, lat, lng, name])
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
@@ -25,6 +25,7 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
duration: 10_800,
distance: 290_000,
slowerThanQuickest: 0,
otherEngine: false,
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
@@ -142,4 +143,19 @@ describe('RoadtripAlternativesBar', () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalled()
})
it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[
overlay({ note: 'Fastest' }),
overlay({ index: 1, note: 'No tolls', otherEngine: true }),
]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1)
})
})
@@ -1,6 +1,7 @@
import React from 'react'
import { Shuffle, X, AlertTriangle } from 'lucide-react'
import { Shuffle, X, AlertTriangle, Info } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
@@ -92,6 +93,17 @@ export default function RoadtripAlternativesBar({
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
</span>
</span>
{/* Only where the drive time on the map came from the other engine. The
road and the length are the same question either way; the time is not,
and two engines' times sitting next to each other on the map invite a
subtraction that means nothing. Saying so here, where the choice is
actually made, is cheaper than a second request per offer to restate
the whole list in one engine's terms. */}
{alt.otherEngine ? (
<Tooltip label={t('roadtrip.alt.otherEngine')}>
<Info size={12} className="shrink-0 text-content-faint" aria-label={t('roadtrip.alt.otherEngine')} />
</Tooltip>
) : null}
</button>
))}
</div>
@@ -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,304 @@
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.
// Hoisted to one object rather than built per call, the way the real hook's useMemo
// hands back the same one until the trip's places move.
const BIAS = { point: { lat: 53, lng: 10, radius: 5000 } }
vi.mock('../../hooks/useLocationBias', () => ({ useLocationBias: () => BIAS }))
import RoadtripManualStopModal from './RoadtripManualStopModal'
/** 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...')
/**
* Typing, on the real clock.
*
* Deliberately not on a fake one. A faked timer here has to be installed before the
* dialog mounts, and React's own scheduler and Testing Library's waiting helpers both
* run on the same timers, so the pair deadlocks the worker before a single test
* reports. Every assertion below is reached through `waitFor` instead, and the two
* that have to prove something did NOT happen wait out a real pause.
*/
async function type(value: string): Promise<void> {
await act(async () => { fireEvent.change(queryBox(), { target: { value } }) })
}
/** A real pause, long enough for the debounce to have fired if it were going to. */
const settle = (ms = DEBOUNCE + 200) => act(async () => { await new Promise(resolve => setTimeout(resolve, ms)) })
/** The rows of the suggestion list, once the dialog has asked and been answered. */
const answered = () => waitFor(() => expect(search).toHaveBeenCalled())
beforeEach(() => {
search.mockReset()
search.mockResolvedValue({ places: [hit()], source: 'openstreetmap' })
})
afterEach(() => {
vi.restoreAllMocks()
})
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')
await settle()
expect(search).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-MANUAL-002: the search waits for a pause in the typing', async () => {
setup()
await type('Supercharger')
// Nothing goes out on the keystroke itself. The pause after it is measured on the
// real clock, so the test asks whether the request is debounced at all rather than
// whether the gap is exactly 320 ms: a busy runner can stretch any wall-clock
// window, and a boundary asserted against one is a flake waiting to happen.
expect(search).not.toHaveBeenCalled()
await answered()
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()
await type('Sup')
await type('Super')
await type('Supercharger')
await answered()
await settle()
expect(search).toHaveBeenCalledTimes(1)
expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.anything())
})
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')
await answered()
// The rejection is swallowed on purpose (it is a typeahead, not a submit), but the
// list has to stop saying it is loading.
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
})
})
@@ -0,0 +1,407 @@
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])
/**
* The search hint, held by content rather than by identity.
*
* `useLocationBias` rebuilds its answer whenever the trip's places change, and the
* effect below has to re-ask when the hint really moves. Depending on the object
* itself makes that a question about identity instead: any caller whose hook hands
* back a fresh literal per render turns the effect into a render loop, because the
* effect sets state on a short query and the next render brings a new object. The
* hint is three numbers, so the key is those three numbers, and the value itself is
* read out of a ref at the moment the request actually goes out.
*/
const biasKey = locationBias ? `${locationBias.lat},${locationBias.lng},${locationBias.radius ?? ''}` : ''
const biasRef = useRef(locationBias)
biasRef.current = locationBias
// Debounced, and with a cancelled flag rather than a bare `.then(setState)`: the
// endpoint takes no signal, so the only way a stale answer cannot overwrite a newer
// one is for the run it belongs to to know it has been superseded.
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, biasRef.current)
.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, biasKey])
/**
* 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>
)
}
@@ -1,12 +1,28 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
import { chargingRepo } from '../../repo/chargingRepo'
import type { CorridorPoi } from './useCorridorPois'
// The dialog carries the charging panel, which reads the open availability data for a
// station that is not on the trip yet. Both of its collaborators are stubbed: the trip
// is whichever one is open, and the data comes off the network.
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
const charging = {
checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord',
source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0',
updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0,
tariffs: [], pricesUnavailable: false,
}
beforeEach(() => { vi.clearAllMocks() })
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
return {
osm_id: 'node:9',
@@ -116,15 +132,24 @@ describe('RoadtripStopPopup', () => {
['sights', 'Sights'],
]
/**
* One kind pill, by the name on it.
*
* Filtered on `aria-pressed` rather than taken as the only match: for a charging hit
* the dialog also carries the charging panel, whose `<summary>` reads "Charging" too.
*/
const kindPill = (label: string): HTMLElement =>
screen.getAllByRole('button', { name: new RegExp(label) }).filter(el => el.hasAttribute('aria-pressed'))[0]
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
expect(kindPill(label)).toHaveAttribute('aria-pressed', 'true')
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
for (const [, other] of CORRIDOR_KINDS) {
if (other === label) continue
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
expect(kindPill(other)).toHaveAttribute('aria-pressed', 'false')
}
unmount()
})
@@ -270,6 +295,56 @@ describe('RoadtripStopPopup', () => {
}
})
// ── Charging, before the add ───────────────────────────────────────────────
//
// The data was only reachable once the charger was on the trip, which is the wrong
// way round: how busy the place is and what it charges is what decides whether to
// add it at all.
const chargingDraft = () => draft({ poi: poi({ category: 'charging', name: 'Ladepark Nord', lat: 48.137, lng: 11.575 }) })
it('FE-ROADTRIP-STOPPOPUP-030: a charging hit is asked about by where it is', async () => {
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
// By coordinate and name: there is no place row to name it by yet.
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord'))
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
// The licence line travels with the numbers wherever they are shown.
expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com')
})
it('FE-ROADTRIP-STOPPOPUP-031: a fuel stop costs nothing', () => {
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(chargingRepo.readAt).not.toHaveBeenCalled()
expect(screen.queryByText('Published tariffs')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-032: the question follows the kind that is picked', async () => {
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(chargingRepo.readAt).not.toHaveBeenCalled()
fireEvent.click(kindPill('Charging'))
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
fireEvent.click(kindPill('Fuel'))
expect(screen.queryByText('2/4 available')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-033: one station, one question, however much else is changed', async () => {
// A cache miss upstream costs several requests against a shared public registry, so
// the dialog asks once and re-rendering it for an unrelated answer must not re-ask.
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
expect(chargingRepo.readAt).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
// leads nowhere.
@@ -3,6 +3,7 @@ import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react
import Modal from '../shared/Modal'
import CustomTimePicker from '../shared/CustomTimePicker'
import StayPortals from './StayPortals'
import ChargingInfo from './ChargingInfo'
import { useTranslation } from '../../i18n/TranslationContext'
import { safeExternalHref } from '../../utils/safeUrl'
import { formatDurationShort } from './roadtripModel'
@@ -266,6 +267,26 @@ export default function RoadtripStopPopup({
))}
</div>
</div>
{/* How busy the charging area is and what it charges, while the stop is still
being considered rather than once it is on the trip. Only for the kind that
has an answer: a petrol station or a bakery asks nothing, so picking either
of those costs no request at all.
One panel for one station, for as long as the dialog is open. The corridor
list deliberately does not do this per row: it publishes results box by
box, each row would own its own refresh timer, and a station in a dense
row of chargers is the case the upstream match refuses as ambiguous. */}
{stopType === 'charging' ? (
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.charging.availability')}
</span>
<div className="mt-1.5">
<ChargingInfo lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} />
</div>
</div>
) : null}
</>
)}
@@ -143,4 +143,81 @@ describe('buildAlternativeOverlays', () => {
)
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
})
it('FE-ALTOVL-011: a route the other engine priced cannot win the quickest election', () => {
// The shape a default install actually produces: OSRM answered with the one road
// it likes, and the toll-free offer beside it came from the avoidance router,
// whose speed model reads a Spanish motorway a seventh faster. Electing across
// the two paints the detour blue and calls the road being driven the slow way
// round, on nothing but that gap.
const out = buildAlternativeOverlays(
[
alt({ duration: 11_880, coordinates: line(20, 0) }),
alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }),
],
LABELS,
)
expect(out[0].color).toBe(ALT_PRIMARY)
expect(out[1].color).toBe(ALT_SECONDARY)
expect(out[0].note).toBe('Fastest')
expect(out[1].note).toBe('No tolls')
})
it('FE-ALTOVL-012: no difference is printed across two engines', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 11_880, coordinates: line(20, 0) }),
alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }),
],
LABELS,
)
expect(out[0].slowerThanQuickest).toBe(0)
expect(out[1].slowerThanQuickest).toBe(0)
expect(out[0].otherEngine).toBe(false)
expect(out[1].otherEngine).toBe(true)
// Each route still reports its own time; it is only the subtraction that is refused.
expect(out[0].duration).toBe(11_880)
expect(out[1].duration).toBe(10_140)
})
it('FE-ALTOVL-013: an instance whose own router answers still gets a real difference', () => {
// A self-hosted OSRM built through the MLD pipeline answers `exclude` itself, so
// the avoidance offer carries no engine of its own and the two figures are the
// same engine's. That comparison is sound and must keep working.
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0) }),
alt({ duration: 5400, coordinates: line(20, 1), avoids: 'toll' }),
],
LABELS,
)
expect(out[1].slowerThanQuickest).toBe(1800)
expect(out.every(o => o.otherEngine === false)).toBe(true)
})
it('FE-ALTOVL-014: a list with nothing comparable in it still names one road primary', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0), engine: 'valhalla' }),
alt({ duration: 5400, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }),
],
LABELS,
)
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
expect(out[0].color).toBe(ALT_PRIMARY)
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
})
it('FE-ALTOVL-015: the road being driven stays blue whatever the other engine says', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 11_880, coordinates: line(20, 0), current: true }),
alt({ duration: 10_140, coordinates: line(20, 1), avoids: 'toll', engine: 'valhalla' }),
],
LABELS,
)
expect(out[0].color).toBe(ALT_PRIMARY)
expect(out[0].note).toBe('Current')
expect(out[1].color).toBe(ALT_SECONDARY)
})
})
@@ -16,6 +16,13 @@ export interface AlternativeOverlay {
distance: number
/** Seconds more than the quickest offer; negative never happens by construction. */
slowerThanQuickest: number
/**
* True when this route's figures came from the other engine, so they cannot be read
* against the rest of the list. Nothing derived from a comparison is filled in for
* such a route: it takes no part in electing the quickest and its
* `slowerThanQuickest` stays zero.
*/
otherEngine: boolean
labelBg: string
/** Where to hang the label — a point on this route and on no other. */
at: { lat: number; lng: number }
@@ -91,7 +98,21 @@ export function buildAlternativeOverlays(
// The quickest of what came back, which is what Apple calls out — not necessarily the
// first entry, since the road currently driven is put at the top when there is one.
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
//
// Only among the routes one engine priced. The avoidance offer on a default install
// comes from the second engine, whose speed model differs by up to a seventh either
// way depending on the region, so letting it into this election decides which road is
// blue and how much slower every other road is called on nothing but that gap. On a
// Spanish motorway leg that is enough to crown a toll-free B-road detour and label
// the motorway somebody is actually driving as the slower way round.
//
// Index 0 is the fallback rather than -1: the first entry is the road being driven
// when there is one and the router's own pick otherwise, so if a list ever held
// nothing but second-engine routes, that is still the one to call primary.
const comparable = routes.map((r, i) => (r.engine ? -1 : i)).filter(i => i >= 0)
const quickest = comparable.length
? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0])
: 0
const anyCurrent = routes.some(r => r.current)
return routes.map((route, index) => {
@@ -100,6 +121,7 @@ export function buildAlternativeOverlays(
// Blue is the road you are on: the one currently driven, or the router's own pick
// when nothing has been bent. Everything else is the pale blue of an offer.
const primary = route.current || (!anyCurrent && index === quickest)
const otherEngine = !!route.engine
return {
index,
coordinates: route.coordinates,
@@ -119,7 +141,14 @@ export function buildAlternativeOverlays(
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
// Left at zero for a route the other engine priced, and for every route when
// that is the only thing to measure against. The bar reads a zero as "no
// difference worth printing" and falls back to naming what the road is, which
// for these is always the class left out of it.
slowerThanQuickest: otherEngine || !!routes[quickest].engine
? 0
: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
otherEngine,
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
at: at ?? { lat: 0, lng: 0 },
}
@@ -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,
+53
View File
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { chargingRepo } from './chargingRepo'
import { isEffectivelyOffline } from '../sync/networkMode'
import { apiClient } from '../api/client'
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }))
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn() } }))
const info = {
checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord',
source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0',
updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0,
tariffs: [], pricesUnavailable: false,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(apiClient.post).mockResolvedValue({ data: info })
})
describe('charging data for a station that is not a stop yet', () => {
it('FE-ROADTRIP-CHARGEREPO-001: asks the trip-scoped route with the coordinate and the name', async () => {
expect(await chargingRepo.readAt(1, 48.137, 11.575, 'Ladepark Nord')).toEqual(info)
expect(apiClient.post).toHaveBeenCalledWith('/trips/1/roadtrip/charging-lookup', { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' })
// Never the saved-stop route: that one takes a place id, which a hit found along
// the route does not have.
expect(apiClient.get).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CHARGEREPO-002: the same station twice is one request, a different one is another', async () => {
const first = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark')
const again = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark')
expect(await first).toEqual(await again)
expect(apiClient.post).toHaveBeenCalledTimes(1)
// A charger 200 m down the road is a different charger, cache or no cache.
await chargingRepo.readAt(2, 48.202, 11.2, 'Ladepark')
expect(apiClient.post).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-CHARGEREPO-003: offline it refuses rather than queueing a read', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
await expect(chargingRepo.readAt(3, 48.3, 11.3, 'Ladepark')).rejects.toThrow('Offline')
expect(apiClient.post).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CHARGEREPO-004: an answer that is not the contract is rejected, not rendered', async () => {
// Availability is nullable on purpose and the panel branches on it; a string where
// a count belongs must not reach it.
vi.mocked(apiClient.post).mockResolvedValue({ data: { ...info, available: 'lots' } })
await expect(chargingRepo.readAt(4, 48.4, 11.4, 'Ladepark')).rejects.toThrow()
})
})
+34 -7
View File
@@ -3,15 +3,42 @@ import { apiClient } from '../api/client'
import { isEffectivelyOffline } from '../sync/networkMode'
const pending = new Map<string, { until: number; value: Promise<ChargingInfo> }>()
/**
* One request per station per minute, however many places ask for it.
*
* The rail renders a badge for every charging stop, the inspector a panel for the same
* one and the add dialog a third for a station that is not on the trip yet, each on
* its own refresh timer. The window is a shade under the server's own minute of cache,
* so a refresh that gets through lands on a fresh answer rather than the cached one.
*
* A rejection stays in the map for the full window on purpose: a station the upstream
* registry cannot answer for should not be asked again a second later.
*/
function coalesce(key: string, load: () => Promise<ChargingInfo>): Promise<ChargingInfo> {
const cached = pending.get(key)
if (cached && cached.until > Date.now()) return cached.value
if (pending.size > 200) pending.clear()
const value = load()
pending.set(key, { until: Date.now() + 55000, value })
return value
}
export const chargingRepo = {
async read(tripId: number, placeId: number) {
if (isEffectivelyOffline()) throw new Error('Offline')
const key = `${tripId}/${placeId}`
const cached = pending.get(key)
if (cached && cached.until > Date.now()) return cached.value
if (pending.size > 200) pending.clear()
const value = apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data))
pending.set(key, { until: Date.now() + 55000, value })
return value
return coalesce(`${tripId}/${placeId}`, () =>
apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data)))
},
/**
* The same answer for a station the trip has not saved yet, which is the only way to
* ask before adding it: the saved-stop route takes a place id, and a hit found along
* the route has none. Cached under the coordinate rather than an id, so looking at
* the same charger twice is one request.
*/
async readAt(tripId: number, lat: number, lng: number, name: string) {
if (isEffectivelyOffline()) throw new Error('Offline')
return coalesce(`${tripId}/at:${lat},${lng}:${name}`, () =>
apiClient.post(`/trips/${tripId}/roadtrip/charging-lookup`, { lat, lng, name }).then(reply => chargingInfoSchema.parse(reply.data)))
},
}
@@ -0,0 +1,38 @@
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { chargingLookupSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { ChargingService } from './charging.service';
/** Exported so the parity test can hold the route and the MCP tool to the same contract. */
export class ChargingLookupDto extends createZodDto(chargingLookupSchema) {}
/**
* Charging availability for a station nobody has added yet.
*
* A sibling of `ChargingController` rather than another verb on it, because the two
* ask in different ways: that one names a row in `places`, this one hands over a
* coordinate. Same service, same cache, same answer.
*
* Trip-scoped on purpose, even though the trip is not part of the question. Leaving
* `:tripId` out would take `TripAccessGuard` with it, and what is left is an open
* proxy in front of somebody else's public API: anyone with the URL could aim it
* anywhere on the planet at whatever rate they liked. Nothing asks this while not
* planning a trip, so the scope costs the caller nothing.
*
* A POST for a read follows `RoadtripSearchController`: the input is a coordinate pair
* and a name, which belongs in a body behind a contract rather than in a query string.
*/
@Controller('api/trips/:tripId/roadtrip/charging-lookup')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
export class ChargingLookupController {
constructor(private readonly charging: ChargingService) {}
@Post()
@HttpCode(200)
lookup(@Body() input: ChargingLookupDto) { return this.charging.lookup(input.lat, input.lng, input.name); }
}
+8
View File
@@ -1,3 +1,4 @@
import { chargingLookupSchema } from '@trek/shared';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
@@ -17,4 +18,11 @@ export class ChargingMcp {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
return ok(await this.charging.read(tripId, placeId));
}
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
return ok(await this.charging.lookup(lat, lng, name));
}
}
+21 -3
View File
@@ -5,7 +5,8 @@ import { DatabaseService } from '../database/database.service';
import { readCappedJson } from '../../utils/cappedFetch';
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */
export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
async function request(path: string) {
const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' });
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); }
@@ -22,11 +23,28 @@ export class ChargingService {
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
const key = `${place.lat},${place.lng}:${place.name}`;
return this.lookup(place.lat, place.lng, place.name);
}
/**
* The same answer for a station the trip has not saved yet.
*
* A hit found along the route has no `places` row, so it cannot be asked for by id,
* but the match never needed one: a coordinate and a name is the whole input. Sharing
* `cached` with the saved-stop path matters twice over. The answer a charger gives
* while it is being considered is the answer it gives once it is on the trip, and the
* minute of cover is what keeps a second look from becoming a second round of
* upstream requests.
*
* The cache is keyed on the coordinate exactly as it arrived, not on a rounded one:
* a stop added from a corridor hit carries the coordinate the hit had, so the two
* paths land on the same entry without either of them knowing about the other.
*/
lookup(lat: number, lng: number, name: string): Promise<ChargingInfo> {
const key = `${lat},${lng}:${name}`;
const cached = this.cached.get(key);
if (cached && cached.expires > Date.now()) return cached.value;
if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!);
const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable'));
const value = this.load(lat, lng, name).catch(() => empty('unavailable'));
this.cached.set(key, { expires: Date.now() + 60000, value });
return value;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { ChargingMcp } from './charging.mcp';
import { ChargingService } from './charging.service';
import { ChargingController } from './charging.controller';
import { ChargingLookupController } from './charging-lookup.controller';
import { GoogleRouteService } from './google-route.service';
import { GoogleRouteController } from './google-route.controller';
import { GoogleRouteMcp } from './google-route.mcp';
@@ -47,7 +48,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
PluginsRuntimeModule,
MapsModule, PlacesModule, AssignmentsModule,
],
controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
RoadtripSearchService,
RoadtripHazardsService,
+20
View File
@@ -146,6 +146,26 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
expect(read).toHaveBeenCalledTimes(1);
} finally { read.mockRestore(); }
});
it('gates the coordinate lookup like the saved stop and validates before fetching', async () => {
const lookup = vi.spyOn(app.get(ChargingService), 'lookup').mockResolvedValue({} as never);
const body = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' };
const post = () => request(server).post('/api/trips/5/roadtrip/charging-lookup').set('Cookie', cookie());
try {
await request(server).post('/api/trips/5/roadtrip/charging-lookup').send(body).expect(401);
await request(server).post('/api/trips/6/roadtrip/charging-lookup').set('Cookie', cookie()).send(body).expect(404);
await post().send({ ...body, lat: 91 }).expect(400);
// A list is the shape a caller would reach for to fan a whole corridor out in one
// request. There is no rate limiter here and each miss costs the upstream registry
// several requests, so the contract refuses it before anything leaves the server.
await post().send([body]).expect(400);
expect(lookup).not.toHaveBeenCalled();
await post().send(body).expect(200);
expect(lookup).toHaveBeenCalledWith(48.137, 11.575, 'Ladepark Nord');
setAddon(false);
await post().send(body).expect(404);
expect(lookup).toHaveBeenCalledTimes(1);
} finally { lookup.mockRestore(); }
});
it('validates Google route preview and import before executing either service', async () => {
const routes = app.get(GoogleRouteService);
const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] });
+104 -2
View File
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChargingService } from '../../../src/nest/roadtrip/charging.service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chargingLookupSchema } from '@trek/shared';
import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service';
import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller';
import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers';
const now = Date.parse('2026-09-12T12:00:00Z');
@@ -59,3 +63,101 @@ describe('Open charging data', () => {
expect(fetcher).toHaveBeenCalledTimes(3);
});
});
/**
* The same data for a station nobody has added yet (#1797 feedback: "it would be useful
* to see this data also before the add"). One service, one cache, one answer: the only
* difference is how the station is named.
*/
describe('Open charging data before the stop exists', () => {
/**
* The clock, pinned to the instant the fixtures above are written at.
*
* `normalizeCharging` takes the current time as an argument and the tests further up
* pass it; the service does not, so it reads the real one, and its freshness window is
* twenty minutes. Left alone, every answer that goes through the service is stale by
* construction and `available` comes back null, which is the field this is here to
* prove survives the trip from the registry to the dialog.
*
* Only `Date.now` is pinned, never the timers: the outbound requests carry an
* `AbortSignal.timeout` that has to keep running.
*/
beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(now); });
afterEach(() => { vi.restoreAllMocks(); });
const answers = (tariffs: unknown) => vi.fn(async (url: string) => {
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 }));
return new Response(JSON.stringify(tariffs));
});
it('answers for a coordinate and lets the saved stop reuse that answer', async () => {
const fetcher = answers({ items: [tariff], total_count: 1 });
vi.stubGlobal('fetch', fetcher);
const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
const ahead = await service.lookup(48, 11, 'Test');
expect(ahead.status).toBe('ok');
// The count of individual charge points, which is what "how many chargers" means.
expect(ahead).toMatchObject({ total: 3, available: 1, unknown: 1 });
expect(ahead.tariffs[0].components[0]).toMatchObject({ kind: 'ENERGY', taxIncluded: true });
// Added to the trip, the same coordinate and name key the same cache entry, so the
// stop on the rail does not pay for the answer the dialog already got.
expect(await service.read(1, 2)).toEqual(ahead);
expect(fetcher).toHaveBeenCalledTimes(3);
});
it('never reports a station it could not identify as free', async () => {
// Two operators within the radius is the dense case a station found along a route
// routinely is. Returning the nearest one would attach another operator's prices.
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
return new Response(JSON.stringify({ items: [station, { ...station, id: 'other', operator: { name: 'Other' } }], total_count: 2 }));
}));
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Charging');
expect(info.status).toBe('ambiguous');
expect(info.available).toBeNull();
});
it('turns an unreachable source into an empty answer, not a failed request', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 503 })));
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Test');
expect(info).toEqual({ ...empty('unavailable'), checkedAt: info.checkedAt });
});
it('does not go to the database for a station that is not in it', async () => {
vi.stubGlobal('fetch', answers({ items: [], total_count: 0 }));
const get = vi.fn();
await new ChargingService({ get } as never).lookup(48, 11, 'Test');
expect(get).not.toHaveBeenCalled();
});
it('serves the route and the MCP tool from one contract and one access check', async () => {
const charging = { read: vi.fn(), lookup: vi.fn(async () => empty('unknown')) };
const db = { canAccessTrip: vi.fn(() => false) };
const tool = new ChargingMcp(charging as never, db as never, {} as never);
const input = { lat: 48.1, lng: 11.5, name: 'Ladepark Nord' };
// A trip the caller cannot reach is refused before anything is fetched, exactly as
// TripAccessGuard refuses it on the route.
expect((await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never)).isError).toBe(true);
expect(charging.lookup).not.toHaveBeenCalled();
db.canAccessTrip.mockReturnValue(true);
await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never);
await new ChargingLookupController(charging as never).lookup(input as unknown as ChargingLookupDto);
expect(charging.lookup.mock.calls).toEqual([[48.1, 11.5, 'Ladepark Nord'], [48.1, 11.5, 'Ladepark Nord']]);
// Both surfaces validate through the shared contract rather than a restatement of
// it, so a bound or a length limit cannot be tightened on one side only.
for (const bad of [{ ...input, lat: 91 }, { ...input, lng: -181 }, { ...input, name: 'x'.repeat(201) }]) {
expect(ChargingLookupDto.schema.safeParse(bad).success).toBe(false);
expect(chargingLookupSchema.safeParse(bad).success).toBe(false);
}
expect(ChargingLookupDto.schema.safeParse(input).success).toBe(true);
const declared = getEntry(ChargingMcp as unknown as ClassRef, 'lookup')?.options as { inputSchema: Record<string, unknown> };
expect(Object.keys(declared.inputSchema).sort()).toEqual(['lat', 'lng', 'name', 'tripId']);
for (const [field, type] of Object.entries(chargingLookupSchema.shape)) {
expect(declared.inputSchema[field]).toBe(type);
}
});
});
+10
View File
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.prices': 'التعريفات المنشورة',
'roadtrip.charging.note': 'تعريفة منشورة؛ قد تُطبق ضرائب وشروط ورسوم إضافية. قد يختلف سعر بطاقة الشحن.',
'roadtrip.charging.coverage': 'بيانات مفتوحة؛ تختلف التغطية حسب المنطقة والمشغل.',
'roadtrip.charging.availability': 'التوفر والتعريفات',
'roadtrip.import.title': 'استيراد مسار Google Maps',
'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.',
'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.',
@@ -193,6 +194,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': 'مسارات أخرى',
@@ -204,6 +213,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'الأسرع',
'roadtrip.alt.slower': 'أبطأ بـ {time}',
'roadtrip.alt.quicker': 'أسرع بـ {time}',
'roadtrip.alt.otherEngine': 'الزمن محسوب بمحرك التجنب، لا بالمحرك الرئيسي.',
'roadtrip.day': 'اليوم {number}',
'roadtrip.quietDay.empty': 'لا توجد محطات بعد — أفلِت واحدة هنا',
'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.',
'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.',
'roadtrip.charging.availability': 'Disponibilidade e tarifas',
'roadtrip.import.title': 'Importar rota do Google Maps',
'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.',
'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.',
@@ -198,6 +199,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',
@@ -209,6 +218,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Mais rápida',
'roadtrip.alt.slower': '{time} mais lenta',
'roadtrip.alt.quicker': '{time} mais rápida',
'roadtrip.alt.otherEngine': 'Tempo do roteador de desvios, não do principal.',
'roadtrip.day': 'Dia {number}',
'roadtrip.quietDay.empty': 'Ainda sem paradas — solte uma aqui',
'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tarifa publicada; pot haver-hi impostos, condicions i càrrecs addicionals. El preu de la targeta pot variar.',
'roadtrip.charging.coverage': 'Dades obertes; cobertura segons regió i operador.',
'roadtrip.charging.availability': 'Disponibilitat i tarifes',
'roadtrip.import.title': 'Importa una ruta de Google Maps',
'roadtrip.import.note': 'Les parades s’afegeixen al dia triat. TREK recalcula la ruta.',
'roadtrip.import.unresolved': 'Ubicació no trobada; aquesta parada s’ometrà.',
@@ -200,6 +201,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',
@@ -211,6 +220,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'La més ràpida',
'roadtrip.alt.slower': '{time} més lenta',
'roadtrip.alt.quicker': '{time} més ràpida',
'roadtrip.alt.otherEngine': 'Temps de l’encaminador d’evitació, no del principal.',
'roadtrip.day': 'Dia {number}',
'roadtrip.quietDay.empty': 'Encara sense parades: deixa’n una aquí',
'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Zveřejněný tarif; mohou platit daně, podmínky a další poplatky. Cena vaší karty se může lišit.',
'roadtrip.charging.coverage': 'Otevřená data; pokrytí závisí na regionu a provozovateli.',
'roadtrip.charging.availability': 'Dostupnost a tarify',
'roadtrip.import.title': 'Importovat trasu z Google Maps',
'roadtrip.import.note': 'Zastávky se přidají ke zvolenému dni. TREK trasu přepočítá.',
'roadtrip.import.unresolved': 'Místo nenalezeno; tato zastávka bude přeskočena.',
@@ -196,6 +197,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',
@@ -207,6 +216,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Nejrychlejší',
'roadtrip.alt.slower': 'o {time} déle',
'roadtrip.alt.quicker': 'o {time} rychleji',
'roadtrip.alt.otherEngine': 'Čas spočítal server pro objížďky, ne ten hlavní.',
'roadtrip.day': 'Den {number}',
'roadtrip.quietDay.empty': 'Zatím žádné zastávky — přetáhněte sem',
'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Veröffentlichter Tarif; Steuern, Bedingungen und Zusatzgebühren beachten. Dein Ladekartenpreis kann abweichen.',
'roadtrip.charging.coverage': 'Offene Daten; Abdeckung abhängig von Region und Betreiber.',
'roadtrip.charging.availability': 'Verfügbarkeit und Tarife',
'roadtrip.import.title': 'Google-Maps-Route importieren',
'roadtrip.import.note': 'Stopps werden an den gewählten Tag angehängt. TREK berechnet die Route neu.',
'roadtrip.import.unresolved': 'Ort nicht gefunden; dieser Stopp wird übersprungen.',
@@ -195,6 +196,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',
@@ -206,6 +215,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Schnellste',
'roadtrip.alt.slower': '{time} länger',
'roadtrip.alt.quicker': '{time} kürzer',
'roadtrip.alt.otherEngine': 'Zeit vom Ausweich-Router, nicht vom Haupt-Router.',
'roadtrip.day': 'Tag {number}',
'roadtrip.quietDay.empty': 'Noch keine Stopps — hier ablegen',
'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Published tariff; taxes, conditions and extra fees may apply. Your charging-card price can differ.',
'roadtrip.charging.coverage': 'Open data; coverage varies by region and operator.',
'roadtrip.charging.availability': 'Availability and prices',
'roadtrip.import.title': 'Import Google Maps route',
'roadtrip.import.note': 'Stops are appended to the selected day. TREK recalculates the route.',
'roadtrip.import.unresolved': 'Location unresolved; this stop will be skipped.',
@@ -192,6 +193,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',
@@ -203,6 +212,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Fastest',
'roadtrip.alt.slower': '{time} slower',
'roadtrip.alt.quicker': '{time} quicker',
'roadtrip.alt.otherEngine': 'Timed by the avoidance router, not the main one.',
'roadtrip.day': 'Day {number}',
'roadtrip.quietDay.empty': 'No stops yet — drop one here',
'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here',
+10
View File
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tarifa publicada; pueden aplicarse impuestos, condiciones y cargos adicionales. El precio de tu tarjeta puede variar.',
'roadtrip.charging.coverage': 'Datos abiertos; cobertura según región y operador.',
'roadtrip.charging.availability': 'Disponibilidad y tarifas',
'roadtrip.import.title': 'Importar ruta de Google Maps',
'roadtrip.import.note': 'Las paradas se añaden al día elegido. TREK recalcula la ruta.',
'roadtrip.import.unresolved': 'Ubicación no encontrada; se omitirá esta parada.',
@@ -199,6 +200,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',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'La más rápida',
'roadtrip.alt.slower': '{time} más',
'roadtrip.alt.quicker': '{time} menos',
'roadtrip.alt.otherEngine': 'Tiempo del enrutador de evitación, no del principal.',
'roadtrip.day': 'Día {number}',
'roadtrip.quietDay.empty': 'Sin paradas todavía: suelta una aquí',
'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí',
+10
View File
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tarif publié ; taxes, conditions et frais supplémentaires possibles. Le prix de votre carte peut différer.',
'roadtrip.charging.coverage': 'Données ouvertes ; couverture variable selon la région et l’opérateur.',
'roadtrip.charging.availability': 'Disponibilité et tarifs',
'roadtrip.import.title': 'Importer un itinéraire Google Maps',
'roadtrip.import.note': 'Les étapes sont ajoutées au jour choisi. TREK recalcule le trajet.',
'roadtrip.import.unresolved': 'Lieu introuvable ; cette étape sera ignorée.',
@@ -199,6 +200,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',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'La plus rapide',
'roadtrip.alt.slower': '{time} de plus',
'roadtrip.alt.quicker': '{time} de moins',
'roadtrip.alt.otherEngine': 'Temps du routeur de contournement, pas du principal.',
'roadtrip.day': 'Jour {number}',
'roadtrip.quietDay.empty': 'Aucun arrêt — déposez-en un ici',
'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici',
+10
View File
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Δημοσιευμένη χρέωση· ενδέχεται να ισχύουν φόροι, όροι και πρόσθετα τέλη. Η τιμή της κάρτας σας μπορεί να διαφέρει.',
'roadtrip.charging.coverage': 'Ανοικτά δεδομένα· η κάλυψη εξαρτάται από την περιοχή και τον πάροχο.',
'roadtrip.charging.availability': 'Διαθεσιμότητα και χρεώσεις',
'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps',
'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.',
'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.',
@@ -201,6 +202,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': 'Άλλες διαδρομές',
@@ -212,6 +221,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Η ταχύτερη',
'roadtrip.alt.slower': '{time} πιο αργή',
'roadtrip.alt.quicker': '{time} πιο γρήγορη',
'roadtrip.alt.otherEngine': 'Χρόνος από τον δρομολογητή αποφυγής, όχι τον κύριο.',
'roadtrip.day': 'Ημέρα {number}',
'roadtrip.quietDay.empty': 'Καμία στάση ακόμη — αφήστε μία εδώ',
'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.prices': 'Közzétett díjak',
'roadtrip.charging.note': 'Közzétett díj; adók, feltételek és felárak lehetnek. A töltőkártya ára eltérhet.',
'roadtrip.charging.coverage': 'Nyílt adatok; a lefedettség régiónként és szolgáltatónként változik.',
'roadtrip.charging.availability': 'Elérhetőség és díjak',
'roadtrip.import.title': 'Google Maps-útvonal importálása',
'roadtrip.import.note': 'A megállók a kiválasztott naphoz kerülnek. A TREK újratervezi az útvonalat.',
'roadtrip.import.unresolved': 'A hely nem található; ez a megálló kimarad.',
@@ -197,6 +198,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',
@@ -208,6 +217,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Leggyorsabb',
'roadtrip.alt.slower': '{time}-cel lassabb',
'roadtrip.alt.quicker': '{time}-cel gyorsabb',
'roadtrip.alt.otherEngine': 'Az időt a kerülő útvonaltervező számolta, nem a fő.',
'roadtrip.day': '{number}. nap',
'roadtrip.quietDay.empty': 'Még nincs megálló — húzzon ide egyet',
'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tarif publik; pajak, syarat, dan biaya tambahan mungkin berlaku. Harga kartu pengisian Anda bisa berbeda.',
'roadtrip.charging.coverage': 'Data terbuka; cakupan bergantung wilayah dan operator.',
'roadtrip.charging.availability': 'Ketersediaan dan tarif',
'roadtrip.import.title': 'Impor rute Google Maps',
'roadtrip.import.note': 'Perhentian ditambahkan ke hari yang dipilih. TREK menghitung ulang rute.',
'roadtrip.import.unresolved': 'Lokasi tidak ditemukan; perhentian ini akan dilewati.',
@@ -199,6 +200,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',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Tercepat',
'roadtrip.alt.slower': '{time} lebih lama',
'roadtrip.alt.quicker': '{time} lebih cepat',
'roadtrip.alt.otherEngine': 'Waktu dihitung mesin penghindaran, bukan mesin utama.',
'roadtrip.day': 'Hari {number}',
'roadtrip.quietDay.empty': 'Belum ada perhentian — jatuhkan di sini',
'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Tariffa pubblicata; verificare tasse, condizioni e supplementi. Il prezzo della tua tessera può variare.',
'roadtrip.charging.coverage': 'Dati aperti; copertura variabile per regione e gestore.',
'roadtrip.charging.availability': 'Disponibilità e tariffe',
'roadtrip.import.title': 'Importa percorso Google Maps',
'roadtrip.import.note': 'Le tappe vengono aggiunte al giorno scelto. TREK ricalcola il percorso.',
'roadtrip.import.unresolved': 'Posizione non trovata; questa tappa verrà saltata.',
@@ -199,6 +200,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',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'La più veloce',
'roadtrip.alt.slower': '{time} in più',
'roadtrip.alt.quicker': '{time} in meno',
'roadtrip.alt.otherEngine': 'Tempo dal router delle esclusioni, non dal principale.',
'roadtrip.day': 'Giorno {number}',
'roadtrip.quietDay.empty': 'Ancora nessuna sosta: trascinane una qui',
'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta',
+10
View File
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'公開料金です。税金、条件、追加料金をご確認ください。充電カードの料金は異なる場合があります。',
'roadtrip.charging.coverage': 'オープンデータ。地域や事業者によって対応範囲が異なります。',
'roadtrip.charging.availability': '空き状況と料金',
'roadtrip.import.title': 'Google Mapsのルートをインポート',
'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。',
'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。',
@@ -196,6 +197,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': '別のルート',
@@ -207,6 +216,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': '最速',
'roadtrip.alt.slower': '{time} 遅い',
'roadtrip.alt.quicker': '{time} 速い',
'roadtrip.alt.otherEngine': '所要時間は主エンジンではなく回避用エンジンでの計算です。',
'roadtrip.day': '{number} 日目',
'roadtrip.quietDay.empty': 'まだ立ち寄り先がありません。ここにドロップ',
'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ',
+9
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'공개 요금입니다. 세금, 조건, 추가 요금이 적용될 수 있으며 충전 카드 요금은 다를 수 있습니다.',
'roadtrip.charging.coverage': '공개 데이터로 지역과 운영사에 따라 범위가 다릅니다.',
'roadtrip.charging.availability': '이용 가능 여부와 요금',
'roadtrip.import.title': 'Google Maps 경로 가져오기',
'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.',
'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.',
@@ -195,6 +196,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': '다른 경로',
@@ -206,6 +214,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': '가장 빠름',
'roadtrip.alt.slower': '{time} 더 걸림',
'roadtrip.alt.quicker': '{time} 더 빠름',
'roadtrip.alt.otherEngine': '기본 엔진이 아니라 회피 엔진이 계산한 시간입니다.',
'roadtrip.day': '{number}일차',
'roadtrip.quietDay.empty': '아직 경유지가 없습니다. 여기에 놓으세요',
'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Gepubliceerd tarief; belastingen, voorwaarden en toeslagen kunnen gelden. Je laadpasprijs kan afwijken.',
'roadtrip.charging.coverage': 'Open data; dekking verschilt per regio en aanbieder.',
'roadtrip.charging.availability': 'Beschikbaarheid en tarieven',
'roadtrip.import.title': 'Google Maps-route importeren',
'roadtrip.import.note': 'Stops worden aan de gekozen dag toegevoegd. TREK berekent de route opnieuw.',
'roadtrip.import.unresolved': 'Locatie niet gevonden; deze stop wordt overgeslagen.',
@@ -199,6 +200,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',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Snelste',
'roadtrip.alt.slower': '{time} langzamer',
'roadtrip.alt.quicker': '{time} sneller',
'roadtrip.alt.otherEngine': 'Tijd van de vermijdingsrouter, niet van de hoofdrouter.',
'roadtrip.day': 'Dag {number}',
'roadtrip.quietDay.empty': 'Nog geen stops — laat er hier een vallen',
'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Opublikowana taryfa; możliwe podatki, warunki i dodatkowe opłaty. Cena Twojej karty może się różnić.',
'roadtrip.charging.coverage': 'Otwarte dane; zasięg zależy od regionu i operatora.',
'roadtrip.charging.availability': 'Dostępność i taryfy',
'roadtrip.import.title': 'Importuj trasę z Google Maps',
'roadtrip.import.note': 'Przystanki zostaną dodane do wybranego dnia. TREK przeliczy trasę.',
'roadtrip.import.unresolved': 'Nie znaleziono miejsca; ten przystanek zostanie pominięty.',
@@ -197,6 +198,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',
@@ -208,6 +217,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Najszybsza',
'roadtrip.alt.slower': '{time} dłużej',
'roadtrip.alt.quicker': '{time} krócej',
'roadtrip.alt.otherEngine': 'Czas policzony silnikiem omijania, nie głównym.',
'roadtrip.day': 'Dzień {number}',
'roadtrip.quietDay.empty': 'Brak przystanków — upuść tutaj',
'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Опубликованный тариф; возможны налоги, условия и доплаты. Цена по вашей карте может отличаться.',
'roadtrip.charging.coverage': 'Открытые данные; охват зависит от региона и оператора.',
'roadtrip.charging.availability': 'Доступность и тарифы',
'roadtrip.import.title': 'Импорт маршрута Google Maps',
'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.',
'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.',
@@ -199,6 +200,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': 'Другие маршруты',
@@ -210,6 +219,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Самый быстрый',
'roadtrip.alt.slower': 'на {time} дольше',
'roadtrip.alt.quicker': 'на {time} быстрее',
'roadtrip.alt.otherEngine': 'Время посчитал маршрутизатор обхода, не основной.',
'roadtrip.day': 'День {number}',
'roadtrip.quietDay.empty': 'Остановок пока нет — перетащите сюда',
'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку',
+10
View File
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.prices': 'Publicerade priser',
'roadtrip.charging.note': 'Publicerat pris; skatter, villkor och tillägg kan gälla. Ditt laddkorts pris kan avvika.',
'roadtrip.charging.coverage': 'Öppna data; täckning varierar efter region och operatör.',
'roadtrip.charging.availability': 'Tillgänglighet och priser',
'roadtrip.import.title': 'Importera Google Maps-rutt',
'roadtrip.import.note': 'Stoppen läggs till på vald dag. TREK beräknar rutten på nytt.',
'roadtrip.import.unresolved': 'Platsen hittades inte; stoppet hoppas över.',
@@ -196,6 +197,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',
@@ -207,6 +216,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Snabbast',
'roadtrip.alt.slower': '{time} långsammare',
'roadtrip.alt.quicker': '{time} snabbare',
'roadtrip.alt.otherEngine': 'Tid från undvikandemotorn, inte huvudmotorn.',
'roadtrip.day': 'Dag {number}',
'roadtrip.quietDay.empty': 'Inga stopp än – släpp ett här',
'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här',
+10
View File
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Yayımlanan tarife; vergi, koşullar ve ek ücretler uygulanabilir. Şarj kartı fiyatınız farklı olabilir.',
'roadtrip.charging.coverage': 'Açık veri; kapsam bölge ve operatöre göre değişir.',
'roadtrip.charging.availability': 'Müsaitlik ve tarifeler',
'roadtrip.import.title': 'Google Maps rotasını içe aktar',
'roadtrip.import.note': 'Duraklar seçilen güne eklenir. TREK rotayı yeniden hesaplar.',
'roadtrip.import.unresolved': 'Konum bulunamadı; bu durak atlanacak.',
@@ -198,6 +199,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',
@@ -209,6 +218,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'En hızlı',
'roadtrip.alt.slower': '{time} daha uzun',
'roadtrip.alt.quicker': '{time} daha kısa',
'roadtrip.alt.otherEngine': 'Süre, ana motorla değil kaçınma motoruyla hesaplandı.',
'roadtrip.day': '{number}. gün',
'roadtrip.quietDay.empty': 'Henüz mola yok — buraya bırakın',
'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Опублікований тариф; можливі податки, умови та доплати. Ціна за вашою карткою може відрізнятися.',
'roadtrip.charging.coverage': 'Відкриті дані; охоплення залежить від регіону й оператора.',
'roadtrip.charging.availability': 'Доступність і тарифи',
'roadtrip.import.title': 'Імпорт маршруту Google Maps',
'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.',
'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.',
@@ -198,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': 'Інші маршрути',
@@ -209,6 +218,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Найшвидший',
'roadtrip.alt.slower': 'на {time} довше',
'roadtrip.alt.quicker': 'на {time} швидше',
'roadtrip.alt.otherEngine': 'Час порахував маршрутизатор обходів, а не основний.',
'roadtrip.day': 'День {number}',
'roadtrip.quietDay.empty': 'Зупинок ще немає — перетягніть сюди',
'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку',
+10
View File
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.note':
'Biểu giá công bố; có thể áp dụng thuế, điều kiện và phụ phí. Giá thẻ sạc của bạn có thể khác.',
'roadtrip.charging.coverage': 'Dữ liệu mở; phạm vi phụ thuộc khu vực và nhà vận hành.',
'roadtrip.charging.availability': 'Tình trạng còn trống và biểu giá',
'roadtrip.import.title': 'Nhập tuyến đường Google Maps',
'roadtrip.import.note': 'Các điểm dừng được thêm vào ngày đã chọn. TREK tính lại tuyến đường.',
'roadtrip.import.unresolved': 'Không tìm thấy vị trí; điểm dừng này sẽ bị bỏ qua.',
@@ -197,6 +198,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',
@@ -208,6 +217,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': 'Nhanh nhất',
'roadtrip.alt.slower': 'chậm hơn {time}',
'roadtrip.alt.quicker': 'nhanh hơn {time}',
'roadtrip.alt.otherEngine': 'Giờ do máy chủ định tuyến tránh đường tính, không phải máy chính.',
'roadtrip.day': 'Ngày {number}',
'roadtrip.quietDay.empty': 'Chưa có điểm dừng — thả vào đây',
'roadtrip.quietDay.one': 'Hiện chỉ có {name} — thả một điểm dừng vào đây',
+9
View File
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.prices': '公布的費率',
'roadtrip.charging.note': '公布的費率可能附帶稅費、條件和額外費用。您的充電卡價格可能不同。',
'roadtrip.charging.coverage': '開放資料,涵蓋範圍因地區和營運商而異。',
'roadtrip.charging.availability': '可用狀態與費率',
'roadtrip.import.title': '匯入 Google Maps 路線',
'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。',
'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。',
@@ -186,6 +187,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': '其他路線',
@@ -197,6 +205,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': '最快',
'roadtrip.alt.slower': '慢 {time}',
'roadtrip.alt.quicker': '快 {time}',
'roadtrip.alt.otherEngine': '駕駛時間由避開專用的路徑服務計算,無法與其他項比較。',
'roadtrip.day': '第 {number} 天',
'roadtrip.quietDay.empty': '尚無停靠點 — 拖到這裡',
'roadtrip.quietDay.one': '目前只有 {name} — 把停靠點拖到這裡',
+9
View File
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.charging.prices': '公布的费率',
'roadtrip.charging.note': '公布的费率可能附带税费、条件和额外费用。您的充电卡价格可能不同。',
'roadtrip.charging.coverage': '开放数据,覆盖范围因地区和运营商而异。',
'roadtrip.charging.availability': '可用状态与费率',
'roadtrip.import.title': '导入 Google Maps 路线',
'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。',
'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。',
@@ -186,6 +187,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': '其他路线',
@@ -197,6 +205,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.fastest': '最快',
'roadtrip.alt.slower': '慢 {time}',
'roadtrip.alt.quicker': '快 {time}',
'roadtrip.alt.otherEngine': '用时由避开引擎计算,而非主引擎。',
'roadtrip.day': '第 {number} 天',
'roadtrip.quietDay.empty': '暂无停靠点 — 拖到这里',
'roadtrip.quietDay.one': '目前只有 {name} — 把停靠点拖到这里',
@@ -0,0 +1,24 @@
import { chargingLookupSchema } from './charging.schema';
import { describe, expect, it } from 'vitest';
describe('Charging station lookup by coordinate', () => {
const request = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' };
it('accepts a bounded coordinate and a nameless station', () => {
expect(chargingLookupSchema.safeParse(request).success).toBe(true);
// OSM charging nodes are regularly untagged; the matcher has its own answer for
// that case, so an empty name must reach it rather than being refused here.
expect(chargingLookupSchema.safeParse({ ...request, name: '' }).success).toBe(true);
});
it('refuses coordinates off the globe and an unbounded name', () => {
expect(chargingLookupSchema.safeParse({ ...request, lat: 91 }).success).toBe(false);
expect(chargingLookupSchema.safeParse({ ...request, lng: -181 }).success).toBe(false);
expect(chargingLookupSchema.safeParse({ ...request, lat: Number.NaN }).success).toBe(false);
expect(chargingLookupSchema.safeParse({ ...request, name: 'x'.repeat(201) }).success).toBe(false);
});
it('has no batch form: the array a caller might send is not a station', () => {
// The cache key upstream is one station, and a cache miss costs several requests
// against a shared public registry. Anything list-shaped is refused outright.
expect(chargingLookupSchema.safeParse([request]).success).toBe(false);
});
});
+25
View File
@@ -30,3 +30,28 @@ export const chargingInfoSchema = z.object({
pricesUnavailable: z.boolean(),
});
export type ChargingInfo = z.infer<typeof chargingInfoSchema>;
/**
* A charging station addressed by where it is, rather than by a row in `places`.
*
* The saved-stop route answers for a place id, which a hit found along the route does
* not have yet, and "is anything free here, and what does it cost" is the question
* people want answered before adding the charger, not after. The match needs nothing
* more than this: a coordinate, and whatever the search called the place.
*
* One station per request, deliberately. A cache miss costs the upstream registry
* several requests, nothing rate-limits the route, and the caches on both sides of it
* are sized for the handful of stops a trip has. A batch would turn one click into a
* burst against shared public infrastructure.
*/
export const chargingLookupSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
/**
* An empty name is allowed. OSM charging nodes are frequently nameless, and the
* matcher already treats "nothing to go on" as its own case: it then insists the
* station be within 30 m instead of accepting the nearest one inside 100 m.
*/
name: z.string().max(200),
});
export type ChargingLookup = z.infer<typeof chargingLookupSchema>;
+2
View File
@@ -249,3 +249,5 @@ Roadtrip corridor searches include installed search-provider plugins. Providers
Google Maps directions links can be reviewed with preview_google_maps_route and saved with import_google_maps_route. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied.
get_roadtrip_charging_info accepts tripId and placeId. It requires trip access and the Roadtrip addon. It reports matching failures, unavailable feeds, freshness, known free capacity and unknown status counts separately. Published tariff components include currency, tax handling and conditions; they do not estimate a user's roaming price. Data source: [MobiData BW OCPDB](https://api.mobidata-bw.de/), with per-source attribution from its public source registry.
lookup_roadtrip_charging_info answers the same question for a station that is not on the trip, by coordinate and name rather than by place id, which is what a hit found along the route has. It takes tripId, lat, lng and name, requires the same trip access and the same addon, and returns the same fields with the same caveats. The trip is there for the access check only and does not narrow the search. One station per call: it shares its cache with the saved-stop tool, and the registry behind it is public infrastructure. It carries no power rating and no socket count; those are OpenStreetMap tag data and travel with the corridor search result instead.
+8 -2
View File
@@ -106,7 +106,11 @@ Driving settings includes **Show hazard areas**, off by default and shared by th
Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`.
**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results.
**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results. It opens on Charging when the trip's vehicle is electric and on Fuel otherwise, and the choice is yours from then on.
Clicking a result brings it into view on the map, which is how you tell which side of the road it is on. **Clear results** above the list empties both the list and its pins, along with the name, section, plug and power filters.
**Add manually**, beside Search, is for the stop the search does not know about: a good share of the chargers standing at a junction are in no OpenStreetMap extract. Look the place up by name and TREK works out which leg of which day it belongs on, measured against the drawn route. **Add between** offers every leg of every routed day if that guess is wrong, and a place well away from the route is accepted rather than refused, with a note saying how far off it sits. From there it opens the same dialog a found result does, so the kind of stop and the time spent there are chosen in one place.
> **AI / MCP:** `search_roadtrip_corridor` uses the same combined sources and reports `failedSources`.
@@ -116,7 +120,9 @@ In Roadtrip, open the three-dot menu beside the day selector to import a Google
Roadtrip charging stops show compact availability and published energy-price badges. The place detail panel adds source attribution, timestamps and tariff conditions. Data comes from the public MobiData BW OCPDB aggregation, including participating German operators and Swiss data, without an API key or account. Coverage varies; an unknown or stale status does not mean a charger is free. Prices are published source tariffs, not personal charging-card quotes. Data refreshes while Roadtrip is visible and is not advertised as live offline.
> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop.
The same panel appears in the dialog that adds a station found along the route, so availability and price can be read before the stop exists. It is fetched once per opened dialog and only for a stop being added as a charging stop, never per row of the result list.
> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. lookup_roadtrip_charging_info reads it for a station that is not on the trip, named by coordinate instead of by place id.
GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable.