From a249c861e670d1c44bf0fec53666bae48a78f2a5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 16:51:34 +0200 Subject: [PATCH] fix(tours): read and write a run's pace in the user's distance unit The pace was always min/km and the run's time sum forced kilometres, so with distances in miles the run card disagreed with every other figure in the planner. The pace is still kept per km, but is typed, shown and checked per mile when distances are in miles, and the time sum reads in that unit too. --- .../src/components/Tours/TourDayRowFacts.tsx | 4 +- .../collection/CollectionToursSection.tsx | 4 +- .../components/Tours/run/RunFields.test.tsx | 31 ++++++++++-- client/src/components/Tours/run/RunFields.tsx | 48 +++++++++++-------- .../src/components/Tours/run/RunHeadPills.tsx | 4 +- .../src/components/Tours/run/runPace.test.ts | 21 +++++++- client/src/components/Tours/run/runPace.ts | 48 ++++++++++++------- .../src/components/Tours/run/runPaceText.ts | 14 +++--- shared/src/i18n/ar/tours.ts | 4 +- shared/src/i18n/az/tours.ts | 4 +- shared/src/i18n/br/tours.ts | 4 +- shared/src/i18n/ca/tours.ts | 4 +- shared/src/i18n/cs/tours.ts | 4 +- shared/src/i18n/de/tours.ts | 4 +- shared/src/i18n/en/tours.ts | 4 +- shared/src/i18n/es/tours.ts | 4 +- shared/src/i18n/et/tours.ts | 4 +- shared/src/i18n/fr/tours.ts | 4 +- shared/src/i18n/gr/tours.ts | 4 +- shared/src/i18n/hu/tours.ts | 4 +- shared/src/i18n/id/tours.ts | 4 +- shared/src/i18n/it/tours.ts | 4 +- shared/src/i18n/ja/tours.ts | 4 +- shared/src/i18n/ko/tours.ts | 4 +- shared/src/i18n/nl/tours.ts | 4 +- shared/src/i18n/pl/tours.ts | 4 +- shared/src/i18n/ru/tours.ts | 4 +- shared/src/i18n/sk/tours.ts | 4 +- shared/src/i18n/sv/tours.ts | 4 +- shared/src/i18n/th/tours.ts | 4 +- shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 4 +- shared/src/i18n/uk/tours.ts | 4 +- shared/src/i18n/vi/tours.ts | 4 +- shared/src/i18n/zh-TW/tours.ts | 4 +- shared/src/i18n/zh/tours.ts | 4 +- 36 files changed, 206 insertions(+), 78 deletions(-) diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 3fae230c3..a41a1b69e 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -204,7 +204,7 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes); const total = plannedTotalText(times, t); const staysText = times.walk ? walkStaysText(times.walk, t) : null; - const pace = runPaceText(tour, t); + const pace = runPaceText(tour, t, unit); const timeParts = [ walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null, // RUN-01: the pace the running time is planned at. @@ -275,7 +275,7 @@ export default function TourDayRowFacts({ movingTimeLabel(tour, t('tours.planner.inspector.duration'), t), 'trip-plan-tour-walking-time' ); - const pace = runPaceText(tour, t); + const pace = runPaceText(tour, t, unit); if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace'); if (times.breakMinutes != null && times.breakMinutes > 0) add( diff --git a/client/src/components/Tours/collection/CollectionToursSection.tsx b/client/src/components/Tours/collection/CollectionToursSection.tsx index 1231c0a15..7548fa16c 100644 --- a/client/src/components/Tours/collection/CollectionToursSection.tsx +++ b/client/src/components/Tours/collection/CollectionToursSection.tsx @@ -1,6 +1,7 @@ import type { CollectionTourItem } from '@trek/shared'; import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react'; import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; import { WhiteBadge } from '../../Planner/planParts'; import ConfirmDialog from '../../shared/ConfirmDialog'; import { fs } from '../../shared/DialogShell'; @@ -24,7 +25,8 @@ function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record state.settings.distance_unit) ?? 'metric'; + const pace = runPaceText(tour, t, unit); const stages = tour.stage_count ? t('tours.stages.count', { count: tour.stage_count }) : null; const icon = 'flex-none text-content-faint'; return ( diff --git a/client/src/components/Tours/run/RunFields.test.tsx b/client/src/components/Tours/run/RunFields.test.tsx index 0c1d073e5..64ac96fbe 100644 --- a/client/src/components/Tours/run/RunFields.test.tsx +++ b/client/src/components/Tours/run/RunFields.test.tsx @@ -1,10 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { useState, type ComponentProps } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { useSettingsStore } from '../../../store/settingsStore'; import { RunFields, RunProfileField } from './RunFields'; import { runPaceInvalid } from './runPace'; -// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-008 +// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-009 type Planner = ComponentProps['planner']; @@ -42,6 +44,8 @@ function Stateful({ const paceField = () => screen.getByRole('textbox'); +afterEach(() => resetAllStores()); + describe('RunFields (RUN-01)', () => { it('FE-TOUR-RUN-FIELDS-001: offers the profile on purpose, with what it asks the router for', () => { const onChange = vi.fn(); @@ -142,10 +146,31 @@ describe('RunFields (RUN-01)', () => { it('FE-TOUR-RUN-FIELDS-008: a trail run is timed as any run, distance times pace, nothing added for the terrain (RUN-02)', () => { render(); const sum = screen.getByTestId('tour-run-time-sum'); - expect(sum).toHaveTextContent('7:30 /km'); + expect(sum).toHaveTextContent('7:30 tours.run.paceUnit'); expect(sum).toHaveTextContent('1 h'); expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription( 'tours.run.paceInfo' ); }); + + it('FE-TOUR-RUN-FIELDS-009: with distances in miles the pace is typed and read per mile, kept per km, and the sum in miles', () => { + seedStore(useSettingsStore, { settings: { distance_unit: 'imperial' } }); + render(); + expect(screen.getByText('tours.run.paceUnitMi')).toBeInTheDocument(); + fireEvent.focus(paceField()); + // 9:39 a mile is 6:00 a kilometre, the way it is kept. + fireEvent.change(paceField(), { target: { value: '9:39' } }); + expect(screen.getByTestId('pace')).toHaveTextContent('360'); + fireEvent.blur(paceField()); + expect(paceField()).toHaveValue('9:39'); + const sum = screen.getByTestId('tour-run-time-sum'); + expect(sum).toHaveTextContent('5 mi'); + expect(sum).not.toHaveTextContent('km'); + expect(sum).toHaveTextContent('9:39 tours.run.paceUnitMi'); + expect(sum).toHaveTextContent('48 min'); + fireEvent.focus(paceField()); + fireEvent.change(paceField(), { target: { value: '1:30' } }); + fireEvent.blur(paceField()); + expect(screen.getByRole('alert')).toHaveTextContent('tours.run.paceInvalidMi'); + }); }); diff --git a/client/src/components/Tours/run/RunFields.tsx b/client/src/components/Tours/run/RunFields.tsx index 7ba1b075b..2f725acf4 100644 --- a/client/src/components/Tours/run/RunFields.tsx +++ b/client/src/components/Tours/run/RunFields.tsx @@ -7,6 +7,8 @@ import { import { Clock, Gauge, Info, Ruler } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import type { DistanceUnit } from '../../../types'; import { formatDistance } from '../../../utils/units'; import { INPUT, LABEL, Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; @@ -14,7 +16,7 @@ import { FigureBadge } from '../paddle/FigureBadge'; import type { TourPlannerController } from '../planner/useTourPlanner'; import { InfoDot, TourNotice } from '../tourParts'; import { formatPlannedTourDuration } from '../tourPresentation'; -import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid } from './runPace'; +import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid, runPaceUnitText } from './runPace'; import { RUN_PROFILE_ICON } from './runProfileIcon'; /** @@ -53,8 +55,14 @@ type RunPlanner = Pick< 'runProfile' | 'setRunProfile' | 'runPace' | 'runPaceMissing' | 'runPaceInvalid' | 'routeAnalysis' > & { walkingDurationMinutes: number | null }; +/** The distance unit the user reads distances in, which a pace is typed and read in too. */ +function useDistanceUnit(): DistanceUnit { + return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; +} + /** - * The pace as min:s per km. A pace a run can be saved with reaches the planner + * The pace as min:s per km, or per mile with distances in miles; it is kept + * per km whatever the unit. A pace a run can be saved with reaches the planner * while it is typed. Anything else waits for leaving the field (or Enter), so * the first digit of "12:30" flashes no error: then a pace out of bounds * reaches the planner as it is and text that is no pace as NaN, the planner @@ -63,11 +71,12 @@ type RunPlanner = Pick< */ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolean }) { const { t } = useTranslation(); + const unit = useDistanceUnit(); const [draft, setDraft] = useState(null); const [focused, setFocused] = useState(false); const { paceSecondsPerKm: pace, setPace } = planner.runPace; const unreadable = pace !== null && Number.isNaN(pace); - const shown = pace === null || unreadable ? '' : formatRunPace(pace); + const shown = pace === null || unreadable ? '' : formatRunPace(pace, unit); const showDraft = draft !== null && (focused || unreadable); return (
@@ -87,13 +96,13 @@ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolea }} onChange={(event) => { setDraft(event.target.value); - const parsed = parseRunPace(event.target.value); + const parsed = parseRunPace(event.target.value, unit); if (!runPaceInvalid(parsed)) setPace(parsed); }} onBlur={() => { setFocused(false); if (draft === null) return; - const parsed = parseRunPace(draft); + const parsed = parseRunPace(draft, unit); setPace(parsed); if (!Number.isNaN(parsed)) setDraft(null); }} @@ -109,42 +118,41 @@ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolea className="pointer-events-none absolute inset-y-0 end-3 flex items-center text-content-faint" style={fs(12, 'body')} > - {t('tours.run.paceUnit')} + {runPaceUnitText(unit, t)}
); } /** - * The sum the running time is, laid out as badges: the distance in km, times - * the pace, is the time. Shown once all three are known, so the runner sees + * The sum the running time is, laid out as badges: the distance, times the + * pace, is the time, both in the unit the user reads distances in. Shown once all three are known, so the runner sees * where the time comes from and that nothing else goes into it. */ function RunTimeSum({ planner }: { planner: RunPlanner }) { const { t } = useTranslation(); + const unit = useDistanceUnit(); const distanceKm = planner.routeAnalysis?.distanceKm ?? null; const pace = planner.runPace.paceSecondsPerKm; const minutes = planner.walkingDurationMinutes; if (distanceKm === null || pace === null || planner.runPaceInvalid || minutes === null) return null; - // The pace is per km, so the sum reads in km whatever unit the app shows distances in. - const distance = formatDistance(distanceKm, 'metric'); + // The pace reads per km or per mile as the distances do, so the sum reads in that unit too. + const distance = formatDistance(distanceKm, unit); + const paceText = `${formatRunPace(pace, unit)} ${runPaceUnitText(unit, t)}`; const time = formatPlannedTourDuration(minutes); const operator = 'font-geist font-semibold text-content-faint'; return (