diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 3d334f79f..7a630954f 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -1045,6 +1045,7 @@ "src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.", "src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.", "src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.", + "src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.", "src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.", "src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.", "src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.", diff --git a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx index 114961eb9..506bea338 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx @@ -14,6 +14,8 @@ import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip import { valhallaAvailable } from '../../../../components/Map/valhallaRoute' import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units' import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi' +import { useIsWide } from '../../../../hooks/sizeClass' +import MRtLimitsEditor from './MRtLimitsEditor' import type { DistanceUnit } from '../../../../types' /** @@ -104,6 +106,9 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) { const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId) const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId) const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId) + // An iPad or an unfolded Duo is the table the figures are set at: there the limits are a + // form (MRtLimitsEditor). The phone and the folded Duo keep the read-out below. + const large = useIsWide() const electric = vehicleKind === 'electric' const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car @@ -225,26 +230,41 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) { {/* ── Limits: what every warning on the stage is measured against ── */} -
- -
- {lines.map((line, i) => ( - - ))} + {large ? ( +
+ void planner.saveRoadtripLimit?.(key, value)} + onResetDayBoundaries={planner.resetDayBoundaries} + /> +
+ ) : ( + <> +
+ +
+ {lines.map((line, i) => ( + + ))} +
+
- -
- {/* Under the figures it speaks of, and above the switch it does not. */} -

- {t('mobileTrip.rtDesktopNote')} -

+ {/* Under the figures it speaks of, and above the switch it does not. */} +

+ {t('mobileTrip.rtDesktopNote')} +

+ + )} {/* ── The one switch: whether a day starts and ends at the stay ── */}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx b/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx new file mode 100644 index 000000000..06c69b939 --- /dev/null +++ b/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx @@ -0,0 +1,350 @@ +import { useState, type ReactNode } from 'react'; +import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'; +import { dayWindow } from '../../../../components/Roadtrip/dayWindow'; +import { parseAvoid, serializeAvoid, type VehicleKind } from '../../../../components/Roadtrip/roadtripModel'; +import { rangeFromSpec, type VehicleSpec } from '../../../../components/Roadtrip/vehicleRange'; +import CustomTimePicker from '../../../../components/shared/CustomTimePicker'; +import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'; +import { useTranslation } from '../../../../i18n'; +import { useSettingsStore } from '../../../../store/settingsStore'; +import type { RouteAvoidClass } from '../../../../types'; +import { convertDistance } from '../../../../utils/units'; +import MSegmented from '../../../components/MSegmented'; +import MToggle from '../../../components/MToggle'; +import { Eyebrow } from '../sheets/MTripSheetUi'; + +/** + * The driving figures as a form, for an iPad or an unfolded Duo. + * + * The phone keeps them read-only (MRtInfoSheet says why: a form in the passenger seat breaks + * a trip while it is happening). A large screen is the table the figures are set at, so + * there the same sheet edits what the desktop's limits dialog edits most: the two driving + * limits, the day's window and where it ends, the vehicle with its range and fill, and what + * the road may avoid. The vehicle's own figures (tank, consumption, battery) stay on the + * desktop; once they are stored the range is theirs and shows read-only here, as it does + * there. + * + * Every change goes through the planner's `saveRoadtripLimit`, the desktop dialog's own + * save, which checks the right to edit days itself. `readOnly` disables the controls for a + * reader without that right, so nothing takes a value only to drop it. + */ + +/** Empty means no limit, and so does zero: both are stored as 0 and read as off. */ +function parseFigure(raw: string): number { + const n = Number(raw.replace(',', '.')); + return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; +} + +/** One card of the form: eyebrow plus its rows. */ +function EditCard({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +/** A label and its control on one line, with the row rule above every row but the first. */ +function EditRow({ label, first, children }: { label: string; first?: boolean; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * A number typed in and saved when the field is left or Enter is pressed, not per + * keystroke: a settings write per digit would plan the trip against "4" on the way to "45". + */ +function FigureField({ + label, + value, + suffix, + placeholder, + disabled, + testId, + onCommit, +}: { + label: string; + value: number | undefined; + suffix: string; + placeholder: string; + disabled: boolean; + testId: string; + onCommit: (value: number) => void; +}) { + const [draft, setDraft] = useState(null); + const shown = draft ?? (value ? String(value) : ''); + const commit = () => { + if (draft === null) return; + setDraft(null); + const next = parseFigure(draft); + if (next !== (value ?? 0)) onCommit(next); + }; + return ( + + setDraft(e.target.value.replace(/[^0-9.,]/g, ''))} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + className="h-9 w-[5.5rem] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 text-end text-body font-semibold tabular-nums text-m-ink outline-none placeholder:font-medium placeholder:text-m-faint disabled:opacity-60" + /> + {suffix} + + ); +} + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +const HINT = 'font-geist text-caption leading-snug text-m-faint'; + +interface CardProps { + tripId: number; + readOnly: boolean; + onSave: (key: string, value: number | string | boolean) => void; +} + +/** How long one stretch and one day may be, in minutes. */ +function DrivingCard({ tripId, readOnly, onSave }: CardProps) { + const { t } = useTranslation(); + const leg = useRoadtripSettings((all) => all.roadtrip_leg_minutes, tripId); + const day = useRoadtripSettings((all) => all.roadtrip_day_minutes, tripId); + const rows = [ + { key: 'roadtrip_leg_minutes', label: t('roadtrip.limit.legLabel'), value: leg, testId: 'rt-edit-leg' }, + { key: 'roadtrip_day_minutes', label: t('roadtrip.limit.dayLabel'), value: day, testId: 'rt-edit-day' }, + ]; + return ( + + {rows.map((row, i) => ( + + onSave(row.key, v)} + /> + + ))} +

{t('roadtrip.limit.hint')}

+
+ ); +} + +/** A choice between a few words, faded and out of reach for a reader. */ +function Choice({ + readOnly, + value, + options, + onChange, +}: { + readOnly: boolean; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { + return ( +
+ +
+ ); +} + +/** When the day may start and end, and whether it ends on the road or at a stop. */ +function WindowCard({ + tripId, + readOnly, + onSave, + onResetDayBoundaries, +}: CardProps & { + onResetDayBoundaries?: () => Promise; +}) { + const { t } = useTranslation(); + const storedStart = useRoadtripSettings((all) => all.roadtrip_day_start, tripId) ?? ''; + const storedEnd = useRoadtripSettings((all) => all.roadtrip_day_end, tripId) ?? ''; + const endMode = useRoadtripSettings((all) => all.roadtrip_day_end_mode, tripId) === 'stop' ? 'stop' : 'route'; + // What is in the pickers, ahead of a time that is whole enough to save. + const [times, setTimes] = useState<{ start?: string; end?: string }>({}); + const shown = { start: times.start ?? storedStart, end: times.end ?? storedEnd }; + const stored = { start: storedStart, end: storedEnd }; + const invalid = !!shown.start && !!shown.end && !dayWindow(shown.start, shown.end); + const setTime = (field: 'start' | 'end', value: string) => { + setTimes((prev) => ({ ...prev, [field]: value })); + if (value !== '' && !TIME_RE.test(value)) return; + if (value !== stored[field]) onSave(`roadtrip_day_${field}`, value); + }; + return ( + + {(['start', 'end'] as const).map((field, i) => ( + + + setTime(field, value)} + /> + + + ))} +

+ {t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')} +

+
+
{t('roadtrip.window.endAt')}
+ onSave('roadtrip_day_end_mode', mode)} + options={[ + { value: 'route', label: t('roadtrip.window.onRoute') }, + { value: 'stop', label: t('roadtrip.window.atStop') }, + ]} + /> +

+ {t(endMode === 'stop' ? 'roadtrip.window.stopHint' : 'roadtrip.window.routeHint')} +

+ {onResetDayBoundaries && !readOnly && ( + + )} +
+
+ ); +} + +/** The vehicle, how far it goes, and how full a stop fills it. */ +function VehicleCard({ tripId, readOnly, onSave }: CardProps) { + const { t } = useTranslation(); + const imperial = useSettingsStore((st) => st.settings.distance_unit) === 'imperial'; + const s = useRoadtripSettings((all) => all, tripId); + const vehicle = s.roadtrip_vehicle ?? ''; + const vehicleKind: VehicleKind | null = vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null; + const computedKm = rangeFromSpec(vehicleKind, { + tankLitres: s.roadtrip_tank_litres, + litresPer100: s.roadtrip_litres_per_100, + batteryKwh: s.roadtrip_battery_kwh, + kwhPer100: s.roadtrip_kwh_per_100, + degradationPercent: s.roadtrip_battery_degradation, + } satisfies VehicleSpec); + const rangeKm = computedKm ?? s.roadtrip_range_km; + // Kilometres in storage, the traveller's own unit on screen, as on the desktop. + const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, imperial ? 'imperial' : 'metric')) : undefined; + const rangeLabel = t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel'); + return ( + +
+ onSave('roadtrip_vehicle', kind === 'any' ? '' : kind)} + options={[ + { value: 'any', label: t('roadtrip.limit.vehicleAny') }, + { value: 'combustion', label: t('roadtrip.limit.vehicleCombustion') }, + { value: 'electric', label: t('roadtrip.limit.vehicleElectric') }, + ]} + /> +
+ + onSave('roadtrip_range_km', Math.round(imperial ? v / 0.621371 : v))} + /> + + + onSave('roadtrip_fill_percent', Math.min(100, v))} + /> + + {computedKm !== null &&

{t('roadtrip.limit.computed')}

} +
+ ); +} + +/** What the road may avoid: one stored list that each switch adds to or takes from. */ +function AvoidCard({ tripId, readOnly, onSave }: CardProps) { + const { t } = useTranslation(); + const avoiding = parseAvoid(useRoadtripSettings((all) => all.roadtrip_avoid, tripId)); + // No second engine, no avoidance: the switches stay, disabled, and the hint says why. + const canAvoid = valhallaAvailable(); + const rows: { cls: RouteAvoidClass; label: string }[] = [ + { cls: 'toll', label: t('roadtrip.avoid.toll') }, + { cls: 'motorway', label: t('roadtrip.avoid.motorway') }, + { cls: 'ferry', label: t('roadtrip.avoid.ferry') }, + ]; + const toggle = (cls: RouteAvoidClass) => + onSave( + 'roadtrip_avoid', + serializeAvoid(avoiding.includes(cls) ? avoiding.filter((c) => c !== cls) : [...avoiding, cls]) + ); + return ( + + {rows.map(({ cls, label }, i) => ( + + toggle(cls)} + ariaLabel={label} + disabled={readOnly || !canAvoid} + /> + + ))} +

{t(canAvoid ? 'roadtrip.avoid.hint' : 'roadtrip.avoid.unavailable')}

+
+ ); +} + +export default function MRtLimitsEditor({ + onResetDayBoundaries, + ...card +}: CardProps & { + onResetDayBoundaries?: () => Promise; +}) { + return ( +
+ + + + +
+ ); +} diff --git a/client/tests/unit/mobile/trip/MRtLimitsEditor.test.tsx b/client/tests/unit/mobile/trip/MRtLimitsEditor.test.tsx new file mode 100644 index 000000000..c7f5808f9 --- /dev/null +++ b/client/tests/unit/mobile/trip/MRtLimitsEditor.test.tsx @@ -0,0 +1,166 @@ +import type { RoadtripPreferences } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass'; +import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'; +import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPreferencesStore'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'; +import { fireEvent, render, screen } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; + +// FE-MOB-RTEDIT-001 to FE-MOB-RTEDIT-012 +// +// The driving figures as a form on an iPad or an unfolded Duo, inside the real +// MRtInfoSheet and TranslationProvider. The phone keeps the read-out +// (MRtInfoSheet.test.tsx); these pin the large screen's form. + +const USER_ID = 7; +const TRIP_ID = 4; + +const TYPED: RoadtripPreferences = { + roadtrip_vehicle: 'combustion', + roadtrip_range_km: 600, + roadtrip_fill_percent: 80, + roadtrip_leg_minutes: 240, + roadtrip_day_minutes: 480, + roadtrip_day_start: '08:00', + roadtrip_day_end: '19:00', + roadtrip_avoid: 'toll', +}; + +function renderSheet( + preferences: RoadtripPreferences = TYPED, + size: SizeClass = 'wide', + plannerOverrides: Record = {} +) { + _pinSizeClass(size); + useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } }); + const saveRoadtripLimit = vi.fn(async () => {}); + const planner = buildPlanner({ + tripId: TRIP_ID, + saveRoadtripLimit, + ...plannerOverrides, + } as unknown as Partial); + const shell = buildShell({ sheet: { id: 'rtinfo' } } as unknown as Partial); + render(); + return { saveRoadtripLimit }; +} + +/** Types a figure into a field and leaves it, which is when the form saves. */ +function typeInto(testId: string, value: string): void { + const field = screen.getByTestId(testId); + fireEvent.change(field, { target: { value } }); + fireEvent.blur(field); +} + +describe('MRtLimitsEditor', () => { + beforeEach(() => { + resetAllStores(); + useRoadtripPreferencesStore.setState({ byTrip: {} }); + seedStore(useAuthStore, { user: { id: USER_ID } as never }); + }); + + afterEach(() => { + _pinSizeClass(null); + }); + + it('FE-MOB-RTEDIT-001: the phone keeps the read-out and its desktop footnote', () => { + renderSheet(TYPED, 'compact'); + expect(screen.queryByTestId('rt-limits-editor')).not.toBeInTheDocument(); + expect(screen.getByText(/desktop/i)).toBeInTheDocument(); + }); + + it('FE-MOB-RTEDIT-002: an iPad gets the form instead, without the footnote', () => { + renderSheet(); + expect(screen.getByTestId('rt-limits-editor')).toBeInTheDocument(); + expect(screen.queryByText(/desktop/i)).not.toBeInTheDocument(); + expect(screen.getByTestId('rt-edit-leg')).toHaveValue('240'); + expect(screen.getByTestId('rt-edit-day')).toHaveValue('480'); + expect(screen.getByTestId('rt-edit-range')).toHaveValue('600'); + expect(screen.getByTestId('rt-edit-fill')).toHaveValue('80'); + }); + + it('FE-MOB-RTEDIT-003: a driving limit is saved when the field is left, not per keystroke', () => { + const { saveRoadtripLimit } = renderSheet(); + const field = screen.getByTestId('rt-edit-leg'); + fireEvent.change(field, { target: { value: '3' } }); + fireEvent.change(field, { target: { value: '300' } }); + expect(saveRoadtripLimit).not.toHaveBeenCalled(); + fireEvent.blur(field); + expect(saveRoadtripLimit).toHaveBeenCalledTimes(1); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_leg_minutes', 300); + }); + + it('FE-MOB-RTEDIT-004: an emptied field switches the limit off, an unchanged one saves nothing', () => { + const { saveRoadtripLimit } = renderSheet(); + typeInto('rt-edit-day', '480'); + expect(saveRoadtripLimit).not.toHaveBeenCalled(); + typeInto('rt-edit-day', ''); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_minutes', 0); + }); + + it('FE-MOB-RTEDIT-005: the fill is capped at a full tank', () => { + const { saveRoadtripLimit } = renderSheet(); + typeInto('rt-edit-fill', '140'); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_fill_percent', 100); + }); + + it('FE-MOB-RTEDIT-006: an imperial traveller types miles and kilometres are stored', () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } }); + const { saveRoadtripLimit } = renderSheet(); + expect(screen.getByTestId('rt-edit-range')).toHaveValue('373'); + typeInto('rt-edit-range', '310'); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_range_km', 499); + }); + + it('FE-MOB-RTEDIT-007: a range worked out from the battery is read-only here', () => { + renderSheet({ + roadtrip_vehicle: 'electric', + roadtrip_battery_kwh: 77, + roadtrip_kwh_per_100: 18, + roadtrip_battery_degradation: 8, + }); + expect(screen.getByTestId('rt-edit-range')).toBeDisabled(); + expect(screen.getByTestId('rt-edit-range')).toHaveValue('394'); + }); + + it('FE-MOB-RTEDIT-008: the vehicle is chosen in the form, none stored as empty', () => { + const { saveRoadtripLimit } = renderSheet(); + fireEvent.click(screen.getByRole('tab', { name: 'Electric' })); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', 'electric'); + fireEvent.click(screen.getByRole('tab', { name: 'Either' })); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', ''); + }); + + it('FE-MOB-RTEDIT-009: the avoidance is one stored list that each switch adds to or takes from', () => { + const { saveRoadtripLimit } = renderSheet(); + fireEvent.click(screen.getByRole('switch', { name: 'Ferries' })); + expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', 'toll,ferry'); + fireEvent.click(screen.getByRole('switch', { name: 'Toll roads' })); + expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', ''); + }); + + it('FE-MOB-RTEDIT-010: where the day ends is a choice between the route and a stop', () => { + const { saveRoadtripLimit } = renderSheet(); + fireEvent.click(screen.getByRole('tab', { name: 'At the last place' })); + expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_end_mode', 'stop'); + }); + + it('FE-MOB-RTEDIT-011: a reader without a save sees the figures, every field disabled', () => { + renderSheet(TYPED, 'split', { saveRoadtripLimit: undefined }); + for (const id of ['rt-edit-leg', 'rt-edit-day', 'rt-edit-range', 'rt-edit-fill']) { + expect(screen.getByTestId(id)).toBeDisabled(); + } + expect(screen.getByRole('switch', { name: 'Toll roads' })).toBeDisabled(); + }); + + it('FE-MOB-RTEDIT-012: a decimal comma is read and rounded, junk switches the limit off', () => { + const { saveRoadtripLimit } = renderSheet(); + typeInto('rt-edit-leg', '245,6'); + expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_leg_minutes', 246); + typeInto('rt-edit-day', '.'); + expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_day_minutes', 0); + }); +});