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. */} -