From aaf6559d5d5d1ea08517867fadd222faf7cc62b1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 22:35:11 +0200 Subject: [PATCH] feat(roadtrip): state a range as the car it comes from A range could only be typed as one number, and that is the number fewest travellers actually know. A tank size is on the filler flap, a consumption is in the manual, a battery capacity and a kWh per hundred are on the data sheet. All of them divide into the same figure, so the dialog now takes either and does the division itself. What the parts say wins over what was typed, because it is the more specific answer, and the range row goes read-only when they do so there is never a second answer to the same question. Of ABRP's list only battery wear is here. Plug type, reference speed, relative speed and drive style shape a consumption PREDICTION; this addon does not predict one, it divides a capacity by a consumption the traveller states, so those fields would add typing without moving a kilometre. Wear is different in kind: it shrinks the capacity itself, and on an older car by enough to decide whether the last leg of a day arrives. Storage stays metric throughout, the same rule the range itself follows. An imperial traveller types gallons, miles per gallon and kWh per 100 miles and gets them back unchanged; mpg is the inverse of litres per hundred rather than a scale factor, which a straight multiplication would have stored as a car four times as thirsty. The gauge under the picker is the argument that these two ways of stating a range are the same thing, so it answers while the figures are still being typed rather than after the field is left. It draws the tank the day it left the factory: the filled part is what a stop actually puts back in, the hatched tail is what age has taken off, and both are things the bare number cannot say. Also here, from the same round: a fill-up that stops short of full. Nobody charges to 100 % on the road, and counting every stop as a full tank overstated everything after it by that last fifth. Stops that refuel now carry a badge saying how far they fill. --- .../Roadtrip/RoadtripLimitsCard.test.tsx | 75 ++- .../Roadtrip/RoadtripLimitsCard.tsx | 508 ++++++++++++++---- .../components/Roadtrip/RoadtripSidebar.tsx | 47 +- .../components/Roadtrip/roadtripModel.test.ts | 43 ++ .../src/components/Roadtrip/roadtripModel.ts | 20 +- .../components/Roadtrip/useRoadtripRoutes.ts | 37 +- .../components/Roadtrip/vehicleRange.test.ts | 113 ++++ .../src/components/Roadtrip/vehicleRange.ts | 157 ++++++ client/src/types.ts | 33 ++ shared/src/i18n/ar/roadtrip.ts | 20 + shared/src/i18n/br/roadtrip.ts | 20 + shared/src/i18n/ca/roadtrip.ts | 20 + shared/src/i18n/cs/roadtrip.ts | 20 + shared/src/i18n/de/roadtrip.ts | 20 + shared/src/i18n/en/roadtrip.ts | 20 + shared/src/i18n/es/roadtrip.ts | 20 + shared/src/i18n/fr/roadtrip.ts | 20 + shared/src/i18n/gr/roadtrip.ts | 20 + shared/src/i18n/hu/roadtrip.ts | 20 + shared/src/i18n/id/roadtrip.ts | 20 + shared/src/i18n/it/roadtrip.ts | 20 + shared/src/i18n/ja/roadtrip.ts | 20 + shared/src/i18n/ko/roadtrip.ts | 20 + shared/src/i18n/nl/roadtrip.ts | 20 + shared/src/i18n/pl/roadtrip.ts | 20 + shared/src/i18n/ru/roadtrip.ts | 20 + shared/src/i18n/sv/roadtrip.ts | 20 + shared/src/i18n/tr/roadtrip.ts | 20 + shared/src/i18n/uk/roadtrip.ts | 20 + shared/src/i18n/vi/roadtrip.ts | 20 + shared/src/i18n/zh-TW/roadtrip.ts | 20 + shared/src/i18n/zh/roadtrip.ts | 20 + 32 files changed, 1381 insertions(+), 112 deletions(-) create mode 100644 client/src/components/Roadtrip/vehicleRange.test.ts create mode 100644 client/src/components/Roadtrip/vehicleRange.ts 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 }: { ) } @@ -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. */} -