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} 添加为加油停靠点',