From 5b147af0b692e36b85a20a13f5b9cd6419a3d0ea Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 21:59:36 +0200 Subject: [PATCH] feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things that only showed up once it was on screen. The range budget treated a petrol station and a charger as the same thing, so an electric car pausing for coffee at a filling station had its battery refilled on paper: the warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can now be named, and then only what actually refills it counts — and the search asks for that kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the old behaviour, which is what everybody who never opens the dialog is on. The band was its own shape in a rail made of drive bands. It is one now: the same rail, the same height, the same type size, a warning tint instead of a ring, and the figures where a drive band keeps them. The buttons are icons of the same 18px square the shuffle mark uses, so the band stays one line beside its neighbours. The old range warning stood down where the band shows. Both at once said the same thing twice in the wrong order — the answer above the problem — because the warning marks the stop somebody finds out at while the band sits on the leg the fuel ends on. Two measured corrections to the search itself. The server turns a box into a circle of half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35 and came back flagged as narrowed EVERY time, which read on screen as "we could not check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before the dry point rather than on it, because only road already driven is any use — centred on the point, half the circle was spent on road nobody can reach. An answer that finds nothing now leaves the button rather than only a close. The place search is a shared public service that does time out, and "it did not answer" with no way to ask again reads as broken rather than as busy. --- .../components/Map/useMergedMapPois.test.ts | 27 +++ client/src/components/Map/useMergedMapPois.ts | 30 ++- .../Roadtrip/RoadtripLimitsCard.tsx | 30 ++- .../components/Roadtrip/RoadtripSidebar.tsx | 190 +++++++++++------- .../Roadtrip/refuelSuggestion.test.ts | 5 +- .../components/Roadtrip/refuelSuggestion.ts | 5 +- .../components/Roadtrip/roadtripModel.test.ts | 39 ++++ .../src/components/Roadtrip/roadtripModel.ts | 29 ++- .../components/Roadtrip/useRefuelSearch.ts | 56 +++++- .../components/Roadtrip/useRoadtripRoutes.ts | 9 +- client/src/pages/TripPlannerPage.tsx | 9 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 3 + .../src/pages/tripPlanner/useTripPlanner.ts | 17 +- client/src/store/settingsStore.ts | 2 + client/src/types.ts | 8 + shared/src/i18n/ar/roadtrip.ts | 8 +- shared/src/i18n/br/roadtrip.ts | 8 +- shared/src/i18n/ca/roadtrip.ts | 8 +- shared/src/i18n/cs/roadtrip.ts | 8 +- shared/src/i18n/de/roadtrip.ts | 8 +- shared/src/i18n/en/roadtrip.ts | 8 +- shared/src/i18n/es/roadtrip.ts | 8 +- shared/src/i18n/fr/roadtrip.ts | 8 +- shared/src/i18n/gr/roadtrip.ts | 8 +- shared/src/i18n/hu/roadtrip.ts | 8 +- shared/src/i18n/id/roadtrip.ts | 8 +- shared/src/i18n/it/roadtrip.ts | 8 +- shared/src/i18n/ja/roadtrip.ts | 8 +- shared/src/i18n/ko/roadtrip.ts | 8 +- shared/src/i18n/nl/roadtrip.ts | 8 +- shared/src/i18n/pl/roadtrip.ts | 8 +- shared/src/i18n/ru/roadtrip.ts | 8 +- shared/src/i18n/sv/roadtrip.ts | 8 +- shared/src/i18n/tr/roadtrip.ts | 8 +- shared/src/i18n/uk/roadtrip.ts | 8 +- shared/src/i18n/vi/roadtrip.ts | 8 +- shared/src/i18n/zh-TW/roadtrip.ts | 8 +- shared/src/i18n/zh/roadtrip.ts | 8 +- 38 files changed, 518 insertions(+), 125 deletions(-) diff --git a/client/src/components/Map/useMergedMapPois.test.ts b/client/src/components/Map/useMergedMapPois.test.ts index 1ecb7a2cd..63d880bb3 100644 --- a/client/src/components/Map/useMergedMapPois.test.ts +++ b/client/src/components/Map/useMergedMapPois.test.ts @@ -57,3 +57,30 @@ describe('useMergedMapPois', () => { expect(result.current).toBe(first) }) }) + +describe('useMergedMapPois — the refuel offers', () => { + const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi) + + it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => { + // In road trip mode this is the only channel a POI reaches the map through, so + // without it somebody is asked to accept a stop they cannot see. + const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')])) + expect(result.current.map(p => p.osm_id)).toEqual(['offer']) + }) + + it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => { + const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')])) + expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b']) + }) + + it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => { + const corridor = [poi('a')] + const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), { + initialProps: { o: [] as Poi[] }, + }) + const first = result.current + rerender({ o: [] }) + expect(result.current).toBe(first) + expect(result.current).toBe(corridor) + }) +}) diff --git a/client/src/components/Map/useMergedMapPois.ts b/client/src/components/Map/useMergedMapPois.ts index b6253671a..a82209ba6 100644 --- a/client/src/components/Map/useMergedMapPois.ts +++ b/client/src/components/Map/useMergedMapPois.ts @@ -13,16 +13,30 @@ import type { Poi } from './poiCategories' * on: a hotel found both along the route and in the viewport is one pin, and the corridor's * copy wins because it carries how far along the drive it sits. * + * `offered` is the third case: the handful of filling stations proposed where a tank runs + * out. They belong on the map for a plain reason — somebody is being asked to accept a + * stop, and a stop you cannot see is not one you can judge — but they are not a search + * result and must not survive the panel being closed, which is why they arrive separately + * rather than being folded into the corridor's own list. + * * Returns the input array itself whenever there is nothing to merge. The map redraws off * this reference, so handing it a fresh array on every render would rebuild every pin. */ -export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] { +export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] { return useMemo(() => { - if (!corridor) return explore - if (!explore.length) return corridor - if (!corridor.length) return explore - const seen = new Set(corridor.map(p => p.osm_id)) - const extra = explore.filter(p => !seen.has(p.osm_id)) - return extra.length ? [...corridor, ...extra] : corridor - }, [corridor, explore]) + const base = corridor === null ? explore : merge(corridor, explore) + return merge(base, offered) + }, [corridor, explore, offered]) +} + +/** Nothing offered, as one stable array, so the default never changes identity. */ +const NONE: Poi[] = [] + +/** `a` with everything from `b` that it does not already carry, by `osm_id`. */ +function merge(a: Poi[], b: Poi[]): Poi[] { + if (!b.length) return a + if (!a.length) return b + const seen = new Set(a.map(p => p.osm_id)) + const extra = b.filter(p => !seen.has(p.osm_id)) + return extra.length ? [...a, ...extra] : a } diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index da58cb498..82baaf3b4 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' -import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react' +import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react' import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { convertDistance, formatDistance } from '../../utils/units' @@ -103,6 +104,13 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: { ) } +/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */ +const VEHICLES: { key: 'combustion' | 'electric' | null; labelKey: string; Icon: typeof Car }[] = [ + { key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car }, + { key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel }, + { key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap }, +] + export default function RoadtripLimitsCard({ onSave }: { /** * Persists one setting. Absent leaves the dialog read-only. @@ -123,6 +131,7 @@ export default function RoadtripLimitsCard({ onSave }: { // Parsed rather than trusted: a per-user setting gets no server-side validation, and // an unknown word here would become a costing option the router does not have. + const vehicle = settings.roadtrip_vehicle ?? '' const avoiding = parseAvoid(settings.roadtrip_avoid) // No second engine, no avoidance. An instance pointed at its own OSRM has one, and its // car profile is built without excludable classes on every public host. @@ -234,6 +243,25 @@ export default function RoadtripLimitsCard({ onSave }: { placeholder={t('roadtrip.limit.off')} onChange={v => onSave?.('roadtrip_day_minutes', v)} /> + {/* Above the range, because it decides what the range even means: with no + vehicle named, a petrol station and a charger both fill the tank, which + is arithmetic that is wrong for everybody who drives just one of them. */} + s.settings.distance_unit) const key = `${dayId}:${dry.legIndex}` const open = refuel.openFor === key + const settled = open && !refuel.loading && refuel.outcome return ( -
-
- - - {t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })} - - {open ? ( - - ) : ( - - )} +
+ + + +
+ {/* Built as a drive band, because that is what it belongs to: the leg the fuel + runs out on. Same height, same type, same rail. The warning ring is the only + difference, and it is the whole message — this is the band where the tank + ends. */} +
+
+ + + {t('roadtrip.refuel.dry')} + + {/* How far INTO this leg, where a drive band keeps its figures. Not the range + that was crossed: that is the traveller's own setting, says nothing about + where, and made every band on a day read the same number. */} + + {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} + + {/* An icon, the size of the shuffle mark a drive band carries, because the + band has to stay one line high beside its neighbours. What it does is in + the tooltip and in the label a screen reader gets. */} + {open && (refuel.loading || refuel.results.length) ? ( + + + + ) : ( + /* An answer that found nothing leaves the button, not a dead end. The place + search is a shared public service that does time out, and "it did not + answer" with no way to ask again reads as broken rather than as busy. */ + + + + )} +
+ + {open && refuel.loading ? ( + {t('roadtrip.refuel.looking')} + ) : null} + + {settled ? ( + refuel.results.length ? ( +
    + {/* 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, the figure that decides + whether this one is any use. The detour is already in it. */} + {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, which is worse than saying nothing. */ + + {refuel.outcome === 'none' + ? t('roadtrip.refuel.none') + : refuel.outcome === 'incomplete' + ? t('roadtrip.refuel.incomplete') + : t('roadtrip.refuel.failed')} + + ) + ) : null} +
- - {open && refuel.loading ? ( - {t('roadtrip.refuel.looking')} - ) : null} - - {open && !refuel.loading && refuel.outcome ? ( - refuel.results.length ? ( -
    - {/* 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}
) } @@ -956,6 +985,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const last = day.stops.length - 1 + /** + * The findings a stop wears, minus the one the refuel band already says better. + * + * The range warning marks the stop somebody FINDS OUT at and counts the whole tank; + * the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both + * at once says the same thing twice, in the wrong order — the answer above the problem + * — so the warning stands down wherever the band is showing. + */ + const showingBand = !!refuel && !loading && !!day.dryPoints?.length + const findingsFor = (i: number) => + day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 @@ -1091,7 +1131,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on stop={stop} entry={day.schedule.entries[i]} late={marks.find(w => w.code === 'late')} - driveFindings={day.driveWarnings.filter(w => w.index === i)} + driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} @@ -1103,7 +1143,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on number={counted} entry={day.schedule.entries[i]} late={marks.find(w => w.code === 'late')} - driveFindings={day.driveWarnings.filter(w => w.index === i)} + driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} continues={i < last} starts={i === 0} diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts index befbfc46e..886518769 100644 --- a/client/src/components/Roadtrip/refuelSuggestion.test.ts +++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts @@ -77,8 +77,11 @@ describe('outcomeOf', () => { // 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') + // `clamped` is not the same thing: it means the server searched a smaller circle + // than the box described, and the caller sizes the box so that never happens. + // Treating it as "could not check" turned every empty answer into a shrug. + expect(outcomeOf([], { clamped: true })).toBe('none') }) it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => { diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts index 21721c413..9daf24b80 100644 --- a/client/src/components/Roadtrip/refuelSuggestion.ts +++ b/client/src/components/Roadtrip/refuelSuggestion.ts @@ -86,7 +86,10 @@ export function outcomeOf( ): RefuelOutcome { if (!answer) return 'failed' if (candidates.length) return 'found' - return answer.truncated || answer.clamped ? 'incomplete' : 'none' + // `truncated` alone. `clamped` only means the server searched a smaller circle than + // the box described, which the caller now sizes so it never happens — and reporting it + // as "we could not check" turned every empty answer into a shrug. + return answer.truncated ? 'incomplete' : 'none' } /** diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 5d9d2a2e4..f42cf94f2 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -11,6 +11,7 @@ import { parseClock, splitIntoRuns, sumLegSeconds, + refuelStopTypeFor, } from './roadtripModel' describe('formatDurationShort', () => { @@ -687,3 +688,41 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { expect(out.carryKm).toBeNull() }) }) + +describe('refuelsRange — what fills which tank', () => { + it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => { + // What TREK did before the setting existed, and the right answer for somebody who + // never opened the dialog. Anything else would quietly change their warnings. + expect(refuelsRange('fuel')).toBe(true) + expect(refuelsRange('charging')).toBe(true) + expect(refuelsRange('fuel', null)).toBe(true) + expect(refuelsRange('charging', null)).toBe(true) + }) + + it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => { + // The bug this exists for: an electric car pausing at a petrol station had its + // battery refilled on paper, the warnings went quiet for the rest of the day, and + // the driver was told nothing. + expect(refuelsRange('charging', 'electric')).toBe(true) + expect(refuelsRange('fuel', 'electric')).toBe(false) + }) + + it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => { + expect(refuelsRange('fuel', 'combustion')).toBe(true) + expect(refuelsRange('charging', 'combustion')).toBe(false) + }) + + it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => { + for (const vehicle of [null, 'combustion', 'electric'] as const) { + expect(refuelsRange('rest_area', vehicle)).toBe(false) + expect(refuelsRange('restaurant', vehicle)).toBe(false) + expect(refuelsRange(null, vehicle)).toBe(false) + } + }) + + it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => { + expect(refuelStopTypeFor('combustion')).toEqual(['fuel']) + expect(refuelStopTypeFor('electric')).toEqual(['charging']) + expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging']) + }) +}) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 85e834fa5..91fed9b37 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -123,9 +123,32 @@ export interface ScheduleWarning { */ export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const -/** Whether stopping here starts the range budget over. */ -export function refuelsRange(stopType: string | null | undefined): boolean { - return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '') +/** + * What the traveller drives, which decides what actually fills the tank. + * + * Absent means both count, which is what TREK did before the setting existed and is the + * right answer for somebody who never said. It is only wrong once a vehicle IS named: + * a petrol station does not charge a battery, and a charger does not fill a tank. + */ +export type VehicleKind = 'combustion' | 'electric' + +/** The stop kind that refills the named vehicle, for filtering a search. */ +export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] { + if (vehicle === 'combustion') return ['fuel'] + if (vehicle === 'electric') return ['charging'] + return REFUELLING_STOP_TYPES +} + +/** + * Whether stopping here starts the range budget over. + * + * With no vehicle named, either kind does — a plain reading of "I filled up". Once one is + * named the other stops counting, because the alternative is arithmetic that is simply + * wrong: an electric car pausing at a petrol station had its battery refilled on paper, + * the warnings went quiet for the rest of the day, and the driver was told nothing. + */ +export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean { + return refuelStopTypeFor(vehicle).includes(stopType ?? '') } /** diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts index faf83735e..1d6ad2413 100644 --- a/client/src/components/Roadtrip/useRefuelSearch.ts +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -1,8 +1,10 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useMemo, 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 { useSettingsStore } from '../../store/settingsStore' +import { refuelStopTypeFor, type VehicleKind } from './roadtripModel' +import { boxAround, pointAtMeters, type LatLng } from './corridor' import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion' /** @@ -21,8 +23,27 @@ import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } * One request, one day, no shared state touched. */ -/** How far around the dry point to look, in kilometres. */ -const LOOK_KM = 25 +/** + * How far around the search point to look, in kilometres. + * + * Fourteen and not more, because the server turns a box into a circle of half its + * diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which + * is the largest question it answers in full — ±25 asked for 35 and came back flagged + * as narrowed every single time, which then read on screen as "we could not check". + */ +const LOOK_KM = 14 + +/** + * How far BEFORE the dry point to centre that circle, in kilometres. + * + * Only the road already driven is any use: a station past the point is on the far side + * of an empty tank. Centring on the dry point spends half the circle on road nobody can + * reach, so it is pulled back far enough that the useful half becomes the whole of it. + */ +const LOOK_BACK_KM = 12 + +/** Nothing on offer, as one stable array, so an idle search never redraws the map. */ +const NONE: RefuelCandidate[] = [] export interface RefuelSearch { /** Which dry point is being answered, as `:`, or null when idle. */ @@ -30,6 +51,15 @@ export interface RefuelSearch { loading: boolean outcome: RefuelOutcome | null results: RefuelCandidate[] + /** + * The ones on offer, for the map to draw. + * + * The same three the rail lists and no more: somebody is being asked to accept a stop, + * and a stop that cannot be seen is not one that can be judged. Empty while nothing is + * open, and the same array each time, because the map rebuilds every pin when this + * reference changes. + */ + offered: 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 @@ -37,6 +67,9 @@ export interface RefuelSearch { export function useRefuelSearch(): RefuelSearch { const { locale } = useTranslation() + // What to look for. Somebody who said they drive an electric car has no use for a + // petrol station in the list, and the other way round; with nothing said, both. + const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) const [openFor, setOpenFor] = useState(null) const [loading, setLoading] = useState(false) const [outcome, setOutcome] = useState(null) @@ -76,7 +109,14 @@ export function useRefuelSearch(): RefuelSearch { setLoading(true) try { - const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal) + const kind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null + const wanted = refuelStopTypeFor(kind).join(',') + // Pulled back along the road, because only what lies before the dry point can be + // reached. `at` stays the dry point itself: it is what the band names and what the + // map is asked to show. + const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at + const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal) if (controller.signal.aborted) return const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }) setResults(candidates) @@ -86,7 +126,9 @@ export function useRefuelSearch(): RefuelSearch { } finally { if (!controller.signal.aborted) setLoading(false) } - }, [locale]) + }, [locale, vehicle]) - return { openFor, loading, outcome, results, ask, close } + const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results]) + + return { openFor, loading, outcome, results, offered, ask, close } } diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index b0d1e310a..99042655d 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,7 +3,7 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula import { resolveLegMode } from '../Planner/legMode' import { computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, - type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel' + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel' import { projectOntoRoute, pointAtMeters} from './corridor' import { useSettingsStore } from '../../store/settingsStore' import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' @@ -283,8 +283,13 @@ export function useRoadtripRoutes( * any value — and an unknown word here would become a costing option the router does * not have. */ + const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid) const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) + // Validated rather than trusted, like the avoidance above: a per-user setting gets no + // server-side check, and an unknown word here would silently stop both kinds counting. + const vehicleKind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null const avoidKey = avoid.join(',') // Zero and absent both mean "no limit": zero is a legal thing to type and says the // same thing, so it is folded here rather than guarded at every reading. @@ -546,7 +551,7 @@ export function useRoadtripRoutes( legs, // One longer than the legs: the last stop of the day counts too, because filling // up on arrival is what makes the next morning start full. - stops.map(s => refuelsRange(s.stopType)), + stops.map(s => refuelsRange(s.stopType, vehicleKind)), limits, carryKm, ) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 514999e89..8d0ce48b9 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, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, @@ -325,7 +325,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null { // drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the // two answers are drawn side by side rather than one hiding the other. const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false - const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois) + // The refuel offers ride the same channel: in road trip mode this is the only way a + // POI reaches the map, so without them somebody is asked to accept a stop they cannot + // see. They vanish with the offer rather than lingering as a search result. + const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered) // Costs expense editor opened from a booking modal (save-then-open). Lives at the // page level so it has tripMembers / base currency / current user available. @@ -443,7 +446,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined} roadtripVias={roadtripActive ? roadtripVias.byDay : undefined} alternativeRoutes={alternativeOverlays} - focusPoints={alternativeFocusPoints} + focusPoints={mapFocusPoints} clusterLoosely={roadtripActive} activeAlternative={highlightedAlternative} onChooseAlternative={chooseRouteAlternative} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 1c7b09788..1cc0b59ba 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -150,6 +150,9 @@ function baseState(): HookState { roadtripActive: false, roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 }, roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() }, + refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() }, + askRefuel: vi.fn(), + acceptRefuel: vi.fn(), followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false }, roadtripViaCounts: {}, stopDraft: null, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 5a87b0139..5dc36c36e 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -1075,6 +1075,21 @@ export function useTripPlanner() { [alternativeOverlays], ) + /** + * What the map should bring into view. + * + * Refuel offers win while they are open, and for the reason they exist at all: somebody + * is being asked to accept a stop, and a stop off the edge of the map cannot be judged. + * They are the newer, smaller and more specific answer, so they take the view from the + * alternatives rather than being averaged with them into a frame that shows neither. + */ + const mapFocusPoints = useMemo<[number, number][]>( + () => (refuel.offered.length + ? refuel.offered.map(p => [p.lat, p.lng] as [number, number]) + : alternativeFocusPoints), + [refuel.offered, alternativeFocusPoints], + ) + /** Asks the router for other ways of driving one leg of one day. */ const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => { const day = roadtripRoutes.days.find(d => d.dayId === dayId) @@ -1870,7 +1885,7 @@ export function useTripPlanner() { saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, refuel, askRefuel, acceptRefuel, - routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index d9c2f0169..58282177e 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -64,6 +64,8 @@ export const DEFAULT_SETTINGS: Settings = { // Empty = the public Valhalla, unless routing_base_url names an own router, in which // case no second engine is asked at all. See valhallaBase(). valhalla_base_url: '', + // Empty = not said, and then a stop of either kind fills up. See refuelsRange(). + roadtrip_vehicle: '', dark_mode: false, // Empty = no personal display currency, so Costs falls back to the trip's own. default_currency: '', diff --git a/client/src/types.ts b/client/src/types.ts index 67a48079c..d5b014097 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -159,6 +159,14 @@ export interface Settings { * because it goes to the router as one request either way. */ roadtrip_avoid?: string + /** + * What the traveller drives: 'combustion', 'electric', or absent for "did not say". + * + * Absent has to mean BOTH kinds refill, which is what the range budget did before this + * existed. Anything else would quietly change the warnings of every traveller who never + * opened the dialog. + */ + roadtrip_vehicle?: string /** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */ carto_api_key?: string mapbox_access_token?: string diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 8dc5a24d1..3d999bda8 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'تجنُّب {count}', 'roadtrip.avoid.missed': 'لا مفر من {classes}', 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', - 'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}', + 'roadtrip.refuel.dry': 'ينفد الخزان هنا', + 'roadtrip.refuel.after': 'بعد {distance}', + 'roadtrip.limit.vehicleLabel': 'بماذا تقود', + 'roadtrip.limit.vehicleAny': 'كلاهما', + 'roadtrip.limit.vehicleCombustion': 'وقود', + 'roadtrip.limit.vehicleElectric': 'كهرباء', 'roadtrip.refuel.find': 'ابحث عن وقود', + 'roadtrip.refuel.again': 'حاول مجددًا', 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…', 'roadtrip.refuel.spare': 'يتبقى {distance}', 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 4333b3b7c..e684d0781 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -38,8 +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.dry': 'Aqui o tanque acaba', + 'roadtrip.refuel.after': 'após {distance}', + 'roadtrip.limit.vehicleLabel': 'O que você dirige', + 'roadtrip.limit.vehicleAny': 'Ambos', + 'roadtrip.limit.vehicleCombustion': 'Combustível', + 'roadtrip.limit.vehicleElectric': 'Elétrico', 'roadtrip.refuel.find': 'Buscar combustível', + 'roadtrip.refuel.again': 'Tentar de novo', 'roadtrip.refuel.looking': 'Procurando ao longo da rota…', 'roadtrip.refuel.spare': 'restam {distance}', 'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 910f9018b..ff6826f23 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -38,8 +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.dry': 'Aquí s’esgota el dipòsit', + 'roadtrip.refuel.after': 'després de {distance}', + 'roadtrip.limit.vehicleLabel': 'Què condueixes', + 'roadtrip.limit.vehicleAny': 'Tots dos', + 'roadtrip.limit.vehicleCombustion': 'Benzina', + 'roadtrip.limit.vehicleElectric': 'Elèctric', 'roadtrip.refuel.find': 'Cerca combustible', + 'roadtrip.refuel.again': 'Torna-ho a provar', 'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…', 'roadtrip.refuel.spare': 'queden {distance}', 'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 9e9a9568d..b065b4d41 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -38,8 +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.dry': 'Tady dojde palivo', + 'roadtrip.refuel.after': 'po {distance}', + 'roadtrip.limit.vehicleLabel': 'Čím jezdíš', + 'roadtrip.limit.vehicleAny': 'Obojí', + 'roadtrip.limit.vehicleCombustion': 'Palivo', + 'roadtrip.limit.vehicleElectric': 'Elektro', 'roadtrip.refuel.find': 'Najít palivo', + 'roadtrip.refuel.again': 'Zkusit znovu', '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', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 9d45278c3..e9461f66d 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -38,8 +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.dry': 'Hier ist der Tank leer', + 'roadtrip.refuel.after': 'nach {distance}', + 'roadtrip.limit.vehicleLabel': 'Womit du fährst', + 'roadtrip.limit.vehicleAny': 'Beides', + 'roadtrip.limit.vehicleCombustion': 'Sprit', + 'roadtrip.limit.vehicleElectric': 'Strom', 'roadtrip.refuel.find': 'Tankstelle suchen', + 'roadtrip.refuel.again': 'Nochmal', 'roadtrip.refuel.looking': 'Suche entlang der Strecke…', 'roadtrip.refuel.spare': 'noch {distance}', 'roadtrip.refuel.add': '{name} als Tankstopp übernehmen', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 249dfbc1a..e28a88682 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -38,8 +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.dry': 'Tank runs out here', + 'roadtrip.refuel.after': 'after {distance}', + 'roadtrip.limit.vehicleLabel': 'What you drive', + 'roadtrip.limit.vehicleAny': 'Either', + 'roadtrip.limit.vehicleCombustion': 'Petrol', + 'roadtrip.limit.vehicleElectric': 'Electric', 'roadtrip.refuel.find': 'Find fuel', + 'roadtrip.refuel.again': 'Try again', 'roadtrip.refuel.looking': 'Looking along the route…', 'roadtrip.refuel.spare': '{distance} to spare', 'roadtrip.refuel.add': 'Add {name} as a fuel stop', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index b9d907570..67ae0e0cb 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -38,8 +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.dry': 'Aquí se vacía el depósito', + 'roadtrip.refuel.after': 'tras {distance}', + 'roadtrip.limit.vehicleLabel': 'Qué conduces', + 'roadtrip.limit.vehicleAny': 'Ambos', + 'roadtrip.limit.vehicleCombustion': 'Gasolina', + 'roadtrip.limit.vehicleElectric': 'Eléctrico', 'roadtrip.refuel.find': 'Buscar combustible', + 'roadtrip.refuel.again': 'Reintentar', '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', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 6b40b2232..32f9e2f57 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -38,8 +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.dry': 'Réservoir vide ici', + 'roadtrip.refuel.after': 'après {distance}', + 'roadtrip.limit.vehicleLabel': 'Ce que vous conduisez', + 'roadtrip.limit.vehicleAny': 'Les deux', + 'roadtrip.limit.vehicleCombustion': 'Carburant', + 'roadtrip.limit.vehicleElectric': 'Électrique', 'roadtrip.refuel.find': 'Chercher du carburant', + 'roadtrip.refuel.again': 'Réessayer', 'roadtrip.refuel.looking': 'Recherche le long du trajet…', 'roadtrip.refuel.spare': 'reste {distance}', 'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 87ce149a4..e8852eaaf 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} αποφεύγονται', 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', - 'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}', + 'roadtrip.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ', + 'roadtrip.refuel.after': 'μετά από {distance}', + 'roadtrip.limit.vehicleLabel': 'Τι οδηγείς', + 'roadtrip.limit.vehicleAny': 'Και τα δύο', + 'roadtrip.limit.vehicleCombustion': 'Βενζίνη', + 'roadtrip.limit.vehicleElectric': 'Ηλεκτρικό', 'roadtrip.refuel.find': 'Εύρεση καυσίμων', + 'roadtrip.refuel.again': 'Ξανά', 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…', 'roadtrip.refuel.spare': 'μένουν {distance}', 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index e3445d086..0c07ed8c1 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -38,8 +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.dry': 'Itt fogy ki a tank', + 'roadtrip.refuel.after': '{distance} után', + 'roadtrip.limit.vehicleLabel': 'Mivel utazol', + 'roadtrip.limit.vehicleAny': 'Mindkettő', + 'roadtrip.limit.vehicleCombustion': 'Üzemanyag', + 'roadtrip.limit.vehicleElectric': 'Elektromos', 'roadtrip.refuel.find': 'Üzemanyag keresése', + 'roadtrip.refuel.again': 'Újra', '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', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index f6071b000..169b4a2f2 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -38,8 +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.dry': 'Tangki habis di sini', + 'roadtrip.refuel.after': 'setelah {distance}', + 'roadtrip.limit.vehicleLabel': 'Kamu mengemudi apa', + 'roadtrip.limit.vehicleAny': 'Keduanya', + 'roadtrip.limit.vehicleCombustion': 'Bensin', + 'roadtrip.limit.vehicleElectric': 'Listrik', 'roadtrip.refuel.find': 'Cari bahan bakar', + 'roadtrip.refuel.again': 'Coba lagi', 'roadtrip.refuel.looking': 'Mencari di sepanjang rute…', 'roadtrip.refuel.spare': 'sisa {distance}', 'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index ba741a196..bccf14109 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -38,8 +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.dry': 'Qui il serbatoio si esaurisce', + 'roadtrip.refuel.after': 'dopo {distance}', + 'roadtrip.limit.vehicleLabel': 'Cosa guidi', + 'roadtrip.limit.vehicleAny': 'Entrambi', + 'roadtrip.limit.vehicleCombustion': 'Benzina', + 'roadtrip.limit.vehicleElectric': 'Elettrico', 'roadtrip.refuel.find': 'Cerca carburante', + 'roadtrip.refuel.again': 'Riprova', 'roadtrip.refuel.looking': 'Ricerca lungo il percorso…', 'roadtrip.refuel.spare': 'restano {distance}', 'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 70808dd28..d592111b0 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} 件を回避', 'roadtrip.avoid.missed': '{classes} は回避できず', 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', - 'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)', + 'roadtrip.refuel.dry': 'ここで燃料切れ', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '何で走るか', + 'roadtrip.limit.vehicleAny': '両方', + 'roadtrip.limit.vehicleCombustion': 'ガソリン', + 'roadtrip.limit.vehicleElectric': '電気', 'roadtrip.refuel.find': '給油所を探す', + 'roadtrip.refuel.again': 'もう一度', 'roadtrip.refuel.looking': 'ルート沿いを検索中…', 'roadtrip.refuel.spare': '残り {distance}', 'roadtrip.refuel.add': '{name} を給油の停車地として追加', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 780ca16c7..c4cfe59d8 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count}개 회피', 'roadtrip.avoid.missed': '{classes} 회피 불가', 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', - 'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)', + 'roadtrip.refuel.dry': '여기서 연료 소진', + 'roadtrip.refuel.after': '{distance} 뒤', + 'roadtrip.limit.vehicleLabel': '무엇으로 이동하나요', + 'roadtrip.limit.vehicleAny': '둘 다', + 'roadtrip.limit.vehicleCombustion': '연료', + 'roadtrip.limit.vehicleElectric': '전기', 'roadtrip.refuel.find': '주유소 찾기', + 'roadtrip.refuel.again': '다시 시도', 'roadtrip.refuel.looking': '경로를 따라 찾는 중…', 'roadtrip.refuel.spare': '{distance} 여유', 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 007ab2e76..9fc025ba5 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -38,8 +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.dry': 'Hier is de tank leeg', + 'roadtrip.refuel.after': 'na {distance}', + 'roadtrip.limit.vehicleLabel': 'Wat je rijdt', + 'roadtrip.limit.vehicleAny': 'Beide', + 'roadtrip.limit.vehicleCombustion': 'Benzine', + 'roadtrip.limit.vehicleElectric': 'Elektrisch', 'roadtrip.refuel.find': 'Tankstation zoeken', + 'roadtrip.refuel.again': 'Opnieuw', 'roadtrip.refuel.looking': 'Zoeken langs de route…', 'roadtrip.refuel.spare': 'nog {distance}', 'roadtrip.refuel.add': '{name} als tankstop toevoegen', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 398b36ed2..f402ce6fd 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -38,8 +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.dry': 'Tu kończy się paliwo', + 'roadtrip.refuel.after': 'po {distance}', + 'roadtrip.limit.vehicleLabel': 'Czym jeździsz', + 'roadtrip.limit.vehicleAny': 'Oba', + 'roadtrip.limit.vehicleCombustion': 'Paliwo', + 'roadtrip.limit.vehicleElectric': 'Elektryk', 'roadtrip.refuel.find': 'Znajdź paliwo', + 'roadtrip.refuel.again': 'Ponów', 'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…', 'roadtrip.refuel.spare': 'zostaje {distance}', 'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index f57ca820d..12649b452 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обход', 'roadtrip.avoid.missed': '{classes} не обойти', 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', - 'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}', + 'roadtrip.refuel.dry': 'Здесь кончится топливо', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'На чём вы едете', + 'roadtrip.limit.vehicleAny': 'Оба', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Электро', 'roadtrip.refuel.find': 'Найти заправку', + 'roadtrip.refuel.again': 'Ещё раз', 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…', 'roadtrip.refuel.spare': 'останется {distance}', 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 8d48aa86e..4bc554425 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -38,8 +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.dry': 'Här tar tanken slut', + 'roadtrip.refuel.after': 'efter {distance}', + 'roadtrip.limit.vehicleLabel': 'Vad du kör', + 'roadtrip.limit.vehicleAny': 'Båda', + 'roadtrip.limit.vehicleCombustion': 'Bensin', + 'roadtrip.limit.vehicleElectric': 'El', 'roadtrip.refuel.find': 'Hitta bränsle', + 'roadtrip.refuel.again': 'Försök igen', 'roadtrip.refuel.looking': 'Söker längs rutten…', 'roadtrip.refuel.spare': '{distance} kvar', 'roadtrip.refuel.add': 'Lägg till {name} som tankstopp', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 235c83036..8508a5f58 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -38,8 +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.dry': 'Depo burada bitiyor', + 'roadtrip.refuel.after': '{distance} sonra', + 'roadtrip.limit.vehicleLabel': 'Ne kullanıyorsun', + 'roadtrip.limit.vehicleAny': 'İkisi de', + 'roadtrip.limit.vehicleCombustion': 'Benzin', + 'roadtrip.limit.vehicleElectric': 'Elektrik', 'roadtrip.refuel.find': 'Yakıt bul', + 'roadtrip.refuel.again': 'Yeniden dene', 'roadtrip.refuel.looking': 'Rota boyunca aranıyor…', 'roadtrip.refuel.spare': '{distance} kalır', 'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 9f4fa7552..6707817bf 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обхід', 'roadtrip.avoid.missed': '{classes} не обійти', 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', - 'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}', + 'roadtrip.refuel.dry': 'Тут закінчиться пальне', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'Чим ви їдете', + 'roadtrip.limit.vehicleAny': 'Обидва', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Електро', 'roadtrip.refuel.find': 'Знайти заправку', + 'roadtrip.refuel.again': 'Ще раз', 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…', 'roadtrip.refuel.spare': 'лишиться {distance}', 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 3bc408bce..1bec30741 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -38,8 +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.dry': 'Hết xăng ở đây', + 'roadtrip.refuel.after': 'sau {distance}', + 'roadtrip.limit.vehicleLabel': 'Bạn lái xe gì', + 'roadtrip.limit.vehicleAny': 'Cả hai', + 'roadtrip.limit.vehicleCombustion': 'Xăng', + 'roadtrip.limit.vehicleElectric': 'Điện', 'roadtrip.refuel.find': 'Tìm trạm xăng', + 'roadtrip.refuel.again': 'Thử lại', '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', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 60c93ce28..8900e3656 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避開 {count} 項', 'roadtrip.avoid.missed': '無法避開{classes}', 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', - 'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後', + 'roadtrip.refuel.dry': '此處將耗盡油量', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '你開什麼車', + 'roadtrip.limit.vehicleAny': '兩者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '電動', 'roadtrip.refuel.find': '尋找加油站', + 'roadtrip.refuel.again': '重試', 'roadtrip.refuel.looking': '正在沿路線尋找…', 'roadtrip.refuel.spare': '剩餘 {distance}', 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 81012d2c6..9697bafac 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避开 {count} 项', 'roadtrip.avoid.missed': '无法避开{classes}', 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', - 'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后', + 'roadtrip.refuel.dry': '此处将耗尽油量', + 'roadtrip.refuel.after': '{distance} 后', + 'roadtrip.limit.vehicleLabel': '你开什么车', + 'roadtrip.limit.vehicleAny': '两者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '电动', 'roadtrip.refuel.find': '查找加油站', + 'roadtrip.refuel.again': '重试', 'roadtrip.refuel.looking': '正在沿路线查找…', 'roadtrip.refuel.spare': '剩余 {distance}', 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点',