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 (