diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
index 8ab93f52a..a6b396379 100644
--- a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
- * FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
+ * FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning.
*
* The one that matters is when a number is written. These fields had no local
* state, so every keystroke was a settings PUT: typing "180" sent three, and
@@ -18,7 +18,7 @@ function open(onSave?: (key: string, value: number) => void) {
fireEvent.click(screen.getByRole('button'))
}
-/** The three inputs, in the order the dialog lists them. */
+/** Every number field, in the order the dialog lists them. */
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
beforeEach(() => {
@@ -127,4 +127,75 @@ describe('RoadtripLimitsCard', () => {
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
})
+
+ it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
+ useSettingsStore.setState({
+ settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never,
+ })
+ open(vi.fn())
+
+ // Leg, day, battery, consumption, wear, range, fill. The three in the middle
+ // do not exist until there is a kind of car to have them.
+ expect(inputs()).toHaveLength(7)
+ expect(screen.getByText('kWh')).toBeInTheDocument()
+ expect(screen.getByText('kWh/100 km')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => {
+ useSettingsStore.setState({
+ settings: {
+ roadtrip_vehicle: 'electric',
+ roadtrip_battery_kwh: 58,
+ roadtrip_kwh_per_100: 16,
+ // Deliberately different from what the figures work out to: the point of
+ // the row going read-only is that there is no longer a second answer.
+ roadtrip_range_km: 500,
+ distance_unit: 'metric',
+ } as never,
+ })
+ open(vi.fn())
+
+ expect(screen.getByTestId('limit-derived')).toHaveTextContent('363')
+ // Leg, day, battery, consumption, wear, fill — the range input is gone.
+ expect(inputs()).toHaveLength(6)
+ })
+
+ it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => {
+ useSettingsStore.setState({
+ settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never,
+ })
+ const onSave = vi.fn()
+ open(onSave)
+
+ // Leg, day, tank, consumption, range, fill.
+ const tank = inputs()[2]
+ fireEvent.change(tank, { target: { value: '15' } })
+ fireEvent.blur(tank)
+
+ // 15 US gallons, not 15 litres. Storing the number as typed would have made
+ // every fuel warning fire at a quarter of the right distance.
+ expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78)
+ })
+ it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => {
+ useSettingsStore.setState({
+ settings: {
+ roadtrip_vehicle: 'electric',
+ roadtrip_battery_kwh: 58,
+ roadtrip_kwh_per_100: 16,
+ distance_unit: 'metric',
+ } as never,
+ })
+ const onSave = vi.fn()
+ open(onSave)
+
+ // Leg, day, battery, consumption, wear, fill — the range row is read-only here.
+ const consumption = inputs()[3]
+ fireEvent.change(consumption, { target: { value: '20' } })
+
+ // 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge
+ // is the argument that these figures ARE the range, and it only makes it if it
+ // answers now rather than after the field is left.
+ expect(screen.getAllByText('290').length).toBeGreaterThan(0)
+ expect(onSave).not.toHaveBeenCalled()
+ })
})
diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
index 82baaf3b4..a6215d717 100644
--- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
@@ -1,31 +1,38 @@
import React, { useState } from 'react'
-import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react'
+import {
+ Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
+ Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles,
+} 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'
-import { formatDurationShort, parseAvoid, serializeAvoid, AVOIDABLE } from './roadtripModel'
+import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
+import { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel'
+import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange'
import { valhallaAvailable } from '../Map/valhallaRoute'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { DistanceUnit, RouteAvoidClass } from '../../types'
import { FS } from './typeScale'
/**
- * The three numbers that decide when the rail speaks up about the driving.
+ * Everything that decides when the rail speaks up about the driving.
*
* Behind a button rather than laid out in the column, because this is set once and then
- * read never: a permanent card costs three rows of a narrow sidebar for something most
+ * read never: a permanent card costs rows of a narrow sidebar for something most
* travellers touch on the first day of planning and leave alone after. The button carries
- * the current answer, so what is set stays visible without the form being.
+ * the current answers, so what is set stays visible without the form being.
*
- * All three are personal rather than instance configuration: how long somebody is willing
- * to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
- * They are plain per-user settings, which is why this needs no migration.
+ * All of it is personal rather than instance configuration: how long somebody is willing
+ * to sit behind a wheel, and what their car is, belong to the traveller. They are plain
+ * per-user settings, which is why this needs no migration.
*
- * Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
- * idea what car this is, and neither has TREK, so the range is a number the traveller
- * types. Furkot does exactly the same, for the same reason.
+ * The dialog has two halves because the settings are two kinds. The left is the trip: how
+ * long a day may be and what the road may not be. The right is the car, and it is the
+ * half that earns the width — a range can be typed as one number or worked out from what
+ * the vehicle is made of, and the gauge shows the second turning into the first as it is
+ * typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is,
+ * and neither has TREK.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
@@ -34,12 +41,41 @@ function parseLimit(raw: string): number {
return Number.isFinite(n) && n > 0 ? n : 0
}
-function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
+/**
+ * One number in the form, or one number the form worked out.
+ *
+ * `derived` turns the row read-only and states where the figure came from. The row keeps
+ * its shape either way, because a range that switched between an input and a line of text
+ * would move every row under it the moment a consumption was typed.
+ */
+function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, wide, onDraft, onChange }: {
icon: typeof Clock
label: string
suffix: string
value: number | undefined
placeholder: string
+ step?: number
+ derived?: string
+ /**
+ * Room for a unit longer than a symbol, set per section rather than per row.
+ *
+ * "kWh/100 km" and "L/100 km" are units people actually quote, and a column sized for
+ * "min" wrapped them onto a second line, which made one row of the vehicle section
+ * taller than the rest. Sizing it from the string would have done the same thing
+ * sideways: only the consumption row would widen, and the section's right edge would
+ * come out ragged.
+ */
+ wide?: boolean
+ /**
+ * What is in the field right now, on every keystroke, saved or not.
+ *
+ * Separate from `onChange` because the two answer different questions. `onChange` is
+ * "this is the new setting", which must stay rare — it is an HTTP write. This one is
+ * "this is what the traveller is looking at", which the gauge needs on every keystroke
+ * or it would sit still until the field is left and the promise it makes, that these
+ * figures are the range, would only pay out after the fact.
+ */
+ onDraft?: (next: number) => void
onChange: (next: number) => void
}): React.ReactElement {
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
@@ -60,21 +96,37 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
{label}
- setDraft(e.target.value)}
- onBlur={commit}
- // Committed here rather than by blurring and letting onBlur do it: a
- // second commit is a no-op anyway, and going through blur made Enter
- // depend on focus handling instead of on the key.
- onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
- className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
- />
- {suffix}
+ {derived ? (
+ // A mark rather than a word: "computed" is eight characters in English and
+ // fourteen in half the locales, and the column is as wide as the input it
+ // replaces. The sentence lives in the title and in the screen-reader text.
+
+
+ {value ?? 0}
+ {derived}
+
+ ) : (
+ { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
+ onBlur={commit}
+ // Committed here rather than by blurring and letting onBlur do it: a
+ // second commit is a no-op anyway, and going through blur made Enter
+ // depend on focus handling instead of on the key.
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
+ className={`${wide ? 'w-16' : 'w-20'} rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none`}
+ />
+ )}
+ {suffix}
)
}
@@ -104,13 +156,160 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
)
}
+/** The name of one half of the form. */
+function SectionTitle({ icon: Icon, children }: {
+ icon: typeof Clock
+ children: React.ReactNode
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ )
+}
+
+/**
+ * The range, drawn as the thing it describes.
+ *
+ * The bar is the tank the day it left the factory, left to right, and it carries the two
+ * figures a bare number cannot say on its own:
+ *
+ * - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the
+ * road, and a traveller who set 80 % is looking at four fifths of a bar, which is the
+ * honest picture of what the next leg has.
+ * - The hatched tail is what age has taken off an electric car's battery. It is already
+ * out of the number above, so without the tail that figure looks like an unexplained
+ * disagreement with the car's own data sheet.
+ *
+ * Widths animate because this sits directly under the fields that feed it: typing a
+ * consumption and watching the bar move is what makes the two ways of stating a range
+ * visibly the same thing.
+ */
+function RangeGauge({ rangeKm, fillPercent, wearPercent, unit, electric, t }: {
+ rangeKm: number | null
+ fillPercent: number | null
+ wearPercent: number | null
+ unit: DistanceUnit
+ electric: boolean
+ t: (key: string, params?: Record) => string
+}): React.ReactElement {
+ // Capped where the arithmetic caps it, so the picture cannot claim a battery is gone.
+ const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0
+ const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
+ const usable = 100 - wear
+ const filled = rangeKm ? (usable * fill) / 100 : 0
+ const rest = 100 - wear - filled
+ // Split rather than formatted whole, so the unit can sit smaller beside the figure.
+ // The rounding is formatDistance's own, because the badge on the trigger uses that and
+ // the two must not disagree by a tenth.
+ const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
+ const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)'
+
+ return (
+
+
+
+ {electric ? : }
+
+
+
+
+ {shown ?? '—'}
+
+ {shown !== null ? (
+
+ {getDistanceUnitLabel(unit)}
+
+ ) : null}
+
+
+ {shown === null
+ ? t('roadtrip.limit.rangeEmpty')
+ : t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
+
+
+
+
+ {/* The quarter marks are painted over the whole bar rather than drawn into each
+ segment, so they stay put while the segments under them move. They are the
+ card's own colour, which makes them read as cut-outs: visible across the filled
+ part, invisible over the empty track, where there is nothing to divide. */}
+
+
+
+ {shown === null ? (
+ {t('roadtrip.limit.rangeEmptyHint')}
+ ) : (
+ <>
+
+ {fill < 100
+ ? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
+ : t('roadtrip.limit.fullNote')}
+
+ {/* The swatch is what ties the sentence to the tail of the bar. Without it the
+ hatching is an unexplained stripe and the sentence an unexplained loss. */}
+ {wear > 0 ? (
+
+
+ {t('roadtrip.limit.wearNote', { percent: wear })}
+
+ ) : null}
+ >
+ )}
+
+
+ )
+}
+
/** 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 }[] = [
+const VEHICLES: { key: VehicleKind | 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 },
]
+/**
+ * What each kind of car is made of, and where each figure is kept.
+ *
+ * Two rows for petrol and three for electric, because that is the difference that
+ * matters: a tank does not lose capacity to the years and a battery does. Everything else
+ * ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a
+ * consumption PREDICTION. This dialog does not predict one; it divides a capacity by a
+ * consumption the traveller states, so those fields would add typing without moving a
+ * single kilometre of the answer.
+ */
+const SPEC_ROWS: Record = {
+ combustion: [
+ { key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 },
+ { key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
+ ],
+ electric: [
+ { key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 },
+ { key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
+ { key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 },
+ ],
+}
+
export default function RoadtripLimitsCard({ onSave }: {
/**
* Persists one setting. Absent leaves the dialog read-only.
@@ -124,6 +323,7 @@ export default function RoadtripLimitsCard({ onSave }: {
const settings = useSettingsStore(s => s.settings)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
+ const imperial = distanceUnit === 'imperial'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
@@ -132,6 +332,42 @@ 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 vehicleKind: VehicleKind | null =
+ vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
+ const saved: VehicleSpec = {
+ tankLitres: settings.roadtrip_tank_litres,
+ litresPer100: settings.roadtrip_litres_per_100,
+ batteryKwh: settings.roadtrip_battery_kwh,
+ kwhPer100: settings.roadtrip_kwh_per_100,
+ degradationPercent: settings.roadtrip_battery_degradation,
+ }
+ /**
+ * The one field being typed in, ahead of the write that will save it.
+ *
+ * One slot rather than a copy of the form, because only one field can be typed in at a
+ * time, and a second copy of the settings is exactly the hand-mirrored state this
+ * codebase refuses everywhere else. It exists so the gauge answers while the traveller
+ * is still typing: the figures below it are only convincingly "the range" if changing
+ * one moves the bar, and a settings PUT per keystroke is not on offer.
+ *
+ * The value arrives in whatever unit the field is labelled with, so it goes back
+ * through the same conversion the save would have used.
+ */
+ const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null)
+ const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill'
+ ? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) }
+ : saved
+ const typedKm = preview?.key === 'range'
+ ? (imperial ? preview.value / 0.621371 : preview.value)
+ : rangeKm
+ const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null
+
+ // What the parts say, and what the trip will actually plan with. The second is the
+ // first when there is one, which is the rule effectiveRangeKm states once for
+ // everybody who needs it.
+ const computedKm = rangeFromSpec(vehicleKind, spec)
+ const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm)
+
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.
@@ -149,22 +385,30 @@ export default function RoadtripLimitsCard({ onSave }: {
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
- // third too early for ever after.
- const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
+ // third too early for ever after. The same rule runs through the vehicle's own figures,
+ // where showSpec and storeSpec do it per field.
+ const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
- const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
+ const km = imperial ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to read it.
- // Each one keeps its own icon, because three numbers in a row say nothing about which
- // is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
- // an imperial traveller reads miles here and types miles in the dialog, while what is
+ // Each one keeps its own icon, because numbers in a row say nothing about which is
+ // which: a clock, a calendar and a pump do. Distances go through formatDistance, so an
+ // imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
- rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
+ // The planning range, not the typed one: when the vehicle's figures win, the badge
+ // has to say what the warnings will actually use.
+ planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null,
+ // Only worth a badge when it is NOT a full tank, which is the case that changes the
+ // arithmetic. "100 %" on the trigger would be a badge for the default.
+ settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100
+ ? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` }
+ : null,
// The avoidance rides here too, and as ONE badge rather than three: it is a single
// decision, the three icons already say which parts of it are on, and a fourth,
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
@@ -184,7 +428,7 @@ export default function RoadtripLimitsCard({ onSave }: {
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
- {/* No icon tile on the trigger: the three badges below already carry a clock, a
+ {/* No icon tile on the trigger: the badges below already carry a clock, a
calendar and a pump, and a fourth mark in front of the title only ate the width
they need to sit on one line. */}
@@ -220,72 +464,45 @@ export default function RoadtripLimitsCard({ onSave }: {
{open ? (
- setOpen(false)} size="sm" title={
+ { setOpen(false); setPreview(null) }} size="2xl" title={
{t('roadtrip.limit.title')}
}>
-
-
onSave?.('roadtrip_leg_minutes', 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. */}
-
-
- {t('roadtrip.limit.vehicleLabel')}
-
- onSave?.('roadtrip_vehicle', String(next))}
- size="sm"
- options={VEHICLES.map(({ key, labelKey, Icon }) => ({
- value: key ?? '',
- label: t(labelKey),
- icon: ,
- }))}
+ {/* Two columns from the small breakpoint up. One narrow column stopped reading
+ as a form and started reading as a wall, and the halves are different in
+ kind anyway: the left is the trip, the right is the car. */}
+
+
+
+ {t('roadtrip.limit.sectionDriving')}
+ onSave?.('roadtrip_leg_minutes', v)}
/>
-
-
-
- {/* Said out loud rather than left to be discovered: the day figure this is
- measured against is driving time only. It does not include how long anyone
- stands still, and it does not include the leg to the hotel, because the
- rail does not model one. A limit called "travel time per day" over that
- number would be a lie that looks tidy. */}
- {t('roadtrip.limit.hint')}
+ onSave?.('roadtrip_day_minutes', v)}
+ />
+ {/* Said out loud rather than left to be discovered: the day figure this is
+ measured against is driving time only. It does not include how long
+ anyone stands still, and it does not include the leg to the hotel,
+ because the rail does not model one. A limit called "travel time per
+ day" over that number would be a lie that looks tidy. */}
+ {t('roadtrip.limit.hint')}
+
- {/* A heading and a rule, because six rows in one list stop reading as a form
- and start reading as a wall. The three above are numbers that warn; the
- three below change the road itself, which is a different kind of setting
- and worth the separation. */}
-
-
- {t('roadtrip.avoid.section')}
-
-
+
+ {t('roadtrip.avoid.section')}
{AVOID_ROWS.map(({ cls, icon, label }) => (
toggleAvoid(cls)}
/>
))}
-
- {/* "Where possible" is doing real work in the label, so it is spelled out
- here: the router weights a class down rather than banning it, and on a
- drive with no way round one the road still uses it. The day says so
- itself when that happens, which is the only honest way to offer this. */}
-
- {canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
-
+ {/* "Where possible" is doing real work in the label, so it is spelled out
+ here: the router weights a class down rather than banning it, and on a
+ drive with no way round one the road still uses it. The day says so
+ itself when that happens, which is the only honest way to offer this. */}
+
+ {canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
+
+
+
+
+ {t('roadtrip.limit.sectionVehicle')}
+ {/* First, because it decides what everything under it 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. */}
+
+
+ {t('roadtrip.limit.vehicleLabel')}
+
+ onSave?.('roadtrip_vehicle', String(next))}
+ size="sm"
+ options={VEHICLES.map(({ key, labelKey, Icon }) => ({
+ value: key ?? '',
+ label: t(labelKey),
+ icon: ,
+ }))}
+ />
+
+
+
+
+
+ {/* The parts, when there is a kind of car to have parts of. Nothing here is
+ required: a traveller who knows their range types it below and never
+ looks at these. */}
+ {vehicleKind ? SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
+ setPreview({ key, value: v })}
+ onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
+ />
+ )) : null}
+
+ {/* Read-only once the parts add up, because then it IS the parts. Leaving it
+ editable would offer a second answer to a question that already has
+ one, and the traveller would have no way of telling which the warnings
+ were using. Clearing a field above hands the row straight back. */}
+ setPreview({ key: 'range', value: v })}
+ onChange={setRange}
+ />
+ {/* Under the range, because it is a fraction OF it. Nobody charges to 100 %
+ on the road: the last fifth takes as long as the first four, so a stop
+ counted as a full tank overstates everything after it by that fifth. */}
+ setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
+ onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
+ />
+
+ {vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
+
+
) : null}
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index f3fa5f064..c60c57a19 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle,
- ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X,
+ ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X, BatteryCharging,
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
@@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters'
-import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
+import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange, type VehicleKind} from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
@@ -165,6 +165,35 @@ function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
)
}
+/**
+ * What a fill-up here actually puts in, beside the stay it takes.
+ *
+ * Only on a stop that refuels, and only when it is not a full tank: "100 %" would be a
+ * badge for the default. It reads as the second half of the stay badge because that is
+ * what it is — how long you stand here, and what you get for it.
+ */
+function FillBadge({ percent }: { percent: number }): React.ReactElement {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+ {`${percent} %`}
+
+
+
+ )
+}
+
function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null {
const { t } = useTranslation()
const text = minutes ? formatDurationShort(minutes * 60) : null
@@ -538,6 +567,12 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
onEditStay?: () => void
}): React.ReactElement {
const { t } = useTranslation()
+ // Read here rather than threaded down: both stop shapes need the same two, and the
+ // badge is the only thing in the rail that depends on them.
+ const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
+ const vehicleSetting = useSettingsStore(st => st.settings.roadtrip_vehicle)
+ const vehicle: VehicleKind | null =
+ vehicleSetting === 'combustion' || vehicleSetting === 'electric' ? vehicleSetting : null
const kind = STOP_KIND_BY_KEY[stop.stopType ?? '']
const Icon = kind?.Icon ?? ParkingSquare
const label = t(kind?.labelKey ?? 'roadtrip.poi.rest')
@@ -604,6 +639,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
+ {fillPercent && refuelsRange(stop.stopType, vehicle) ? : null}
{spurWorthLabelling(stop.offRoadMeters) ? : null}
{(driveFindings ?? []).map(w => )}
@@ -821,6 +857,12 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
onEditStay?: () => void
}): React.ReactElement {
const { t } = useTranslation()
+ // Read here rather than threaded down: both stop shapes need the same two, and the
+ // badge is the only thing in the rail that depends on them.
+ const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
+ const vehicleSetting = useSettingsStore(st => st.settings.roadtrip_vehicle)
+ const vehicle: VehicleKind | null =
+ vehicleSetting === 'combustion' || vehicleSetting === 'electric' ? vehicleSetting : null
return (
+ {fillPercent && refuelsRange(stop.stopType, vehicle) ? : null}
{spurWorthLabelling(stop.offRoadMeters) ? : null}
{(driveFindings ?? []).map(w => )}
diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts
index f42cf94f2..3cebde8ea 100644
--- a/client/src/components/Roadtrip/roadtripModel.test.ts
+++ b/client/src/components/Roadtrip/roadtripModel.test.ts
@@ -726,3 +726,46 @@ describe('refuelsRange — what fills which tank', () => {
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
})
})
+
+describe('deriveDriveWarnings — filling only part way', () => {
+ const drive = (minutes: number, km: number) =>
+ ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
+
+ it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => {
+ // Nobody charges to 100 % on the road: the last fifth takes as long as the first
+ // four. Counting a stop as a full tank overstates what comes after it by that fifth.
+ const out = deriveDriveWarnings(
+ [drive(60, 100), drive(60, 450)],
+ [false, true, false],
+ { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
+ 0,
+ )
+ // After the stop the budget restarts at 100 km rather than 0, so the tank is dry
+ // 400 km into the second leg. Filled all the way it would have gone the whole 450.
+ expect(out.emptyAt).toHaveLength(1)
+ expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
+ })
+
+ it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => {
+ const legs = [drive(60, 100), drive(60, 450)]
+ const refuels = [false, true, false]
+ const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }
+ for (const fillPercent of [undefined, null, 0, 100]) {
+ const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0)
+ // A full tank after the stop covers the remaining 450 km without a finding.
+ expect(out.emptyAt).toEqual([])
+ expect(out.warnings).toEqual([])
+ }
+ })
+
+ it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => {
+ const out = deriveDriveWarnings(
+ [drive(60, 900)],
+ [true, false],
+ { legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 },
+ 0,
+ )
+ expect(out.warnings).toEqual([])
+ expect(out.emptyAt).toEqual([])
+ })
+})
diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts
index 91fed9b37..fd8ccfc78 100644
--- a/client/src/components/Roadtrip/roadtripModel.ts
+++ b/client/src/components/Roadtrip/roadtripModel.ts
@@ -156,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined, vehicle?: Vehi
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
*/
export interface DriveLimits {
+ /**
+ * How full a fill-up actually goes, 1 to 100, or null for "all the way".
+ *
+ * Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
+ * the first four and the manual says not to. Treating every stop as a full tank
+ * overstates the range after it by exactly that fifth, which is a whole leg on a long
+ * day.
+ */
+ fillPercent?: number | null
/** Longest single drive between two stops, in minutes. */
legMinutes: number | null
/** Longest total driving in one day, in minutes. */
@@ -434,8 +443,15 @@ export function deriveDriveWarnings(
// walk the driving legs only, which is why this is counted here rather than derived.
let drivenMeters = 0
+ // What is left on the clock straight after a stop. A tank filled to 80 % has already
+ // used a fifth of its range before the car moves, which is the honest way to say it in
+ // a budget that counts upwards.
+ const usedAfterFilling = limits.rangeKm && limits.fillPercent
+ ? limits.rangeKm * (1 - limits.fillPercent / 100)
+ : 0
+
for (let i = 0; i < legs.length; i++) {
- if (refuelsAt[i]) budget = 0
+ if (refuelsAt[i]) budget = usedAfterFilling
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
@@ -486,7 +502,7 @@ export function deriveDriveWarnings(
}
// The last stop of the day counts too: filling up on arrival is what makes the next
// morning start with a full tank.
- if (refuelsAt[legs.length]) budget = 0
+ if (refuelsAt[legs.length]) budget = usedAfterFilling
const minutes = Math.round(totalSeconds / 60)
const day = limits.dayMinutes && minutes > limits.dayMinutes
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 99042655d..69c1e554e 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -6,6 +6,7 @@ import {
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 { effectiveRangeKm } from './vehicleRange'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { spurFor } from './accessSpur'
import type { RoadtripVia } from '@trek/shared'
@@ -275,6 +276,7 @@ export function useRoadtripRoutes(
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
+ const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
/**
* Road classes to weight away, as the settings row stores them: a comma list.
*
@@ -290,12 +292,43 @@ export function useRoadtripRoutes(
// server-side check, and an unknown word here would silently stop both kinds counting.
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
+ /**
+ * What the vehicle is made of, when the traveller filled that in instead of a range.
+ *
+ * Read as five primitives rather than as one object so the memo below compares numbers.
+ * An object rebuilt each render would give `limits` a new identity every time, and
+ * `limits` is what the route effect watches — every keystroke anywhere in the app would
+ * recalculate every day's driving.
+ */
+ const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
+ const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
+ const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
+ const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
+ const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
+ // The parts win over the typed number when they add up, because they are the more
+ // specific answer; the dialog shows the result, so it is never a surprise. Zero and
+ // absent both mean "no limit", which is why the falsy fold happens here once.
+ const planningRangeKm = useMemo(
+ () => effectiveRangeKm(
+ vehicleKind,
+ { tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent },
+ rangeKm,
+ ) || null,
+ [vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm],
+ )
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.
const limits = useMemo(
- () => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
- [legMinutes, dayMinutes, rangeKm],
+ () => ({
+ legMinutes: legMinutes || null,
+ dayMinutes: dayMinutes || null,
+ rangeKm: planningRangeKm,
+ // Only a real fraction counts. Zero, absent and 100 all mean "fills right up",
+ // which is what the budget did before the setting existed.
+ fillPercent: fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : null,
+ }),
+ [legMinutes, dayMinutes, planningRangeKm, fillPercent],
)
const plan = useMemo(() => {
diff --git a/client/src/components/Roadtrip/vehicleRange.test.ts b/client/src/components/Roadtrip/vehicleRange.test.ts
new file mode 100644
index 000000000..9e5cf4f01
--- /dev/null
+++ b/client/src/components/Roadtrip/vehicleRange.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest'
+import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange'
+
+/**
+ * FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of.
+ *
+ * Two things are worth guarding here. The arithmetic itself, because it decides where
+ * every fuel warning lands, and the unit round trip, because an imperial traveller who
+ * types 15 into a field labelled gallons must not end up with 15 litres in storage.
+ */
+
+describe('rangeFromSpec', () => {
+ it('FE-ROADTRIP-VEHRANGE-001: a tank divided by a consumption is a range', () => {
+ expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-002: a battery divided by a consumption is a range', () => {
+ expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-003: age comes off the battery before the division', () => {
+ // 58 kWh minus a tenth is 52.2, and 52.2 at 16 per hundred is 326.25 km.
+ expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-004: half a pair is not a range', () => {
+ // Guessing the missing half would put the warnings at the wrong place while
+ // looking exact, which is worse than saying nothing.
+ expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull()
+ expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull()
+ expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull()
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-005: figures belong to the kind of car they came from', () => {
+ // A tank size on an electric car is a leftover from before the picker changed,
+ // not a battery.
+ expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull()
+ expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull()
+ expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull()
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-006: a battery reported as entirely gone is a typo, not a car', () => {
+ // Capped at 90 %, because zero usable capacity would put the dry point at the
+ // first metre of the trip and divide by nothing.
+ expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-007: nonsense in a field is the same as an empty field', () => {
+ expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull()
+ expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull()
+ expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull()
+ })
+})
+
+describe('effectiveRangeKm', () => {
+ it('FE-ROADTRIP-VEHRANGE-008: the parts win over a typed round number', () => {
+ // Somebody who gave a tank size and a consumption said something more exact
+ // than "about 500", and the dialog shows them the result.
+ expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-009: an incomplete pair hands the typed number straight back', () => {
+ expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500)
+ expect(effectiveRangeKm(null, {}, 500)).toBe(500)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-010: no figures anywhere is no limit', () => {
+ expect(effectiveRangeKm(null, {}, 0)).toBeNull()
+ expect(effectiveRangeKm('electric', {}, undefined)).toBeNull()
+ })
+})
+
+describe('unit round trip', () => {
+ it('FE-ROADTRIP-VEHRANGE-011: metric figures are stored exactly as typed', () => {
+ expect(storeSpec('tankLitres', 55, false)).toBe(55)
+ expect(showSpec('tankLitres', 55, false)).toBe(55)
+ expect(showSpec('litresPer100', 7.4, false)).toBe(7.4)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-012: gallons go in and come back out as the same gallons', () => {
+ // 15 US gallons is 56.78 litres, and storage is litres — the same rule the
+ // range itself follows, where storage is always kilometres.
+ const stored = storeSpec('tankLitres', 15, true)
+ expect(stored).toBe(56.78)
+ expect(showSpec('tankLitres', stored, true)).toBe(15)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-013: miles per gallon is the inverse, and survives the trip', () => {
+ // The one field that is not a scale factor: 30 mpg is 7.84 L/100 km, and a
+ // straight multiplication would have stored a car four times as thirsty.
+ const stored = storeSpec('litresPer100', 30, true)
+ expect(stored).toBe(7.84)
+ expect(showSpec('litresPer100', stored, true)).toBe(30)
+ expect(specUnit('litresPer100', true)).toBe('mpg')
+ expect(specUnit('litresPer100', false)).toBe('L/100 km')
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-014: electric consumption is quoted per 100 miles', () => {
+ // What the EPA prints on the window sticker, so the figure is one a US
+ // traveller can copy rather than convert.
+ expect(showSpec('kwhPer100', 16, true)).toBe(25.7)
+ expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi')
+ // A capacity is a capacity everywhere.
+ expect(specUnit('batteryKwh', true)).toBe('kWh')
+ expect(showSpec('batteryKwh', 58, true)).toBe(58)
+ })
+
+ it('FE-ROADTRIP-VEHRANGE-015: an empty field stays empty rather than becoming a zero', () => {
+ expect(showSpec('tankLitres', undefined, false)).toBeUndefined()
+ expect(showSpec('tankLitres', 0, true)).toBeUndefined()
+ expect(storeSpec('tankLitres', 0, true)).toBe(0)
+ })
+})
diff --git a/client/src/components/Roadtrip/vehicleRange.ts b/client/src/components/Roadtrip/vehicleRange.ts
new file mode 100644
index 000000000..44532e47c
--- /dev/null
+++ b/client/src/components/Roadtrip/vehicleRange.ts
@@ -0,0 +1,157 @@
+import type { VehicleKind } from './roadtripModel'
+
+/**
+ * How far the vehicle goes on one fill, worked out from what it is made of.
+ *
+ * Two ways to the same number, and the point of having both is that people know their
+ * car in different ways. Some know "about 600 km"; some know the tank holds 55 litres
+ * and it does 7 per hundred, and would have to do the division themselves. Whoever types
+ * a range directly still gets exactly what they typed — this only fills that number in
+ * when it can and the field is empty.
+ *
+ * Deliberately not a consumption model. ABRP asks for reference speed, relative speed and
+ * a drive profile because it predicts a specific drive; this answers "how far on one
+ * fill", which is a division. Adding speed terms would make the number look precise
+ * without making it right, since the biggest factor by far is how the person drives.
+ *
+ * Pure: no React, no store, no network.
+ */
+
+export interface VehicleSpec {
+ /** Litres the tank holds. Petrol only. */
+ tankLitres?: number | null
+ /** Litres per 100 km. Petrol only. */
+ litresPer100?: number | null
+ /** Usable battery when new, in kWh. Electric only. */
+ batteryKwh?: number | null
+ /** kWh per 100 km. Electric only. */
+ kwhPer100?: number | null
+ /**
+ * How much of the battery the years have taken, as a percentage.
+ *
+ * A five-year-old car does not carry the capacity on its data sheet, and the gap is
+ * the difference between arriving and not on the last leg of a day.
+ */
+ degradationPercent?: number | null
+}
+
+/** A positive, finite number, or null. Everything here comes from a text field. */
+function positive(value: number | null | undefined): number | null {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
+}
+
+/**
+ * The range in kilometres that the parts add up to, or null when they do not add up.
+ *
+ * Null rather than a guess whenever a piece is missing: half a specification is not a
+ * range, and a made-up figure here becomes a fuel warning that fires at the wrong place.
+ */
+export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: VehicleSpec): number | null {
+ if (vehicle === 'combustion') {
+ const tank = positive(spec.tankLitres)
+ const per100 = positive(spec.litresPer100)
+ if (!tank || !per100) return null
+ return Math.round((tank / per100) * 100)
+ }
+
+ if (vehicle === 'electric') {
+ const battery = positive(spec.batteryKwh)
+ const per100 = positive(spec.kwhPer100)
+ if (!battery || !per100) return null
+ // Degradation only ever takes away, and never all of it: a battery reported as 100 %
+ // gone is a typo, not a car, and dividing by zero range would put the dry point at
+ // the first metre of the trip.
+ const lost = Math.min(90, Math.max(0, positive(spec.degradationPercent) ?? 0))
+ const usable = battery * (1 - lost / 100)
+ return Math.round((usable / per100) * 100)
+ }
+
+ return null
+}
+
+/**
+ * The range to plan with: what the parts say, or what was typed, in that order.
+ *
+ * The computed one wins because it is the more specific answer — somebody who filled in
+ * a tank size and a consumption said something more exact than a round number, and the
+ * dialog shows them the result so it is never a surprise.
+ */
+export function effectiveRangeKm(
+ vehicle: VehicleKind | null | undefined,
+ spec: VehicleSpec,
+ typedKm: number | null | undefined,
+): number | null {
+ return rangeFromSpec(vehicle, spec) ?? positive(typedKm)
+}
+
+/**
+ * The unit each field is typed in, per measurement system.
+ *
+ * Storage is always metric, exactly as the range itself is always kilometres: an imperial
+ * traveller who typed 15 into a field labelled "gallons" must not have 15 litres stored,
+ * or every warning after it fires at a quarter of the right distance.
+ *
+ * The imperial pair is not a straight translation of the metric one. Fuel economy is
+ * quoted as miles per gallon, which is the INVERSE of litres per hundred, while electric
+ * economy is quoted by the EPA as kWh per 100 miles, which is not. Forcing one shape onto
+ * both would leave one of the two reading as a figure nobody recognises.
+ */
+export type SpecKey = keyof VehicleSpec
+
+const LITRES_PER_GALLON = 3.785411784
+/** litres/100 km × mpg = this. The one constant that makes the inversion exact. */
+const MPG_PRODUCT = 235.214583
+const KM_PER_MILE = 1.609344
+
+export function specUnit(key: SpecKey, imperial: boolean): string {
+ switch (key) {
+ case 'tankLitres': return imperial ? 'gal' : 'L'
+ case 'litresPer100': return imperial ? 'mpg' : 'L/100 km'
+ case 'batteryKwh': return 'kWh'
+ case 'kwhPer100': return imperial ? 'kWh/100 mi' : 'kWh/100 km'
+ default: return '%'
+ }
+}
+
+/** How many decimals the field is worth typing. A tank is not measured to a tenth. */
+function places(key: SpecKey, imperial: boolean): number {
+ if (key === 'degradationPercent') return 0
+ if (key === 'tankLitres') return imperial ? 1 : 0
+ if (key === 'litresPer100') return imperial ? 0 : 1
+ return 1
+}
+
+function convert(key: SpecKey, value: number, imperial: boolean, toStorage: boolean): number {
+ if (!imperial) return value
+ switch (key) {
+ case 'tankLitres': return toStorage ? value * LITRES_PER_GALLON : value / LITRES_PER_GALLON
+ // Inverse in both directions, which is why one expression covers the pair.
+ case 'litresPer100': return MPG_PRODUCT / value
+ case 'kwhPer100': return toStorage ? value / KM_PER_MILE : value * KM_PER_MILE
+ default: return value
+ }
+}
+
+function round(value: number, decimals: number): number {
+ const f = 10 ** decimals
+ return Math.round(value * f) / f
+}
+
+/** The stored figure as it should appear in the field, or undefined when there is none. */
+export function showSpec(key: SpecKey, stored: number | null | undefined, imperial: boolean): number | undefined {
+ const value = positive(stored)
+ if (value === null) return undefined
+ return round(convert(key, value, imperial, false), places(key, imperial))
+}
+
+/**
+ * A typed figure as it should be stored. Zero comes back as zero, meaning "not said".
+ *
+ * Two decimals rather than none: a gallon is 3.79 litres, so an imperial figure that
+ * round-tripped through a whole number would drift by up to half a litre and show the
+ * traveller a different number than the one they typed.
+ */
+export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number {
+ if (!(shown > 0)) return 0
+ return round(convert(key, shown, imperial, true), 2)
+}
diff --git a/client/src/types.ts b/client/src/types.ts
index d5b014097..a47603386 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -167,6 +167,39 @@ export interface Settings {
* opened the dialog.
*/
roadtrip_vehicle?: string
+ /**
+ * How full a fill-up goes, 1 to 100. Absent or 100 means all the way.
+ *
+ * Nobody charges to 100 % on the road, so an electric traveller who leaves this at full
+ * gets a range figure a fifth too generous after every stop.
+ */
+ roadtrip_fill_percent?: number
+ /**
+ * What the vehicle is made of, for travellers who would rather not do the division.
+ *
+ * Optional throughout, and stored METRIC throughout — litres and kilowatt-hours, per
+ * 100 kilometres — exactly as roadtrip_range_km is always kilometres. The dialog does
+ * the round trip into gallons, miles per gallon and kWh per 100 miles for an imperial
+ * traveller; storage stays one system so the arithmetic never has to ask.
+ *
+ * When a pair is complete it WINS over roadtrip_range_km, because it is the more
+ * specific answer. Half a pair computes nothing: a made-up range here would put the
+ * fuel warnings at the wrong place while looking exact.
+ */
+ roadtrip_tank_litres?: number
+ roadtrip_litres_per_100?: number
+ roadtrip_battery_kwh?: number
+ roadtrip_kwh_per_100?: number
+ /**
+ * Percent of the battery the years have taken. Absent means none.
+ *
+ * The one figure of ABRP's list that changes the answer here. Plug type, reference
+ * speed and drive style shape a consumption PREDICTION; this addon does not predict
+ * one, it divides a capacity by a consumption the traveller states. Degradation is
+ * different in kind: it shrinks the capacity itself, and on a five-year-old car by
+ * enough to decide whether the last leg of a day arrives.
+ */
+ roadtrip_battery_degradation?: number
/** 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 3d999bda8..e938bee95 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'أطول قيادة متواصلة',
'roadtrip.limit.dayLabel': 'القيادة في اليوم',
'roadtrip.limit.rangeLabel': 'المدى بخزان واحد',
+ 'roadtrip.limit.fillLabel': 'التعبئة حتى',
+ 'roadtrip.limit.fillFull': 'ممتلئ',
+ 'roadtrip.limit.fillBadge': 'يملأ حتى {percent} ٪',
+ 'roadtrip.limit.wearNote': '{percent} ٪ مفقودة بسبب التقادم',
+ 'roadtrip.limit.rangeLabelCharge': 'المدى لكل شحنة',
+ 'roadtrip.limit.sectionDriving': 'القيادة',
+ 'roadtrip.limit.sectionVehicle': 'المركبة',
+ 'roadtrip.limit.tankLabel': 'سعة الخزان',
+ 'roadtrip.limit.batteryLabel': 'البطارية',
+ 'roadtrip.limit.useLabel': 'الاستهلاك',
+ 'roadtrip.limit.wearLabel': 'تآكل البطارية',
+ 'roadtrip.limit.computed': 'محسوب من بيانات المركبة',
+ 'roadtrip.limit.perFill': 'لكل خزان',
+ 'roadtrip.limit.perCharge': 'لكل شحنة',
+ 'roadtrip.limit.afterFill': 'توقف حتى {percent} ٪: {distance}',
+ 'roadtrip.limit.fullNote': 'التوقف يملأ بالكامل',
+ 'roadtrip.limit.rangeEmpty': 'لا يوجد مدى',
+ 'roadtrip.limit.rangeEmptyHint': 'أدخل المدى، أو املأ الحقول ليحسبه TREK.',
+ 'roadtrip.limit.specHint': 'عند تعبئتها، تحدد هذه الحقول المدى أعلاه.',
+ 'roadtrip.limit.vehicleHint': 'حدد نوع سيارتك لتُدخل بياناتها بدل المدى.',
'roadtrip.limit.minutes': 'دقيقة',
'roadtrip.limit.off': 'معطل',
'roadtrip.limit.hint': 'يحسب وقت القيادة فقط. لا يشمل التوقفات والمبيت.',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index e684d0781..9f2070960 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Trecho mais longo seguido',
'roadtrip.limit.dayLabel': 'Condução por dia',
'roadtrip.limit.rangeLabel': 'Autonomia por tanque',
+ 'roadtrip.limit.fillLabel': 'Abastecer até',
+ 'roadtrip.limit.fillFull': 'cheio',
+ 'roadtrip.limit.fillBadge': 'abastece até {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % perdidos pelo desgaste',
+ 'roadtrip.limit.rangeLabelCharge': 'Autonomia por carga',
+ 'roadtrip.limit.sectionDriving': 'Condução',
+ 'roadtrip.limit.sectionVehicle': 'Veículo',
+ 'roadtrip.limit.tankLabel': 'Tanque',
+ 'roadtrip.limit.batteryLabel': 'Bateria',
+ 'roadtrip.limit.useLabel': 'Consumo',
+ 'roadtrip.limit.wearLabel': 'Desgaste da bateria',
+ 'roadtrip.limit.computed': 'Calculado a partir dos dados do veículo',
+ 'roadtrip.limit.perFill': 'por tanque',
+ 'roadtrip.limit.perCharge': 'por carga',
+ 'roadtrip.limit.afterFill': 'Uma parada até {percent} % dá {distance}',
+ 'roadtrip.limit.fullNote': 'Uma parada enche por completo',
+ 'roadtrip.limit.rangeEmpty': 'Sem autonomia',
+ 'roadtrip.limit.rangeEmptyHint': 'Digite uma autonomia, ou preencha os campos e o TREK a calcula.',
+ 'roadtrip.limit.specHint': 'Preenchidos, estes campos definem a autonomia acima.',
+ 'roadtrip.limit.vehicleHint': 'Diga o que você dirige e poderá informar os dados em vez da autonomia.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'desligado',
'roadtrip.limit.hint': 'Conta apenas o tempo de condução. Paradas e pernoites não estão incluídos.',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index ff6826f23..0bb4aadb0 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Trajecte més llarg seguit',
'roadtrip.limit.dayLabel': 'Conducció per dia',
'roadtrip.limit.rangeLabel': 'Autonomia per dipòsit',
+ 'roadtrip.limit.fillLabel': 'Omple fins a',
+ 'roadtrip.limit.fillFull': 'ple',
+ 'roadtrip.limit.fillBadge': 'omple al {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % perdut per l’edat',
+ 'roadtrip.limit.rangeLabelCharge': 'Autonomia per càrrega',
+ 'roadtrip.limit.sectionDriving': 'Conducció',
+ 'roadtrip.limit.sectionVehicle': 'Vehicle',
+ 'roadtrip.limit.tankLabel': 'Dipòsit',
+ 'roadtrip.limit.batteryLabel': 'Bateria',
+ 'roadtrip.limit.useLabel': 'Consum',
+ 'roadtrip.limit.wearLabel': 'Desgast bateria',
+ 'roadtrip.limit.computed': 'Calculat a partir de les dades del vehicle',
+ 'roadtrip.limit.perFill': 'per dipòsit',
+ 'roadtrip.limit.perCharge': 'per càrrega',
+ 'roadtrip.limit.afterFill': 'Una parada al {percent} % dóna {distance}',
+ 'roadtrip.limit.fullNote': 'Una parada omple del tot',
+ 'roadtrip.limit.rangeEmpty': 'Sense autonomia',
+ 'roadtrip.limit.rangeEmptyHint': 'Escriu una autonomia, o omple els camps i TREK la calcula.',
+ 'roadtrip.limit.specHint': 'Omplerts, aquests camps determinen l’autonomia de dalt.',
+ 'roadtrip.limit.vehicleHint': 'Digues què condueixes i podràs donar-ne les dades en lloc de l’autonomia.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'desactivat',
'roadtrip.limit.hint': 'Només compta el temps de conducció. Les parades i les nits no hi són incloses.',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index b065b4d41..805f0e48a 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Nejdelší jízda v kuse',
'roadtrip.limit.dayLabel': 'Jízda za den',
'roadtrip.limit.rangeLabel': 'Dojezd na jednu nádrž',
+ 'roadtrip.limit.fillLabel': 'Natankovat na',
+ 'roadtrip.limit.fillFull': 'plná',
+ 'roadtrip.limit.fillBadge': 'tankuje na {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % ubylo stárnutím',
+ 'roadtrip.limit.rangeLabelCharge': 'Dojezd na nabití',
+ 'roadtrip.limit.sectionDriving': 'Jízda',
+ 'roadtrip.limit.sectionVehicle': 'Vozidlo',
+ 'roadtrip.limit.tankLabel': 'Objem nádrže',
+ 'roadtrip.limit.batteryLabel': 'Baterie',
+ 'roadtrip.limit.useLabel': 'Spotřeba',
+ 'roadtrip.limit.wearLabel': 'Opotřebení baterie',
+ 'roadtrip.limit.computed': 'Vypočteno z údajů vozidla',
+ 'roadtrip.limit.perFill': 'na nádrž',
+ 'roadtrip.limit.perCharge': 'na nabití',
+ 'roadtrip.limit.afterFill': 'Zastávka na {percent} % dá {distance}',
+ 'roadtrip.limit.fullNote': 'Zastávka doplní do plna',
+ 'roadtrip.limit.rangeEmpty': 'Bez dojezdu',
+ 'roadtrip.limit.rangeEmptyHint': 'Zadej dojezd, nebo vyplň pole a TREK ho spočítá.',
+ 'roadtrip.limit.specHint': 'Vyplněná pole určují dojezd nahoře.',
+ 'roadtrip.limit.vehicleHint': 'Uveď, čím jezdíš, a místo dojezdu můžeš zadat údaje vozidla.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'vypnuto',
'roadtrip.limit.hint': 'Počítá jen čas jízdy. Zastávky a noclehy nejsou zahrnuty.',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index e9461f66d..57ce5e001 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Längste Fahrt am Stück',
'roadtrip.limit.dayLabel': 'Fahrzeit pro Tag',
'roadtrip.limit.rangeLabel': 'Reichweite pro Tankfüllung',
+ 'roadtrip.limit.fillLabel': 'Tanken bis',
+ 'roadtrip.limit.fillFull': 'voll',
+ 'roadtrip.limit.fillBadge': 'tankt auf {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % durch Alterung weg',
+ 'roadtrip.limit.rangeLabelCharge': 'Reichweite pro Ladung',
+ 'roadtrip.limit.sectionDriving': 'Fahren',
+ 'roadtrip.limit.sectionVehicle': 'Fahrzeug',
+ 'roadtrip.limit.tankLabel': 'Tankinhalt',
+ 'roadtrip.limit.batteryLabel': 'Akkukapazität',
+ 'roadtrip.limit.useLabel': 'Verbrauch',
+ 'roadtrip.limit.wearLabel': 'Akku-Alterung',
+ 'roadtrip.limit.computed': 'Aus den Fahrzeugdaten berechnet',
+ 'roadtrip.limit.perFill': 'pro Tankfüllung',
+ 'roadtrip.limit.perCharge': 'pro Ladung',
+ 'roadtrip.limit.afterFill': 'Ein Stopp auf {percent} % bringt {distance}',
+ 'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll',
+ 'roadtrip.limit.rangeEmpty': 'Keine Reichweite',
+ 'roadtrip.limit.rangeEmptyHint': 'Trag eine Reichweite ein oder fülle die Felder aus, dann rechnet TREK sie aus.',
+ 'roadtrip.limit.specHint': 'Ausgefüllt bestimmen diese Werte die Reichweite oben.',
+ 'roadtrip.limit.vehicleHint': 'Sag, was du fährst, dann kannst du statt der Reichweite die Fahrzeugdaten angeben.',
'roadtrip.limit.minutes': 'Min',
'roadtrip.limit.off': 'aus',
'roadtrip.limit.hint': 'Zählt nur Fahrzeit. Standzeiten und Übernachtungen sind nicht enthalten.',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index e28a88682..90b1c6cd6 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Longest drive at once',
'roadtrip.limit.dayLabel': 'Driving per day',
'roadtrip.limit.rangeLabel': 'Range on one tank',
+ 'roadtrip.limit.fillLabel': 'Fill up to',
+ 'roadtrip.limit.fillFull': 'full',
+ 'roadtrip.limit.fillBadge': 'fills to {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % lost to age',
+ 'roadtrip.limit.rangeLabelCharge': 'Range on one charge',
+ 'roadtrip.limit.sectionDriving': 'Driving',
+ 'roadtrip.limit.sectionVehicle': 'Vehicle',
+ 'roadtrip.limit.tankLabel': 'Tank size',
+ 'roadtrip.limit.batteryLabel': 'Battery',
+ 'roadtrip.limit.useLabel': 'Consumption',
+ 'roadtrip.limit.wearLabel': 'Battery wear',
+ 'roadtrip.limit.computed': 'Worked out from the vehicle’s figures',
+ 'roadtrip.limit.perFill': 'per tank',
+ 'roadtrip.limit.perCharge': 'per charge',
+ 'roadtrip.limit.afterFill': 'A {percent} % stop gives {distance}',
+ 'roadtrip.limit.fullNote': 'A stop fills right up',
+ 'roadtrip.limit.rangeEmpty': 'No range set',
+ 'roadtrip.limit.rangeEmptyHint': 'Type a range, or fill in the figures below and TREK works it out.',
+ 'roadtrip.limit.specHint': 'Filled in, these decide the range above.',
+ 'roadtrip.limit.vehicleHint': 'Name the kind of car and you can state its figures instead of a range.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'off',
'roadtrip.limit.hint': 'Counts driving time only. Time spent at stops and overnight legs are not included.',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 67ae0e0cb..51ca47b6f 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Trayecto más largo seguido',
'roadtrip.limit.dayLabel': 'Conducción por día',
'roadtrip.limit.rangeLabel': 'Autonomía por depósito',
+ 'roadtrip.limit.fillLabel': 'Repostar hasta',
+ 'roadtrip.limit.fillFull': 'lleno',
+ 'roadtrip.limit.fillBadge': 'reposta al {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % perdido por el desgaste',
+ 'roadtrip.limit.rangeLabelCharge': 'Autonomía por carga',
+ 'roadtrip.limit.sectionDriving': 'Conducción',
+ 'roadtrip.limit.sectionVehicle': 'Vehículo',
+ 'roadtrip.limit.tankLabel': 'Depósito',
+ 'roadtrip.limit.batteryLabel': 'Batería',
+ 'roadtrip.limit.useLabel': 'Consumo',
+ 'roadtrip.limit.wearLabel': 'Desgaste batería',
+ 'roadtrip.limit.computed': 'Calculado con los datos del vehículo',
+ 'roadtrip.limit.perFill': 'por depósito',
+ 'roadtrip.limit.perCharge': 'por carga',
+ 'roadtrip.limit.afterFill': 'Una parada al {percent} % da {distance}',
+ 'roadtrip.limit.fullNote': 'Una parada llena del todo',
+ 'roadtrip.limit.rangeEmpty': 'Sin autonomía',
+ 'roadtrip.limit.rangeEmptyHint': 'Escribe una autonomía, o rellena los campos y TREK la calcula.',
+ 'roadtrip.limit.specHint': 'Rellenos, estos campos deciden la autonomía de arriba.',
+ 'roadtrip.limit.vehicleHint': 'Indica el tipo de vehículo y podrás dar sus datos en vez de la autonomía.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'desactivado',
'roadtrip.limit.hint': 'Solo cuenta el tiempo de conducción. Las paradas y las noches no se incluyen.',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 32f9e2f57..3e61f6303 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Plus long trajet d’affilée',
'roadtrip.limit.dayLabel': 'Conduite par jour',
'roadtrip.limit.rangeLabel': 'Autonomie par plein',
+ 'roadtrip.limit.fillLabel': 'Remplir jusqu’à',
+ 'roadtrip.limit.fillFull': 'plein',
+ 'roadtrip.limit.fillBadge': 'remplit à {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % perdus avec l’âge',
+ 'roadtrip.limit.rangeLabelCharge': 'Autonomie par charge',
+ 'roadtrip.limit.sectionDriving': 'Conduite',
+ 'roadtrip.limit.sectionVehicle': 'Véhicule',
+ 'roadtrip.limit.tankLabel': 'Réservoir',
+ 'roadtrip.limit.batteryLabel': 'Batterie',
+ 'roadtrip.limit.useLabel': 'Consommation',
+ 'roadtrip.limit.wearLabel': 'Usure batterie',
+ 'roadtrip.limit.computed': 'Calculé à partir des données du véhicule',
+ 'roadtrip.limit.perFill': 'par plein',
+ 'roadtrip.limit.perCharge': 'par charge',
+ 'roadtrip.limit.afterFill': 'Un arrêt à {percent} % donne {distance}',
+ 'roadtrip.limit.fullNote': 'Un arrêt fait le plein',
+ 'roadtrip.limit.rangeEmpty': 'Aucune autonomie',
+ 'roadtrip.limit.rangeEmptyHint': 'Saisissez une autonomie, ou remplissez les champs et TREK la calcule.',
+ 'roadtrip.limit.specHint': 'Remplis, ces champs déterminent l’autonomie ci-dessus.',
+ 'roadtrip.limit.vehicleHint': 'Indiquez le type de véhicule pour saisir ses données au lieu d’une autonomie.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'désactivé',
'roadtrip.limit.hint': 'Compte uniquement le temps de conduite. Les arrêts et les nuits ne sont pas inclus.',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index e8852eaaf..826161587 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Μεγαλύτερη συνεχής οδήγηση',
'roadtrip.limit.dayLabel': 'Οδήγηση ανά ημέρα',
'roadtrip.limit.rangeLabel': 'Αυτονομία με ένα γέμισμα',
+ 'roadtrip.limit.fillLabel': 'Γέμισμα έως',
+ 'roadtrip.limit.fillFull': 'γεμάτο',
+ 'roadtrip.limit.fillBadge': 'γεμίζει στο {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % χαμένο από τη φθορά',
+ 'roadtrip.limit.rangeLabelCharge': 'Αυτονομία ανά φόρτιση',
+ 'roadtrip.limit.sectionDriving': 'Οδήγηση',
+ 'roadtrip.limit.sectionVehicle': 'Όχημα',
+ 'roadtrip.limit.tankLabel': 'Ρεζερβουάρ',
+ 'roadtrip.limit.batteryLabel': 'Μπαταρία',
+ 'roadtrip.limit.useLabel': 'Κατανάλωση',
+ 'roadtrip.limit.wearLabel': 'Φθορά μπαταρίας',
+ 'roadtrip.limit.computed': 'Υπολογίζεται από τα στοιχεία του οχήματος',
+ 'roadtrip.limit.perFill': 'ανά γέμισμα',
+ 'roadtrip.limit.perCharge': 'ανά φόρτιση',
+ 'roadtrip.limit.afterFill': 'Στάση στο {percent} %: {distance}',
+ 'roadtrip.limit.fullNote': 'Μια στάση γεμίζει τελείως',
+ 'roadtrip.limit.rangeEmpty': 'Χωρίς αυτονομία',
+ 'roadtrip.limit.rangeEmptyHint': 'Δώσε μια αυτονομία ή συμπλήρωσε τα πεδία και το TREK την υπολογίζει.',
+ 'roadtrip.limit.specHint': 'Συμπληρωμένα, αυτά καθορίζουν την αυτονομία πάνω.',
+ 'roadtrip.limit.vehicleHint': 'Δήλωσε τι οδηγείς και δώσε τα στοιχεία του αντί για αυτονομία.',
'roadtrip.limit.minutes': 'λεπ',
'roadtrip.limit.off': 'ανενεργό',
'roadtrip.limit.hint': 'Μετρά μόνο τον χρόνο οδήγησης. Οι στάσεις και οι διανυκτερεύσεις δεν περιλαμβάνονται.',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 0c07ed8c1..87c9d9a87 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Leghosszabb egyhuzamban vezetés',
'roadtrip.limit.dayLabel': 'Vezetés naponta',
'roadtrip.limit.rangeLabel': 'Hatótáv egy tankkal',
+ 'roadtrip.limit.fillLabel': 'Feltöltés eddig',
+ 'roadtrip.limit.fillFull': 'tele',
+ 'roadtrip.limit.fillBadge': '{percent} %-ig tölt',
+ 'roadtrip.limit.wearNote': '{percent} % elveszett az öregedéssel',
+ 'roadtrip.limit.rangeLabelCharge': 'Hatótáv egy töltéssel',
+ 'roadtrip.limit.sectionDriving': 'Vezetés',
+ 'roadtrip.limit.sectionVehicle': 'Jármű',
+ 'roadtrip.limit.tankLabel': 'Tank mérete',
+ 'roadtrip.limit.batteryLabel': 'Akkumulátor',
+ 'roadtrip.limit.useLabel': 'Fogyasztás',
+ 'roadtrip.limit.wearLabel': 'Akkumulátor kopása',
+ 'roadtrip.limit.computed': 'A jármű adataiból számítva',
+ 'roadtrip.limit.perFill': 'tankonként',
+ 'roadtrip.limit.perCharge': 'töltésenként',
+ 'roadtrip.limit.afterFill': '{percent} %-ig töltve: {distance}',
+ 'roadtrip.limit.fullNote': 'Egy megálló teletölt',
+ 'roadtrip.limit.rangeEmpty': 'Nincs hatótáv',
+ 'roadtrip.limit.rangeEmptyHint': 'Add meg a hatótávot, vagy töltsd ki a mezőket, és a TREK kiszámolja.',
+ 'roadtrip.limit.specHint': 'Kitöltve ezek adják a fenti hatótávot.',
+ 'roadtrip.limit.vehicleHint': 'Add meg, mivel utazol, és hatótáv helyett a jármű adatait is megadhatod.',
'roadtrip.limit.minutes': 'perc',
'roadtrip.limit.off': 'ki',
'roadtrip.limit.hint': 'Csak a vezetési időt számolja. A megállók és az éjszakázások nincsenek benne.',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 169b4a2f2..e72111c13 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Perjalanan terlama sekali jalan',
'roadtrip.limit.dayLabel': 'Mengemudi per hari',
'roadtrip.limit.rangeLabel': 'Jarak per tangki',
+ 'roadtrip.limit.fillLabel': 'Isi sampai',
+ 'roadtrip.limit.fillFull': 'penuh',
+ 'roadtrip.limit.fillBadge': 'isi sampai {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % hilang karena usia',
+ 'roadtrip.limit.rangeLabelCharge': 'Jarak per pengisian',
+ 'roadtrip.limit.sectionDriving': 'Mengemudi',
+ 'roadtrip.limit.sectionVehicle': 'Kendaraan',
+ 'roadtrip.limit.tankLabel': 'Kapasitas tangki',
+ 'roadtrip.limit.batteryLabel': 'Baterai',
+ 'roadtrip.limit.useLabel': 'Konsumsi',
+ 'roadtrip.limit.wearLabel': 'Penurunan baterai',
+ 'roadtrip.limit.computed': 'Dihitung dari data kendaraan',
+ 'roadtrip.limit.perFill': 'per tangki',
+ 'roadtrip.limit.perCharge': 'per pengisian',
+ 'roadtrip.limit.afterFill': 'Berhenti sampai {percent} %: {distance}',
+ 'roadtrip.limit.fullNote': 'Satu perhentian mengisi penuh',
+ 'roadtrip.limit.rangeEmpty': 'Belum ada jarak jangkau',
+ 'roadtrip.limit.rangeEmptyHint': 'Isi jarak jangkau, atau isi kolomnya dan TREK yang menghitung.',
+ 'roadtrip.limit.specHint': 'Bila diisi, kolom ini menentukan jarak jangkau di atas.',
+ 'roadtrip.limit.vehicleHint': 'Sebutkan jenis kendaraanmu, lalu isi datanya alih-alih jarak jangkau.',
'roadtrip.limit.minutes': 'mnt',
'roadtrip.limit.off': 'mati',
'roadtrip.limit.hint': 'Hanya menghitung waktu mengemudi. Waktu berhenti dan menginap tidak termasuk.',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index bccf14109..1a771bc22 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Tratto più lungo di fila',
'roadtrip.limit.dayLabel': 'Guida al giorno',
'roadtrip.limit.rangeLabel': 'Autonomia con un pieno',
+ 'roadtrip.limit.fillLabel': 'Riempi fino a',
+ 'roadtrip.limit.fillFull': 'pieno',
+ 'roadtrip.limit.fillBadge': 'riempie al {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % perso per l’età',
+ 'roadtrip.limit.rangeLabelCharge': 'Autonomia per ricarica',
+ 'roadtrip.limit.sectionDriving': 'Guida',
+ 'roadtrip.limit.sectionVehicle': 'Veicolo',
+ 'roadtrip.limit.tankLabel': 'Serbatoio',
+ 'roadtrip.limit.batteryLabel': 'Batteria',
+ 'roadtrip.limit.useLabel': 'Consumo',
+ 'roadtrip.limit.wearLabel': 'Degrado batteria',
+ 'roadtrip.limit.computed': 'Calcolato dai dati del veicolo',
+ 'roadtrip.limit.perFill': 'per pieno',
+ 'roadtrip.limit.perCharge': 'per ricarica',
+ 'roadtrip.limit.afterFill': 'Una sosta al {percent} % dà {distance}',
+ 'roadtrip.limit.fullNote': 'Una sosta riempie del tutto',
+ 'roadtrip.limit.rangeEmpty': 'Nessuna autonomia',
+ 'roadtrip.limit.rangeEmptyHint': 'Inserisci un’autonomia, oppure compila i campi e TREK la calcola.',
+ 'roadtrip.limit.specHint': 'Se compilati, questi campi determinano l’autonomia sopra.',
+ 'roadtrip.limit.vehicleHint': 'Indica il tipo di veicolo per inserire i suoi dati invece dell’autonomia.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'off',
'roadtrip.limit.hint': 'Conta solo il tempo di guida. Le soste e i pernottamenti non sono inclusi.',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index d592111b0..cebaa68d3 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': '連続運転の上限',
'roadtrip.limit.dayLabel': '1日の運転',
'roadtrip.limit.rangeLabel': '満タンでの航続',
+ 'roadtrip.limit.fillLabel': '補給する量',
+ 'roadtrip.limit.fillFull': '満タン',
+ 'roadtrip.limit.fillBadge': '{percent} % まで補給',
+ 'roadtrip.limit.wearNote': '劣化で {percent} % 減',
+ 'roadtrip.limit.rangeLabelCharge': '1回の充電での航続距離',
+ 'roadtrip.limit.sectionDriving': '運転',
+ 'roadtrip.limit.sectionVehicle': '車両',
+ 'roadtrip.limit.tankLabel': 'タンク容量',
+ 'roadtrip.limit.batteryLabel': 'バッテリー容量',
+ 'roadtrip.limit.useLabel': '消費量',
+ 'roadtrip.limit.wearLabel': 'バッテリー劣化',
+ 'roadtrip.limit.computed': '車両データから算出',
+ 'roadtrip.limit.perFill': '満タンあたり',
+ 'roadtrip.limit.perCharge': '1回の充電で',
+ 'roadtrip.limit.afterFill': '{percent} % まで補給で {distance}',
+ 'roadtrip.limit.fullNote': '停車で満タンまで補給',
+ 'roadtrip.limit.rangeEmpty': '航続距離なし',
+ 'roadtrip.limit.rangeEmptyHint': '航続距離を入力するか、下の項目を埋めれば TREK が計算します。',
+ 'roadtrip.limit.specHint': '入力すると、上の航続距離はここから決まります。',
+ 'roadtrip.limit.vehicleHint': '何で走るかを選ぶと、航続距離の代わりに車両データを入力できます。',
'roadtrip.limit.minutes': '分',
'roadtrip.limit.off': 'なし',
'roadtrip.limit.hint': '運転時間のみを数えます。滞在時間と宿泊は含みません。',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index c4cfe59d8..16f1a004b 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': '연속 운전 최대',
'roadtrip.limit.dayLabel': '하루 운전',
'roadtrip.limit.rangeLabel': '1회 주유 주행거리',
+ 'roadtrip.limit.fillLabel': '충전 목표',
+ 'roadtrip.limit.fillFull': '가득',
+ 'roadtrip.limit.fillBadge': '{percent} %까지 충전',
+ 'roadtrip.limit.wearNote': '노후로 {percent} % 감소',
+ 'roadtrip.limit.rangeLabelCharge': '한 번 충전 주행거리',
+ 'roadtrip.limit.sectionDriving': '운전',
+ 'roadtrip.limit.sectionVehicle': '차량',
+ 'roadtrip.limit.tankLabel': '연료탱크',
+ 'roadtrip.limit.batteryLabel': '배터리 용량',
+ 'roadtrip.limit.useLabel': '소비량',
+ 'roadtrip.limit.wearLabel': '배터리 열화',
+ 'roadtrip.limit.computed': '차량 정보로 계산',
+ 'roadtrip.limit.perFill': '한 탱크당',
+ 'roadtrip.limit.perCharge': '한 번 충전당',
+ 'roadtrip.limit.afterFill': '{percent} %까지 충전 시 {distance}',
+ 'roadtrip.limit.fullNote': '정차 시 가득 충전',
+ 'roadtrip.limit.rangeEmpty': '주행거리 없음',
+ 'roadtrip.limit.rangeEmptyHint': '주행거리를 입력하거나 아래 항목을 채우면 TREK이 계산합니다.',
+ 'roadtrip.limit.specHint': '입력하면 위 주행거리는 이 값으로 정해집니다.',
+ 'roadtrip.limit.vehicleHint': '어떤 차인지 고르면 주행거리 대신 차량 정보를 입력할 수 있습니다.',
'roadtrip.limit.minutes': '분',
'roadtrip.limit.off': '끔',
'roadtrip.limit.hint': '운전 시간만 계산합니다. 정차 시간과 숙박은 포함되지 않습니다.',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 9fc025ba5..c5a3fe842 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Langste rit achtereen',
'roadtrip.limit.dayLabel': 'Rijden per dag',
'roadtrip.limit.rangeLabel': 'Bereik per tank',
+ 'roadtrip.limit.fillLabel': 'Vullen tot',
+ 'roadtrip.limit.fillFull': 'vol',
+ 'roadtrip.limit.fillBadge': 'vult tot {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % verloren door veroudering',
+ 'roadtrip.limit.rangeLabelCharge': 'Actieradius per lading',
+ 'roadtrip.limit.sectionDriving': 'Rijden',
+ 'roadtrip.limit.sectionVehicle': 'Voertuig',
+ 'roadtrip.limit.tankLabel': 'Tankinhoud',
+ 'roadtrip.limit.batteryLabel': 'Accu',
+ 'roadtrip.limit.useLabel': 'Verbruik',
+ 'roadtrip.limit.wearLabel': 'Accuslijtage',
+ 'roadtrip.limit.computed': 'Berekend uit de voertuiggegevens',
+ 'roadtrip.limit.perFill': 'per tankbeurt',
+ 'roadtrip.limit.perCharge': 'per lading',
+ 'roadtrip.limit.afterFill': 'Een stop tot {percent} % geeft {distance}',
+ 'roadtrip.limit.fullNote': 'Een stop maakt weer helemaal vol',
+ 'roadtrip.limit.rangeEmpty': 'Geen actieradius',
+ 'roadtrip.limit.rangeEmptyHint': 'Vul een actieradius in, of vul de velden in en TREK rekent hem uit.',
+ 'roadtrip.limit.specHint': 'Ingevuld bepalen deze velden de actieradius hierboven.',
+ 'roadtrip.limit.vehicleHint': 'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'uit',
'roadtrip.limit.hint': 'Telt alleen rijtijd. Tijd bij stops en overnachtingen tellen niet mee.',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index f402ce6fd..786aa7256 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Najdłuższa jazda bez przerwy',
'roadtrip.limit.dayLabel': 'Jazda dziennie',
'roadtrip.limit.rangeLabel': 'Zasięg na jednym baku',
+ 'roadtrip.limit.fillLabel': 'Tankuj do',
+ 'roadtrip.limit.fillFull': 'pełny',
+ 'roadtrip.limit.fillBadge': 'tankuje do {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % utracone przez zużycie',
+ 'roadtrip.limit.rangeLabelCharge': 'Zasięg na ładowaniu',
+ 'roadtrip.limit.sectionDriving': 'Jazda',
+ 'roadtrip.limit.sectionVehicle': 'Pojazd',
+ 'roadtrip.limit.tankLabel': 'Pojemność baku',
+ 'roadtrip.limit.batteryLabel': 'Akumulator',
+ 'roadtrip.limit.useLabel': 'Zużycie',
+ 'roadtrip.limit.wearLabel': 'Zużycie akumulatora',
+ 'roadtrip.limit.computed': 'Obliczone z danych pojazdu',
+ 'roadtrip.limit.perFill': 'na baku',
+ 'roadtrip.limit.perCharge': 'na ładowaniu',
+ 'roadtrip.limit.afterFill': 'Postój do {percent} % daje {distance}',
+ 'roadtrip.limit.fullNote': 'Postój napełnia do pełna',
+ 'roadtrip.limit.rangeEmpty': 'Brak zasięgu',
+ 'roadtrip.limit.rangeEmptyHint': 'Wpisz zasięg albo wypełnij pola, a TREK go obliczy.',
+ 'roadtrip.limit.specHint': 'Wypełnione, te pola wyznaczają zasięg powyżej.',
+ 'roadtrip.limit.vehicleHint': 'Podaj rodzaj pojazdu, a zamiast zasięgu wpiszesz jego dane.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'wył.',
'roadtrip.limit.hint': 'Liczy tylko czas jazdy. Postoje i noclegi nie są wliczane.',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 12649b452..bbe047fb4 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Самый долгий перегон',
'roadtrip.limit.dayLabel': 'Вождение за день',
'roadtrip.limit.rangeLabel': 'Запас хода на баке',
+ 'roadtrip.limit.fillLabel': 'Заправлять до',
+ 'roadtrip.limit.fillFull': 'полный',
+ 'roadtrip.limit.fillBadge': 'заправка до {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % потеряно из-за износа',
+ 'roadtrip.limit.rangeLabelCharge': 'Запас хода на заряде',
+ 'roadtrip.limit.sectionDriving': 'Вождение',
+ 'roadtrip.limit.sectionVehicle': 'Автомобиль',
+ 'roadtrip.limit.tankLabel': 'Объём бака',
+ 'roadtrip.limit.batteryLabel': 'Батарея',
+ 'roadtrip.limit.useLabel': 'Расход',
+ 'roadtrip.limit.wearLabel': 'Износ батареи',
+ 'roadtrip.limit.computed': 'Рассчитано по данным автомобиля',
+ 'roadtrip.limit.perFill': 'на баке',
+ 'roadtrip.limit.perCharge': 'на заряде',
+ 'roadtrip.limit.afterFill': 'Остановка до {percent} %: {distance}',
+ 'roadtrip.limit.fullNote': 'Остановка заправляет полностью',
+ 'roadtrip.limit.rangeEmpty': 'Запас хода не задан',
+ 'roadtrip.limit.rangeEmptyHint': 'Укажите запас хода или заполните поля, и TREK посчитает сам.',
+ 'roadtrip.limit.specHint': 'Заполненные, эти поля задают запас хода выше.',
+ 'roadtrip.limit.vehicleHint': 'Укажите тип машины, и вместо запаса хода можно ввести её данные.',
'roadtrip.limit.minutes': 'мин',
'roadtrip.limit.off': 'выкл',
'roadtrip.limit.hint': 'Учитывается только время за рулём. Остановки и ночёвки не входят.',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 4bc554425..cf2cfabc6 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Längsta körning i sträck',
'roadtrip.limit.dayLabel': 'Körning per dag',
'roadtrip.limit.rangeLabel': 'Räckvidd per tank',
+ 'roadtrip.limit.fillLabel': 'Fyll till',
+ 'roadtrip.limit.fillFull': 'fullt',
+ 'roadtrip.limit.fillBadge': 'fyller till {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % förlorat med åldern',
+ 'roadtrip.limit.rangeLabelCharge': 'Räckvidd per laddning',
+ 'roadtrip.limit.sectionDriving': 'Körning',
+ 'roadtrip.limit.sectionVehicle': 'Fordon',
+ 'roadtrip.limit.tankLabel': 'Tankvolym',
+ 'roadtrip.limit.batteryLabel': 'Batteri',
+ 'roadtrip.limit.useLabel': 'Förbrukning',
+ 'roadtrip.limit.wearLabel': 'Batterislitage',
+ 'roadtrip.limit.computed': 'Beräknat från fordonets uppgifter',
+ 'roadtrip.limit.perFill': 'per tank',
+ 'roadtrip.limit.perCharge': 'per laddning',
+ 'roadtrip.limit.afterFill': 'Ett stopp till {percent} % ger {distance}',
+ 'roadtrip.limit.fullNote': 'Ett stopp fyller helt',
+ 'roadtrip.limit.rangeEmpty': 'Ingen räckvidd',
+ 'roadtrip.limit.rangeEmptyHint': 'Ange en räckvidd, eller fyll i fälten så räknar TREK ut den.',
+ 'roadtrip.limit.specHint': 'Ifyllda bestämmer de här fälten räckvidden ovan.',
+ 'roadtrip.limit.vehicleHint': 'Ange vad du kör så kan du fylla i fordonets uppgifter i stället för en räckvidd.',
'roadtrip.limit.minutes': 'min',
'roadtrip.limit.off': 'av',
'roadtrip.limit.hint': 'Räknar bara körtid. Tid vid stopp och övernattningar ingår inte.',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 8508a5f58..1be063470 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Aralıksız en uzun sürüş',
'roadtrip.limit.dayLabel': 'Günlük sürüş',
'roadtrip.limit.rangeLabel': 'Bir depoyla menzil',
+ 'roadtrip.limit.fillLabel': 'Şu kadar doldur',
+ 'roadtrip.limit.fillFull': 'dolu',
+ 'roadtrip.limit.fillBadge': '%{percent} doldurur',
+ 'roadtrip.limit.wearNote': '%{percent} yaşlanmayla kayıp',
+ 'roadtrip.limit.rangeLabelCharge': 'Şarj başına menzil',
+ 'roadtrip.limit.sectionDriving': 'Sürüş',
+ 'roadtrip.limit.sectionVehicle': 'Araç',
+ 'roadtrip.limit.tankLabel': 'Depo hacmi',
+ 'roadtrip.limit.batteryLabel': 'Batarya',
+ 'roadtrip.limit.useLabel': 'Tüketim',
+ 'roadtrip.limit.wearLabel': 'Batarya yıpranması',
+ 'roadtrip.limit.computed': 'Araç verilerinden hesaplandı',
+ 'roadtrip.limit.perFill': 'depo başına',
+ 'roadtrip.limit.perCharge': 'şarj başına',
+ 'roadtrip.limit.afterFill': '%{percent} dolum: {distance}',
+ 'roadtrip.limit.fullNote': 'Bir mola tamamen doldurur',
+ 'roadtrip.limit.rangeEmpty': 'Menzil yok',
+ 'roadtrip.limit.rangeEmptyHint': 'Bir menzil gir ya da alanları doldur, TREK hesaplasın.',
+ 'roadtrip.limit.specHint': 'Doldurulduğunda bu alanlar yukarıdaki menzili belirler.',
+ 'roadtrip.limit.vehicleHint': 'Ne kullandığını seç, menzil yerine araç verilerini girebilirsin.',
'roadtrip.limit.minutes': 'dk',
'roadtrip.limit.off': 'kapalı',
'roadtrip.limit.hint': 'Yalnızca sürüş süresini sayar. Molalar ve geceleme dahil değildir.',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 6707817bf..aae2b8754 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Найдовший перегін',
'roadtrip.limit.dayLabel': 'Водіння за день',
'roadtrip.limit.rangeLabel': 'Запас ходу на баку',
+ 'roadtrip.limit.fillLabel': 'Заправляти до',
+ 'roadtrip.limit.fillFull': 'повний',
+ 'roadtrip.limit.fillBadge': 'заправка до {percent} %',
+ 'roadtrip.limit.wearNote': '{percent} % втрачено через знос',
+ 'roadtrip.limit.rangeLabelCharge': 'Запас ходу на заряді',
+ 'roadtrip.limit.sectionDriving': 'Водіння',
+ 'roadtrip.limit.sectionVehicle': 'Автомобіль',
+ 'roadtrip.limit.tankLabel': 'Обʼєм бака',
+ 'roadtrip.limit.batteryLabel': 'Батарея',
+ 'roadtrip.limit.useLabel': 'Витрата',
+ 'roadtrip.limit.wearLabel': 'Знос батареї',
+ 'roadtrip.limit.computed': 'Розраховано за даними автомобіля',
+ 'roadtrip.limit.perFill': 'на баку',
+ 'roadtrip.limit.perCharge': 'на заряді',
+ 'roadtrip.limit.afterFill': 'Зупинка до {percent} %: {distance}',
+ 'roadtrip.limit.fullNote': 'Зупинка заправляє повністю',
+ 'roadtrip.limit.rangeEmpty': 'Запас ходу не задано',
+ 'roadtrip.limit.rangeEmptyHint': 'Вкажіть запас ходу або заповніть поля, і TREK порахує сам.',
+ 'roadtrip.limit.specHint': 'Заповнені, ці поля задають запас ходу вище.',
+ 'roadtrip.limit.vehicleHint': 'Вкажіть тип авто, і замість запасу ходу можна ввести його дані.',
'roadtrip.limit.minutes': 'хв',
'roadtrip.limit.off': 'вимк',
'roadtrip.limit.hint': 'Рахує лише час за кермом. Зупинки та ночівлі не враховано.',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index 1bec30741..15aa0bda2 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': 'Chặng lái dài nhất',
'roadtrip.limit.dayLabel': 'Lái xe mỗi ngày',
'roadtrip.limit.rangeLabel': 'Quãng đường mỗi bình',
+ 'roadtrip.limit.fillLabel': 'Đổ đến',
+ 'roadtrip.limit.fillFull': 'đầy',
+ 'roadtrip.limit.fillBadge': 'đổ đến {percent} %',
+ 'roadtrip.limit.wearNote': 'mất {percent} % do chai',
+ 'roadtrip.limit.rangeLabelCharge': 'Quãng đường mỗi lần sạc',
+ 'roadtrip.limit.sectionDriving': 'Lái xe',
+ 'roadtrip.limit.sectionVehicle': 'Phương tiện',
+ 'roadtrip.limit.tankLabel': 'Dung tích bình',
+ 'roadtrip.limit.batteryLabel': 'Pin',
+ 'roadtrip.limit.useLabel': 'Mức tiêu thụ',
+ 'roadtrip.limit.wearLabel': 'Chai pin',
+ 'roadtrip.limit.computed': 'Tính từ thông số xe',
+ 'roadtrip.limit.perFill': 'mỗi bình',
+ 'roadtrip.limit.perCharge': 'mỗi lần sạc',
+ 'roadtrip.limit.afterFill': 'Dừng đến {percent} %: {distance}',
+ 'roadtrip.limit.fullNote': 'Một lần dừng đổ đầy',
+ 'roadtrip.limit.rangeEmpty': 'Chưa có quãng đường',
+ 'roadtrip.limit.rangeEmptyHint': 'Nhập quãng đường, hoặc điền các ô để TREK tự tính.',
+ 'roadtrip.limit.specHint': 'Khi điền, các ô này quyết định quãng đường ở trên.',
+ 'roadtrip.limit.vehicleHint': 'Chọn loại xe để nhập thông số thay cho quãng đường.',
'roadtrip.limit.minutes': 'phút',
'roadtrip.limit.off': 'tắt',
'roadtrip.limit.hint': 'Chỉ tính thời gian lái. Thời gian dừng và nghỉ đêm không được tính.',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 8900e3656..04cca91c7 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': '單次最長駕駛',
'roadtrip.limit.dayLabel': '每天駕駛',
'roadtrip.limit.rangeLabel': '單箱續航',
+ 'roadtrip.limit.fillLabel': '加到',
+ 'roadtrip.limit.fillFull': '滿',
+ 'roadtrip.limit.fillBadge': '加到 {percent} %',
+ 'roadtrip.limit.wearNote': '衰減損失 {percent} %',
+ 'roadtrip.limit.rangeLabelCharge': '每次充電續航',
+ 'roadtrip.limit.sectionDriving': '駕駛',
+ 'roadtrip.limit.sectionVehicle': '車輛',
+ 'roadtrip.limit.tankLabel': '油箱容量',
+ 'roadtrip.limit.batteryLabel': '電池容量',
+ 'roadtrip.limit.useLabel': '能耗',
+ 'roadtrip.limit.wearLabel': '電池衰減',
+ 'roadtrip.limit.computed': '根據車輛資料計算',
+ 'roadtrip.limit.perFill': '每箱油',
+ 'roadtrip.limit.perCharge': '每次充電',
+ 'roadtrip.limit.afterFill': '加到 {percent} % 可行駛 {distance}',
+ 'roadtrip.limit.fullNote': '每次停靠加滿',
+ 'roadtrip.limit.rangeEmpty': '未設定續航',
+ 'roadtrip.limit.rangeEmptyHint': '填寫續航里程,或填好下面的資料,TREK 會自己算。',
+ 'roadtrip.limit.specHint': '填寫後,上面的續航由這些資料決定。',
+ 'roadtrip.limit.vehicleHint': '選擇車輛類型後,可以填車輛資料代替續航里程。',
'roadtrip.limit.minutes': '分鐘',
'roadtrip.limit.off': '關閉',
'roadtrip.limit.hint': '僅計算駕駛時間,不含停留與過夜。',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 9697bafac..79a9ca89f 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -22,6 +22,26 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legLabel': '单次最长驾驶',
'roadtrip.limit.dayLabel': '每天驾驶',
'roadtrip.limit.rangeLabel': '单箱续航',
+ 'roadtrip.limit.fillLabel': '加到',
+ 'roadtrip.limit.fillFull': '满',
+ 'roadtrip.limit.fillBadge': '加到 {percent} %',
+ 'roadtrip.limit.wearNote': '衰减损失 {percent} %',
+ 'roadtrip.limit.rangeLabelCharge': '每次充电续航',
+ 'roadtrip.limit.sectionDriving': '驾驶',
+ 'roadtrip.limit.sectionVehicle': '车辆',
+ 'roadtrip.limit.tankLabel': '油箱容量',
+ 'roadtrip.limit.batteryLabel': '电池容量',
+ 'roadtrip.limit.useLabel': '能耗',
+ 'roadtrip.limit.wearLabel': '电池衰减',
+ 'roadtrip.limit.computed': '根据车辆数据计算',
+ 'roadtrip.limit.perFill': '每箱油',
+ 'roadtrip.limit.perCharge': '每次充电',
+ 'roadtrip.limit.afterFill': '加到 {percent} % 可行驶 {distance}',
+ 'roadtrip.limit.fullNote': '每次停靠加满',
+ 'roadtrip.limit.rangeEmpty': '未设置续航',
+ 'roadtrip.limit.rangeEmptyHint': '填写续航里程,或填好下面的数据,TREK 会自己算。',
+ 'roadtrip.limit.specHint': '填写后,上面的续航由这些数据决定。',
+ 'roadtrip.limit.vehicleHint': '选择车辆类型后,可以填车辆数据代替续航里程。',
'roadtrip.limit.minutes': '分钟',
'roadtrip.limit.off': '关闭',
'roadtrip.limit.hint': '仅计算驾驶时间,不含停留和过夜。',