diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index fd44ac820..5571b133e 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -5,6 +5,9 @@ import {
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
+import type { RefuelSearch } from './useRefuelSearch'
+import type { RefuelCandidate } from './refuelSuggestion'
+import type { DryPoint } from './roadtripModel'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
@@ -35,6 +38,15 @@ interface RoadtripSidebarProps {
onMoveStopToDay?: (fromDayId: number, assignmentId: number, toDayId: number, toIndex: number) => void
/** Asks for other ways of driving one leg (#1797). */
onAskAlternatives?: (dayId: number, legIndex: number) => void
+ /**
+ * The one-shot search for somewhere to fill up before the tank runs out.
+ *
+ * Absent leaves the range findings as they were, a warning and nothing else — which is
+ * also what a viewer sees, since accepting one writes a stop.
+ */
+ refuel?: RefuelSearch
+ onAskRefuel?: (dayId: number, dry: DryPoint & { lat: number; lng: number }) => void
+ onAcceptRefuel?: (dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => void
/** Which leg's alternatives are on show, so the rail can mark it. */
openAlternatives?: { dayId: number; index: number } | null
/**
@@ -292,6 +304,108 @@ function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement
* distance and the time belong together, and it leaves the row's right edge for the
* button instead of a second number.
*/
+
+/**
+ * Where the tank runs out on this leg, and somewhere to do something about it.
+ *
+ * Sits under the drive band rather than on the stop that carries the range warning,
+ * because those are two different places: the warning is filed where somebody finds out,
+ * this is where the fuel actually ends. A filling station offered at the warning is one
+ * the car cannot reach.
+ *
+ * Nothing is searched until it is asked for. Every press is a real request against a
+ * shared service, which is the same reason the corridor search next door is manual.
+ */
+function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
+ dry: DryPoint & { lat: number; lng: number }
+ refuel: RefuelSearch
+ dayId: number
+ onAsk: () => void
+ onAccept?: (poi: RefuelCandidate) => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ const key = `${dayId}:${dry.legIndex}`
+ const open = refuel.openFor === key
+
+ return (
+
+ {/* Three at most. This is an offer beside a plan, not a list to browse; the
+ corridor panel is where somebody goes to see all of them. */}
+ {refuel.results.slice(0, 3).map(poi => (
+
+
+ {poi.name}
+
+
+ {/* What is left when the car draws level, which is the figure that
+ decides whether this one is any use. The detour is in it already. */}
+ {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
+
+ {onAccept ? (
+
+ ) : null}
+
+ ))}
+
+ ) : (
+ /* Three different sentences for three different facts. "Nothing on this
+ stretch" after a request that failed or was cut short states something that
+ was never checked, and that is worse than saying nothing. */
+
+ {refuel.outcome === 'none'
+ ? t('roadtrip.refuel.none')
+ : refuel.outcome === 'incomplete'
+ ? t('roadtrip.refuel.incomplete')
+ : t('roadtrip.refuel.failed')}
+
+ )
+ ) : null}
+
+ )
+}
+
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
leg: RouteSegment | undefined
/** Asks for other ways of driving this leg. Absent means the route is not editable. */
@@ -813,7 +927,7 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React
* (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across
* the trip would put "17" beside a pin the map calls "3".
*/
-function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName }: {
+function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: {
day: RoadtripDay
selectedAssignmentId?: number | null
onSelectStop?: (placeId: number, assignmentId: number) => void
@@ -828,6 +942,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onFollowTrack?: RoadtripSidebarProps['onFollowTrack']
viaCount?: number
trackName?: string
+ refuel?: RefuelSearch
+ onAskRefuel?: RoadtripSidebarProps['onAskRefuel']
+ onAcceptRefuel?: RoadtripSidebarProps['onAcceptRefuel']
+ /** True while any day is still routing; the range findings are not settled until then. */
+ loading?: boolean
}): React.ReactElement {
const { from, setFrom, dropAt, setDropAt } = drag
const dragging = from?.dayId === day.dayId ? from.index : null
@@ -1002,6 +1121,24 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
+ {/* Only on the leg the fuel actually runs out on, and only once the day has
+ finished routing: the warnings are republished after every routing task
+ and the early ones are wrong, so an offer that appears and moves while
+ the trip loads reads as a fault. */}
+ {refuel && !loading
+ ? (day.dryPoints ?? [])
+ .filter(dry => dry.legIndex === i)
+ .map(dry => (
+ onAskRefuel?.(day.dayId, dry)}
+ onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
+ />
+ ))
+ : null}
{/* After the band, because a plugin halt happens on the drive it describes
rather than before setting off. */}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
@@ -1100,7 +1237,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
- onSetStopKind, onFollowTrack, viaCounts, trackNames,
+ onSetStopKind, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
}: RoadtripSidebarProps): React.ReactElement {
const { t } = useTranslation()
// One drag state for the whole rail rather than one per day: a stop that cannot leave
@@ -1155,6 +1292,10 @@ export default function RoadtripSidebar({
onFollowTrack={onFollowTrack}
viaCount={viaCounts?.[day.dayId] ?? 0}
trackName={trackNames?.[day.dayId]}
+ refuel={refuel}
+ onAskRefuel={onAskRefuel}
+ onAcceptRefuel={onAcceptRefuel}
+ loading={routes.loading}
/>
))}
{routes.quietDays.map(day => (
diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts
new file mode 100644
index 000000000..befbfc46e
--- /dev/null
+++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect } from 'vitest'
+import { reachableRefuels, outcomeOf } from './refuelSuggestion'
+import type { LatLng } from './corridor'
+import type { Poi } from '../Map/poiCategories'
+
+// A straight run due east at 52° north, where a degree of longitude is about 68.5 km.
+const LINE: LatLng[] = [
+ { lat: 52, lng: 13 },
+ { lat: 52, lng: 14 },
+ { lat: 52, lng: 15 },
+]
+
+const poi = (name: string, lat: number, lng: number): Poi =>
+ ({ osm_id: `node:${name}`, name, lat, lng } as Poi)
+
+describe('reachableRefuels', () => {
+ it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => {
+ // Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side
+ // of an empty tank, and offering it is offering a walk.
+ const early = poi('early', 52, 13.73) // ~50 km
+ const late = poi('late', 52, 14.9) // ~130 km
+ const out = reachableRefuels([early, late], LINE, 100)
+ expect(out.map(p => p.name)).toEqual(['early'])
+ })
+
+ it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => {
+ // Both sit at the same point along the road, 60 km in, with 100 km of range. One is
+ // on the road; the other is 20 km off it, so 40 km of driving, which no longer fits
+ // in the 40 km that are left once the reserve is kept back.
+ const onRoad = poi('on-road', 52, 13.876)
+ const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north
+ const out = reachableRefuels([onRoad, offRoad], LINE, 100)
+ expect(out.map(p => p.name)).toEqual(['on-road'])
+ })
+
+ it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => {
+ // 8 km before the dry point is inside the ten kilometre reserve.
+ const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km
+ expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([])
+ })
+
+ it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => {
+ // Measured on a real day: the station that got furthest was 9.2 km off the road for
+ // one kilometre more of range. Everything in this list is already reachable, so what
+ // separates them is what the stop costs.
+ const nearRoad = poi('near-road', 52, 13.15) // on the line, early
+ const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later
+ const out = reachableRefuels([bigDetour, nearRoad], LINE, 100)
+ expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour'])
+ })
+
+ it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => {
+ // Filling up early wastes the tank that was already paid for.
+ const early = poi('early', 52, 13.15)
+ const later = poi('later', 52, 14.16)
+ const out = reachableRefuels([early, later], LINE, 100)
+ expect(out.map(p => p.name)).toEqual(['later', 'early'])
+ })
+
+ it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => {
+ // Matched on position, not on id: the same station added by hand or from another
+ // source carries a different osm_id.
+ const pump = poi('pump', 52, 13.73)
+ const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] })
+ expect(out).toEqual([])
+ })
+
+ it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => {
+ const wayOff = poi('way-off', 49, 13.5)
+ expect(reachableRefuels([wayOff], LINE, 100)).toEqual([])
+ })
+})
+
+describe('outcomeOf', () => {
+ it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => {
+ // The distinction the whole band hangs on. Saying "none on this stretch" after a
+ // failed request states a fact that was never checked.
+ expect(outcomeOf([], null)).toBe('failed')
+ expect(outcomeOf([], { truncated: true })).toBe('incomplete')
+ expect(outcomeOf([], { clamped: true })).toBe('incomplete')
+ expect(outcomeOf([], {})).toBe('none')
+ })
+
+ it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
+ const found = [{ name: 'x' }] as never
+ expect(outcomeOf(found, { truncated: true })).toBe('found')
+ })
+})
+
+describe('reachableRefuels — one entry per station', () => {
+ const at = (name: string, lat: number, lng: number): Poi =>
+ ({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi)
+
+ it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => {
+ // Measured on a real day: the top three offers were the same "autostrom plus GmbH"
+ // three times over, because OSM maps every socket as its own node.
+ // The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m
+ // between the pairs.
+ const sockets = [
+ at('autostrom plus GmbH', 52, 13.3),
+ at('autostrom plus GmbH', 52.00005, 13.30005),
+ at('autostrom plus GmbH', 52.004, 13.3005),
+ at('autostrom plus GmbH', 52.00405, 13.30045),
+ ]
+ const out = reachableRefuels(sockets, LINE, 100)
+ expect(out).toHaveLength(1)
+ })
+
+ it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => {
+ // Name alone is not identity: a chain has a branch everywhere.
+ // Measured: genuinely different branches were 18 to 36 km apart.
+ const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100)
+ expect(out).toHaveLength(2)
+ })
+
+ it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => {
+ // Position alone is not identity either.
+ const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100)
+ expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
+ })
+})
diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts
new file mode 100644
index 000000000..21721c413
--- /dev/null
+++ b/client/src/components/Roadtrip/refuelSuggestion.ts
@@ -0,0 +1,120 @@
+import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
+import type { Poi } from '../Map/poiCategories'
+
+/**
+ * Picking a filling station for a tank that is about to run out.
+ *
+ * Pure: no React, no network. The caller does the one request and hands the answers
+ * here, which keeps the judgement — which of these can the car actually reach — testable
+ * without a server.
+ */
+
+/** A candidate, with the two figures that decide whether it is any use. */
+export interface RefuelCandidate extends Poi {
+ /** Kilometres along the day's driving line, in the same space as the dry point. */
+ alongKm: number
+ /** How far off the road it sits. */
+ offRouteKm: number
+ /** Kilometres of range still in the tank when the car draws level with it. */
+ spareKm: number
+}
+
+/**
+ * How much of the last drop to keep back, in kilometres.
+ *
+ * Arriving at a pump with nothing left is not a plan, and the range figure a traveller
+ * types is a round number from a manual rather than a measurement. Ten kilometres is
+ * small enough not to move the suggestion and large enough not to promise the
+ * impossible.
+ */
+const RESERVE_KM = 10
+
+/**
+ * The stations that can actually be reached, nearest to the dry point first.
+ *
+ * Three filters, and each one exists because its absence produces a wrong answer rather
+ * than an untidy one:
+ *
+ * - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the
+ * far side of an empty tank, and offering it is offering a walk.
+ * - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a
+ * tank this close to empty that is the difference between arriving and not.
+ * - What is left has to fit in the range that remains, minus a reserve.
+ *
+ * Sorted by the detour, not by how far along the road it sits. Measured against a real
+ * day: the station that got furthest was 9.2 km off the road for one kilometre more of
+ * range, while one 0.9 km off sat almost as late. Everything in this list is already
+ * reachable, so what separates them is what the stop costs, and that is the detour. Ties
+ * go to the later one, because filling up early wastes the tank you already paid for.
+ */
+export function reachableRefuels(
+ pois: Poi[],
+ line: LatLng[],
+ dryAlongKm: number,
+ { existing = [] }: { existing?: { lat: number; lng: number }[] } = {},
+): RefuelCandidate[] {
+ const out: RefuelCandidate[] = []
+ for (const poi of pois) {
+ const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line)
+ if (!hit) continue
+ // Reached before the tank empties, once the detour out and back is paid for.
+ const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM
+ if (spareKm < 0) continue
+ // Already planned. Matched on position rather than on id: the same pump added by
+ // hand, or from another source, carries a different osm_id and would otherwise be
+ // suggested next to itself.
+ if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue
+ out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm })
+ }
+ return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm))
+}
+
+/**
+ * What a search actually established, as opposed to what it returned.
+ *
+ * An empty list means one of two very different things, and saying the wrong one is
+ * worse than saying nothing: either there is no filling station on this stretch, or we
+ * never managed to look. The server flags the second case itself (`truncated` when it
+ * hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the
+ * third. Only `none` is a statement about the road.
+ */
+export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed'
+
+export function outcomeOf(
+ candidates: RefuelCandidate[],
+ answer: { truncated?: boolean; clamped?: boolean } | null,
+): RefuelOutcome {
+ if (!answer) return 'failed'
+ if (candidates.length) return 'found'
+ return answer.truncated || answer.clamped ? 'incomplete' : 'none'
+}
+
+/**
+ * One entry per filling station, not one per pump.
+ *
+ * A charging site is mapped in OSM as a node per socket and a forecourt often as one per
+ * pump, all with the same name a few metres apart. Deduplicating by id keeps every one of
+ * them, which is right on a map and wrong in a list of three offers: measured on a real
+ * day, the top three were the same "autostrom plus GmbH" three times over.
+ *
+ * Same name and within half a kilometre counts as the same place. Both halves matter:
+ * not position alone, because two operators do share a motorway services, and not name
+ * alone, because a chain has a branch in every town.
+ *
+ * Half a kilometre comes from the data rather than from taste. In one 25 km search the
+ * four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between
+ * the pairs — while genuinely different branches of the same chain were 18 to 36 km
+ * apart. Anything from 500 m to a few kilometres separates those two cases; the lower end
+ * is the safer place to stand.
+ */
+const SAME_PLACE_KM = 0.5
+
+function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
+ const kept: RefuelCandidate[] = []
+ for (const poi of sorted) {
+ const twin = kept.some(k =>
+ k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM)
+ if (!twin) kept.push(poi)
+ }
+ return kept
+}
diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts
new file mode 100644
index 000000000..faf83735e
--- /dev/null
+++ b/client/src/components/Roadtrip/useRefuelSearch.ts
@@ -0,0 +1,92 @@
+import { useCallback, useRef, useState } from 'react'
+import { mapsApi } from '../../api/client'
+import { useTranslation } from '../../i18n'
+import { isEffectivelyOffline } from '../../sync/networkMode'
+import { boxAround, type LatLng } from './corridor'
+import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
+
+/**
+ * Somewhere to fill up before the tank runs out, asked for once, when asked for.
+ *
+ * Deliberately NOT automatic, for the same reason the corridor search next door is not:
+ * every run is a real request against a shared service. It is also not the corridor
+ * search itself, although it looks like a smaller version of it. Three reasons, all
+ * about behaviour rather than tidiness:
+ *
+ * - that search is bound to the day the panel has selected, so it cannot answer a
+ * warning on day five while the panel shows day one;
+ * - starting it clears the panel's results and the traveller's own name filter;
+ * - it asks up to sixteen boxes along a whole day. This question needs one small circle.
+ *
+ * One request, one day, no shared state touched.
+ */
+
+/** How far around the dry point to look, in kilometres. */
+const LOOK_KM = 25
+
+export interface RefuelSearch {
+ /** Which dry point is being answered, as `:`, or null when idle. */
+ openFor: string | null
+ loading: boolean
+ outcome: RefuelOutcome | null
+ results: RefuelCandidate[]
+ /** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
+ ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise
+ close: () => void
+}
+
+export function useRefuelSearch(): RefuelSearch {
+ const { locale } = useTranslation()
+ const [openFor, setOpenFor] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [outcome, setOutcome] = useState(null)
+ const [results, setResults] = useState([])
+ const abortRef = useRef(null)
+
+ const close = useCallback(() => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ setOpenFor(null)
+ setResults([])
+ setOutcome(null)
+ setLoading(false)
+ }, [])
+
+ const ask = useCallback(async (
+ key: string,
+ at: LatLng,
+ line: LatLng[],
+ dryAlongKm: number,
+ existing: LatLng[],
+ ) => {
+ abortRef.current?.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ setOpenFor(key)
+ setResults([])
+ setOutcome(null)
+
+ // Offline the answer would be a list of places that cannot be saved: the place write
+ // queues offline but the day assignment does not, so accepting one would leave an
+ // orphan. Say so instead of offering it.
+ if (isEffectivelyOffline()) {
+ setOutcome('failed')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal)
+ if (controller.signal.aborted) return
+ const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
+ setResults(candidates)
+ setOutcome(outcomeOf(candidates, answer))
+ } catch {
+ if (!controller.signal.aborted) setOutcome('failed')
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ }, [locale])
+
+ return { openFor, loading, outcome, results, ask, close }
+}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 4dbc83d02..b0d1e310a 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -68,6 +68,11 @@ export interface RoadtripDay {
* not route — the budget gives up rather than guessing, and so does this.
*/
dryPoints?: (DryPoint & { lat: number; lng: number })[]
+ /**
+ * `geometry` with the non-driving runs left out, which is what `dryPoints` are measured
+ * along. The same array as `geometry` on a day that drives the whole way.
+ */
+ drivingGeometry?: [number, number][]
/**
* The roads actually driven that day, as [lat, lng] — not the straight lines between
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
@@ -561,8 +566,13 @@ export function useRoadtripRoutes(
return at ? { ...dry, lat: at.lat, lng: at.lng } : null
})
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null)
+ // The same line the dry points are measured along, so anything offered against
+ // them is projected in the same space. Handed on as the SAME array when the day
+ // drives all the way through, which is nearly every day — only a day carrying a
+ // ferry or a walk pays for a second copy.
+ const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
out.push({
- ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints,
+ ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry,
driveWarnings: drive.warnings,
dayWarning: drive.day,
})
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index f13264469..514999e89 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
setRoadtripStopKind,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
- routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
+ routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
@@ -549,6 +549,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
viaCounts={roadtripViaCounts}
trackNames={followTrack.namesByDay}
+ refuel={refuel}
+ onAskRefuel={askRefuel}
+ onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined}
/>
) : (
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index dce17731d..5a87b0139 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -24,6 +24,8 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
+import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
+import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
@@ -34,8 +36,7 @@ import {
reanchorAfterInsert,
reanchorAfterRemove,
reanchorByStopOrder,
- reanchorAfterReorder,
-} from '../../components/Roadtrip/roadtripModel'
+ reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
import type { RoadtripStopType } from '@trek/shared'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
@@ -539,6 +540,7 @@ export function useTripPlanner() {
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
+ const refuel = useRefuelSearch()
const roadtripRoutes = useRoadtripRoutes(
tripId,
roadtripActive ? days : EMPTY_DAYS,
@@ -1204,6 +1206,49 @@ export function useTripPlanner() {
}
}, [anchorFor, roadtripVias, can, trip, toast, t])
+ /**
+ * Somewhere to fill up before this tank runs out.
+ *
+ * Measured against the day's DRIVING line, the same one the dry point was placed on, so
+ * a station's distance along the road is comparable with the distance the fuel lasts.
+ * The day's own places go in as well: a pump already on the plan should not be offered
+ * beside itself.
+ */
+ const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
+ const day = roadtripRoutes.days.find(d => d.dayId === dayId)
+ if (!day) return
+ const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
+ if (line.length < 2) return
+ void refuel.ask(
+ `${dayId}:${dry.legIndex}`,
+ { lat: dry.lat, lng: dry.lng },
+ line,
+ dry.drivenMeters / 1000,
+ day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
+ )
+ }, [roadtripRoutes.days, refuel])
+
+ /**
+ * Accepting one hands it to the same popup a corridor hit goes through.
+ *
+ * Deliberately not a direct write: the popup is where the stop kind and the time spent
+ * are decided, it defaults both from the category, and every step after it — the place,
+ * the assignment at the right position, the via re-anchoring, the re-route — is already
+ * correct there and pinned by tests. A second path to the same end would be a second
+ * place for it to go wrong.
+ */
+ const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => {
+ if (!can('day_edit', trip)) return
+ const day = roadtripRoutes.days.find(d => d.dayId === dayId)
+ if (!day) return
+ // Before the stop the tank would have run out on, which is the leg the dry point
+ // names. A station reached after the day's last stop is tomorrow's problem, and
+ // clamping it onto the final leg would re-route the arrival through it.
+ const position = Math.min(dry.legIndex + 1, day.stops.length - 1)
+ refuel.close()
+ setStopDraft({ poi, dayId, position, dayNumber: day.dayNumber })
+ }, [roadtripRoutes.days, refuel, can, trip])
+
/** Removing a via lets the drive take the direct road again. */
const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
if (!can('day_edit', trip)) return
@@ -1824,6 +1869,7 @@ export function useTripPlanner() {
setRoadtripStopKind,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
+ refuel, askRefuel, acceptRefuel,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index 986ea8e64..8dc5a24d1 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': 'تجنُّب {count}',
'roadtrip.avoid.missed': 'لا مفر من {classes}',
'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.',
+ 'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}',
+ 'roadtrip.refuel.find': 'ابحث عن وقود',
+ 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…',
+ 'roadtrip.refuel.spare': 'يتبقى {distance}',
+ 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود',
+ 'roadtrip.refuel.none': 'لم يُعثر على شيء في المسافة التي يمكن بلوغها.',
+ 'roadtrip.refuel.incomplete': 'اقتُطع البحث، لذا لم تُفحص هذه المسافة بالكامل.',
+ 'roadtrip.refuel.failed': 'لم يستجب البحث عن الأماكن.',
'roadtrip.stop.none': 'وجهة',
'roadtrip.stay.mode.pause': 'استراحة',
'roadtrip.stay.mode.night': 'مبيت',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index 792bfe69b..4333b3b7c 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} evitados',
'roadtrip.avoid.missed': '{classes} inevitável',
'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.',
+ 'roadtrip.refuel.dry': 'O tanque acaba neste trecho, após {distance}',
+ 'roadtrip.refuel.find': 'Buscar combustível',
+ 'roadtrip.refuel.looking': 'Procurando ao longo da rota…',
+ 'roadtrip.refuel.spare': 'restam {distance}',
+ 'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento',
+ 'roadtrip.refuel.none': 'Nada encontrado no trecho alcançável.',
+ 'roadtrip.refuel.incomplete': 'A busca foi cortada, então este trecho não foi verificado por completo.',
+ 'roadtrip.refuel.failed': 'A busca de lugares não respondeu.',
'roadtrip.stop.none': 'Um destino',
'roadtrip.stay.mode.pause': 'Pausa',
'roadtrip.stay.mode.night': 'Pernoite',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index f4d692377..910f9018b 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} evitats',
'roadtrip.avoid.missed': '{classes} inevitable',
'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.',
+ 'roadtrip.refuel.dry': 'El dipòsit s’esgota en aquesta etapa, després de {distance}',
+ 'roadtrip.refuel.find': 'Cerca combustible',
+ 'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…',
+ 'roadtrip.refuel.spare': 'queden {distance}',
+ 'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible',
+ 'roadtrip.refuel.none': 'No s’ha trobat res al tram accessible.',
+ 'roadtrip.refuel.incomplete': 'La cerca s’ha tallat, així que aquest tram no s’ha comprovat del tot.',
+ 'roadtrip.refuel.failed': 'La cerca de llocs no ha respost.',
'roadtrip.stop.none': 'Una destinació',
'roadtrip.stay.mode.pause': 'Parada',
'roadtrip.stay.mode.night': 'Pernoctar',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 5782bfab8..9e9a9568d 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} vynecháno',
'roadtrip.avoid.missed': '{classes} nelze objet',
'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.',
+ 'roadtrip.refuel.dry': 'Na tomto úseku dojde palivo, po {distance}',
+ 'roadtrip.refuel.find': 'Najít palivo',
+ 'roadtrip.refuel.looking': 'Hledám podél trasy…',
+ 'roadtrip.refuel.spare': 'zbývá {distance}',
+ 'roadtrip.refuel.add': 'Přidat {name} jako zastávku na palivo',
+ 'roadtrip.refuel.none': 'Na dosažitelném úseku nic nenalezeno.',
+ 'roadtrip.refuel.incomplete': 'Hledání bylo zkráceno, tento úsek tedy není zkontrolovaný celý.',
+ 'roadtrip.refuel.failed': 'Vyhledávání míst neodpovědělo.',
'roadtrip.stop.none': 'Cíl cesty',
'roadtrip.stay.mode.pause': 'Pauza',
'roadtrip.stay.mode.night': 'Nocleh',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index fcf9d6679..9d45278c3 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} gemieden',
'roadtrip.avoid.missed': '{classes} unvermeidbar',
'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.',
+ 'roadtrip.refuel.dry': 'Auf dieser Etappe geht der Tank leer, nach {distance}',
+ 'roadtrip.refuel.find': 'Tankstelle suchen',
+ 'roadtrip.refuel.looking': 'Suche entlang der Strecke…',
+ 'roadtrip.refuel.spare': 'noch {distance}',
+ 'roadtrip.refuel.add': '{name} als Tankstopp übernehmen',
+ 'roadtrip.refuel.none': 'Auf der erreichbaren Strecke nichts gefunden.',
+ 'roadtrip.refuel.incomplete': 'Die Suche wurde abgeschnitten, die Strecke ist also nicht vollständig geprüft.',
+ 'roadtrip.refuel.failed': 'Die Ortssuche hat nicht geantwortet.',
'roadtrip.stop.none': 'Ein Ziel',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Übernachten',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index f98714b89..249dfbc1a 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} avoided',
'roadtrip.avoid.missed': '{classes} unavoidable',
'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.',
+ 'roadtrip.refuel.dry': 'The tank runs out on this leg, {distance} in',
+ 'roadtrip.refuel.find': 'Find fuel',
+ 'roadtrip.refuel.looking': 'Looking along the route…',
+ 'roadtrip.refuel.spare': '{distance} to spare',
+ 'roadtrip.refuel.add': 'Add {name} as a fuel stop',
+ 'roadtrip.refuel.none': 'Nothing found on the reachable stretch.',
+ 'roadtrip.refuel.incomplete': 'The search was cut short, so this stretch was not fully checked.',
+ 'roadtrip.refuel.failed': 'The place search did not answer.',
'roadtrip.stop.none': 'A destination',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Overnight',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 14f32415b..b9d907570 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} evitados',
'roadtrip.avoid.missed': '{classes} inevitable(s)',
'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.',
+ 'roadtrip.refuel.dry': 'El depósito se vacía en este tramo, tras {distance}',
+ 'roadtrip.refuel.find': 'Buscar combustible',
+ 'roadtrip.refuel.looking': 'Buscando a lo largo de la ruta…',
+ 'roadtrip.refuel.spare': 'quedan {distance}',
+ 'roadtrip.refuel.add': 'Añadir {name} como parada de repostaje',
+ 'roadtrip.refuel.none': 'No se encontró nada en el tramo alcanzable.',
+ 'roadtrip.refuel.incomplete': 'La búsqueda se cortó, así que este tramo no se comprobó por completo.',
+ 'roadtrip.refuel.failed': 'La búsqueda de lugares no respondió.',
'roadtrip.stop.none': 'Un destino',
'roadtrip.stay.mode.pause': 'Parada',
'roadtrip.stay.mode.night': 'Pernoctar',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 245059321..6b40b2232 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} évité(s)',
'roadtrip.avoid.missed': '{classes} inévitable(s)',
'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.',
+ 'roadtrip.refuel.dry': 'Le réservoir se vide sur cette étape, après {distance}',
+ 'roadtrip.refuel.find': 'Chercher du carburant',
+ 'roadtrip.refuel.looking': 'Recherche le long du trajet…',
+ 'roadtrip.refuel.spare': 'reste {distance}',
+ 'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant',
+ 'roadtrip.refuel.none': 'Rien trouvé sur la portion accessible.',
+ 'roadtrip.refuel.incomplete': 'La recherche a été tronquée, cette portion n’a donc pas été entièrement vérifiée.',
+ 'roadtrip.refuel.failed': 'La recherche de lieux n’a pas répondu.',
'roadtrip.stop.none': 'Une destination',
'roadtrip.stay.mode.pause': 'Pause',
'roadtrip.stay.mode.night': 'Nuitée',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index 369c33372..87ce149a4 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} αποφεύγονται',
'roadtrip.avoid.missed': '{classes} αναπόφευκτα',
'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.',
+ 'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}',
+ 'roadtrip.refuel.find': 'Εύρεση καυσίμων',
+ 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…',
+ 'roadtrip.refuel.spare': 'μένουν {distance}',
+ 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού',
+ 'roadtrip.refuel.none': 'Δεν βρέθηκε τίποτα στο προσβάσιμο τμήμα.',
+ 'roadtrip.refuel.incomplete': 'Η αναζήτηση κόπηκε, οπότε το τμήμα δεν ελέγχθηκε πλήρως.',
+ 'roadtrip.refuel.failed': 'Η αναζήτηση τοποθεσιών δεν απάντησε.',
'roadtrip.stop.none': 'Προορισμός',
'roadtrip.stay.mode.pause': 'Στάση',
'roadtrip.stay.mode.night': 'Διανυκτέρευση',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 81cc19e3c..e3445d086 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} kerülve',
'roadtrip.avoid.missed': '{classes} nem kerülhető',
'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.',
+ 'roadtrip.refuel.dry': 'Ezen a szakaszon fogy ki a tank, {distance} után',
+ 'roadtrip.refuel.find': 'Üzemanyag keresése',
+ 'roadtrip.refuel.looking': 'Keresés az útvonal mentén…',
+ 'roadtrip.refuel.spare': 'marad {distance}',
+ 'roadtrip.refuel.add': '{name} hozzáadása tankolási megállóként',
+ 'roadtrip.refuel.none': 'Az elérhető szakaszon nincs találat.',
+ 'roadtrip.refuel.incomplete': 'A keresés csonkult, így ez a szakasz nincs teljesen ellenőrizve.',
+ 'roadtrip.refuel.failed': 'A helykeresés nem válaszolt.',
'roadtrip.stop.none': 'Úti cél',
'roadtrip.stay.mode.pause': 'Szünet',
'roadtrip.stay.mode.night': 'Éjszakázás',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 9b36fafab..f6071b000 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} dihindari',
'roadtrip.avoid.missed': '{classes} tak terhindarkan',
'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.',
+ 'roadtrip.refuel.dry': 'Tangki habis di ruas ini, setelah {distance}',
+ 'roadtrip.refuel.find': 'Cari bahan bakar',
+ 'roadtrip.refuel.looking': 'Mencari di sepanjang rute…',
+ 'roadtrip.refuel.spare': 'sisa {distance}',
+ 'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar',
+ 'roadtrip.refuel.none': 'Tidak ada yang ditemukan di ruas yang terjangkau.',
+ 'roadtrip.refuel.incomplete': 'Pencarian terpotong, jadi ruas ini belum diperiksa sepenuhnya.',
+ 'roadtrip.refuel.failed': 'Pencarian tempat tidak menjawab.',
'roadtrip.stop.none': 'Sebuah tujuan',
'roadtrip.stay.mode.pause': 'Istirahat',
'roadtrip.stay.mode.night': 'Menginap',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index 9a9127bb4..ba741a196 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} evitati',
'roadtrip.avoid.missed': '{classes} inevitabili',
'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.',
+ 'roadtrip.refuel.dry': 'Il serbatoio si esaurisce in questa tappa, dopo {distance}',
+ 'roadtrip.refuel.find': 'Cerca carburante',
+ 'roadtrip.refuel.looking': 'Ricerca lungo il percorso…',
+ 'roadtrip.refuel.spare': 'restano {distance}',
+ 'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante',
+ 'roadtrip.refuel.none': 'Niente trovato nel tratto raggiungibile.',
+ 'roadtrip.refuel.incomplete': 'La ricerca è stata troncata, quindi questo tratto non è stato controllato del tutto.',
+ 'roadtrip.refuel.failed': 'La ricerca dei luoghi non ha risposto.',
'roadtrip.stop.none': 'Una destinazione',
'roadtrip.stay.mode.pause': 'Sosta',
'roadtrip.stay.mode.night': 'Pernottamento',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index 8de6f1eb1..70808dd28 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} 件を回避',
'roadtrip.avoid.missed': '{classes} は回避できず',
'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。',
+ 'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)',
+ 'roadtrip.refuel.find': '給油所を探す',
+ 'roadtrip.refuel.looking': 'ルート沿いを検索中…',
+ 'roadtrip.refuel.spare': '残り {distance}',
+ 'roadtrip.refuel.add': '{name} を給油の停車地として追加',
+ 'roadtrip.refuel.none': '到達できる範囲には見つかりませんでした。',
+ 'roadtrip.refuel.incomplete': '検索が途中で打ち切られたため、この区間は完全には調べられていません。',
+ 'roadtrip.refuel.failed': '場所の検索が応答しませんでした。',
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '休憩',
'roadtrip.stay.mode.night': '宿泊',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index d05b81de7..780ca16c7 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count}개 회피',
'roadtrip.avoid.missed': '{classes} 회피 불가',
'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.',
+ 'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)',
+ 'roadtrip.refuel.find': '주유소 찾기',
+ 'roadtrip.refuel.looking': '경로를 따라 찾는 중…',
+ 'roadtrip.refuel.spare': '{distance} 여유',
+ 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가',
+ 'roadtrip.refuel.none': '도달 가능한 구간에서 찾지 못했습니다.',
+ 'roadtrip.refuel.incomplete': '검색이 중단되어 이 구간을 완전히 확인하지 못했습니다.',
+ 'roadtrip.refuel.failed': '장소 검색이 응답하지 않았습니다.',
'roadtrip.stop.none': '목적지',
'roadtrip.stay.mode.pause': '휴식',
'roadtrip.stay.mode.night': '숙박',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 6219aac38..007ab2e76 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} vermeden',
'roadtrip.avoid.missed': '{classes} onvermijdelijk',
'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.',
+ 'roadtrip.refuel.dry': 'De tank raakt leeg op dit traject, na {distance}',
+ 'roadtrip.refuel.find': 'Tankstation zoeken',
+ 'roadtrip.refuel.looking': 'Zoeken langs de route…',
+ 'roadtrip.refuel.spare': 'nog {distance}',
+ 'roadtrip.refuel.add': '{name} als tankstop toevoegen',
+ 'roadtrip.refuel.none': 'Niets gevonden op het bereikbare stuk.',
+ 'roadtrip.refuel.incomplete': 'De zoekopdracht werd afgekapt, dit stuk is dus niet volledig gecontroleerd.',
+ 'roadtrip.refuel.failed': 'De plaatszoekopdracht gaf geen antwoord.',
'roadtrip.stop.none': 'Een bestemming',
'roadtrip.stay.mode.pause': 'Pauze',
'roadtrip.stay.mode.night': 'Overnachten',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 9bba0abfe..398b36ed2 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} omijane',
'roadtrip.avoid.missed': '{classes} nie do ominięcia',
'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.',
+ 'roadtrip.refuel.dry': 'Na tym odcinku kończy się paliwo, po {distance}',
+ 'roadtrip.refuel.find': 'Znajdź paliwo',
+ 'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…',
+ 'roadtrip.refuel.spare': 'zostaje {distance}',
+ 'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo',
+ 'roadtrip.refuel.none': 'Nic nie znaleziono na dostępnym odcinku.',
+ 'roadtrip.refuel.incomplete': 'Wyszukiwanie zostało ucięte, więc odcinek nie jest sprawdzony w całości.',
+ 'roadtrip.refuel.failed': 'Wyszukiwarka miejsc nie odpowiedziała.',
'roadtrip.stop.none': 'Cel podróży',
'roadtrip.stay.mode.pause': 'Postój',
'roadtrip.stay.mode.night': 'Nocleg',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 194aa6885..f57ca820d 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} в обход',
'roadtrip.avoid.missed': '{classes} не обойти',
'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.',
+ 'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}',
+ 'roadtrip.refuel.find': 'Найти заправку',
+ 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…',
+ 'roadtrip.refuel.spare': 'останется {distance}',
+ 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки',
+ 'roadtrip.refuel.none': 'На доступном участке ничего не найдено.',
+ 'roadtrip.refuel.incomplete': 'Поиск был обрезан, поэтому участок проверен не полностью.',
+ 'roadtrip.refuel.failed': 'Поиск мест не ответил.',
'roadtrip.stop.none': 'Пункт назначения',
'roadtrip.stay.mode.pause': 'Остановка',
'roadtrip.stay.mode.night': 'Ночёвка',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index f466fe0d6..8d48aa86e 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} undviks',
'roadtrip.avoid.missed': '{classes} går inte att undvika',
'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.',
+ 'roadtrip.refuel.dry': 'Tanken tar slut på den här etappen, efter {distance}',
+ 'roadtrip.refuel.find': 'Hitta bränsle',
+ 'roadtrip.refuel.looking': 'Söker längs rutten…',
+ 'roadtrip.refuel.spare': '{distance} kvar',
+ 'roadtrip.refuel.add': 'Lägg till {name} som tankstopp',
+ 'roadtrip.refuel.none': 'Inget hittat på den nåbara sträckan.',
+ 'roadtrip.refuel.incomplete': 'Sökningen kapades, så sträckan är inte helt genomsökt.',
+ 'roadtrip.refuel.failed': 'Platssökningen svarade inte.',
'roadtrip.stop.none': 'Ett resmål',
'roadtrip.stay.mode.pause': 'Paus',
'roadtrip.stay.mode.night': 'Övernattning',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 04eecbfd1..235c83036 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} kaçınılıyor',
'roadtrip.avoid.missed': '{classes} kaçınılmaz',
'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.',
+ 'roadtrip.refuel.dry': 'Bu etapta depo bitiyor, {distance} sonra',
+ 'roadtrip.refuel.find': 'Yakıt bul',
+ 'roadtrip.refuel.looking': 'Rota boyunca aranıyor…',
+ 'roadtrip.refuel.spare': '{distance} kalır',
+ 'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin',
+ 'roadtrip.refuel.none': 'Ulaşılabilir bölümde bir şey bulunamadı.',
+ 'roadtrip.refuel.incomplete': 'Arama kesildi, bu yüzden bu bölüm tam olarak kontrol edilmedi.',
+ 'roadtrip.refuel.failed': 'Yer araması yanıt vermedi.',
'roadtrip.stop.none': 'Bir varış noktası',
'roadtrip.stay.mode.pause': 'Mola',
'roadtrip.stay.mode.night': 'Konaklama',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 23c3ae7d0..9f4fa7552 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} в обхід',
'roadtrip.avoid.missed': '{classes} не обійти',
'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.',
+ 'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}',
+ 'roadtrip.refuel.find': 'Знайти заправку',
+ 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…',
+ 'roadtrip.refuel.spare': 'лишиться {distance}',
+ 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки',
+ 'roadtrip.refuel.none': 'На доступній ділянці нічого не знайдено.',
+ 'roadtrip.refuel.incomplete': 'Пошук було обрізано, тож ділянку перевірено не повністю.',
+ 'roadtrip.refuel.failed': 'Пошук місць не відповів.',
'roadtrip.stop.none': 'Пункт призначення',
'roadtrip.stay.mode.pause': 'Зупинка',
'roadtrip.stay.mode.night': 'Ночівля',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index 5723bf4aa..3bc408bce 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': 'tránh {count}',
'roadtrip.avoid.missed': '{classes} không tránh được',
'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.',
+ 'roadtrip.refuel.dry': 'Hết xăng ở chặng này, sau {distance}',
+ 'roadtrip.refuel.find': 'Tìm trạm xăng',
+ 'roadtrip.refuel.looking': 'Đang tìm dọc tuyến đường…',
+ 'roadtrip.refuel.spare': 'còn {distance}',
+ 'roadtrip.refuel.add': 'Thêm {name} làm điểm đổ xăng',
+ 'roadtrip.refuel.none': 'Không tìm thấy gì trên đoạn có thể tới được.',
+ 'roadtrip.refuel.incomplete': 'Việc tìm kiếm bị cắt ngắn nên đoạn này chưa được kiểm tra đầy đủ.',
+ 'roadtrip.refuel.failed': 'Tìm kiếm địa điểm không phản hồi.',
'roadtrip.stop.none': 'Một điểm đến',
'roadtrip.stay.mode.pause': 'Nghỉ',
'roadtrip.stay.mode.night': 'Qua đêm',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 6f64cdf3a..60c93ce28 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '避開 {count} 項',
'roadtrip.avoid.missed': '無法避開{classes}',
'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。',
+ 'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後',
+ 'roadtrip.refuel.find': '尋找加油站',
+ 'roadtrip.refuel.looking': '正在沿路線尋找…',
+ 'roadtrip.refuel.spare': '剩餘 {distance}',
+ 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點',
+ 'roadtrip.refuel.none': '在可到達的路段上沒有找到。',
+ 'roadtrip.refuel.incomplete': '搜尋被截斷,因此這段路線沒有完全檢查。',
+ 'roadtrip.refuel.failed': '地點搜尋沒有回應。',
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '停留',
'roadtrip.stay.mode.night': '過夜',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 160f0db68..81012d2c6 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '避开 {count} 项',
'roadtrip.avoid.missed': '无法避开{classes}',
'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。',
+ 'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后',
+ 'roadtrip.refuel.find': '查找加油站',
+ 'roadtrip.refuel.looking': '正在沿路线查找…',
+ 'roadtrip.refuel.spare': '剩余 {distance}',
+ 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点',
+ 'roadtrip.refuel.none': '在可到达的路段上没有找到。',
+ 'roadtrip.refuel.incomplete': '搜索被截断,因此这段路线没有完全检查。',
+ 'roadtrip.refuel.failed': '地点搜索没有响应。',
'roadtrip.stop.none': '目的地',
'roadtrip.stay.mode.pause': '停留',
'roadtrip.stay.mode.night': '过夜',