From 1d9b9bf83878f8126e287b7ff41aa7508cd41217 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 12:58:19 +0200 Subject: [PATCH] fix(tours): read a run pace the way runners write it, and keep it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pace typed as "5.30" or "5,45" is now read as 5:30 and 5:45, the way it is written in much of Europe, instead of quietly becoming 5:18 and 5:27. One digit after the point stays a tenth of a minute ("5,5" is 5:30); anything else is marked as no pace. The pace field no longer flashes the out-of-bounds error while a pace is still being typed: the first digit of "12:30" reads as 1:00, so a pace outside the bounds now reaches the planner only on leaving the field or pressing Enter, like text that is no pace already did. The pace unit is translated ("мин/км", "分/km" and so on) in the field, the sum the running time is and the pace pills, so what is shown matches what assistive tech reads out. A run planned over in place again keeps the pace it holds, so the runner need not type it again before Save. --- .../src/components/Tours/TourDayRowFacts.tsx | 4 +-- .../conversion/TourConversionSection.test.tsx | 26 +++++++++++++- .../Tours/conversion/useGpxConversion.ts | 7 ++-- .../Tours/planner/useTourPlanner.run.test.tsx | 35 +++++++++++++++++-- .../Tours/planner/useTourPlanner.ts | 5 ++- .../components/Tours/run/RunFields.test.tsx | 18 ++++++++-- client/src/components/Tours/run/RunFields.tsx | 28 +++++++++------ .../src/components/Tours/run/RunHeadPills.tsx | 2 +- .../components/Tours/run/runMarks.test.tsx | 10 +++--- .../src/components/Tours/run/runPace.test.ts | 10 ++++-- client/src/components/Tours/run/runPace.ts | 19 ++++++---- .../src/components/Tours/run/runPaceText.ts | 12 ++++--- shared/src/i18n/ar/tours.ts | 1 + shared/src/i18n/az/tours.ts | 1 + shared/src/i18n/br/tours.ts | 1 + shared/src/i18n/ca/tours.ts | 1 + shared/src/i18n/cs/tours.ts | 1 + shared/src/i18n/de/tours.ts | 1 + shared/src/i18n/en/tours.ts | 1 + shared/src/i18n/es/tours.ts | 1 + shared/src/i18n/et/tours.ts | 1 + shared/src/i18n/fr/tours.ts | 1 + shared/src/i18n/gr/tours.ts | 1 + shared/src/i18n/hu/tours.ts | 1 + shared/src/i18n/id/tours.ts | 1 + shared/src/i18n/it/tours.ts | 1 + shared/src/i18n/ja/tours.ts | 1 + shared/src/i18n/ko/tours.ts | 1 + shared/src/i18n/nl/tours.ts | 1 + shared/src/i18n/pl/tours.ts | 1 + shared/src/i18n/ru/tours.ts | 1 + shared/src/i18n/sk/tours.ts | 1 + shared/src/i18n/sv/tours.ts | 1 + shared/src/i18n/th/tours.ts | 1 + shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 1 + shared/src/i18n/uk/tours.ts | 1 + shared/src/i18n/vi/tours.ts | 1 + shared/src/i18n/zh-TW/tours.ts | 1 + shared/src/i18n/zh/tours.ts | 1 + 40 files changed, 164 insertions(+), 41 deletions(-) diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 1095a483c..9589a7dfa 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -198,7 +198,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); + const pace = runPaceText(tour, t); const timeParts = [ walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null, // RUN-01: the pace the running time is planned at. @@ -269,7 +269,7 @@ export default function TourDayRowFacts({ movingTimeLabel(tour, t('tours.planner.inspector.duration'), t), 'trip-plan-tour-walking-time' ); - const pace = runPaceText(tour); + const pace = runPaceText(tour, t); 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/conversion/TourConversionSection.test.tsx b/client/src/components/Tours/conversion/TourConversionSection.test.tsx index 6454778c7..76e508485 100644 --- a/client/src/components/Tours/conversion/TourConversionSection.test.tsx +++ b/client/src/components/Tours/conversion/TourConversionSection.test.tsx @@ -187,10 +187,34 @@ describe('useGpxConversion', () => { result.current.adopt('new'); }); const draft = vi.mocked(controller.startDerivedTour).mock.calls[0]?.[0]; - expect(draft).toMatchObject({ tourType: 'run', runProfile: 'running' }); + expect(draft).toMatchObject({ tourType: 'run', runProfile: 'running', runPaceSecondsPerKm: null }); expect(draft).not.toHaveProperty('paddle'); }); + it('RUN-01: a run planned over in place again keeps the pace it holds', async () => { + const run = { + ...imported, + tour_type: 'run' as const, + run_profile: 'running' as const, + run_pace_seconds_per_km: 330, + max_hiking_difficulty: null, + }; + const controller = planner({ + mode: { type: 'view-gpx', placeId: run.place_id, tour: run }, + readOnlyGpxTour: { tour: run, routeGeometry: null }, + } as Partial); + const { result } = renderHook(() => useGpxConversion(controller)); + await act(async () => { + await result.current.analyze(); + }); + act(() => { + result.current.adopt('replace'); + }); + expect(controller.startDerivedTour).toHaveBeenCalledWith( + expect.objectContaining({ inPlace: true, tourType: 'run', runProfile: 'running', runPaceSecondsPerKm: 330 }) + ); + }); + it('PR1-05B: nothing is adopted before the suggestion is ready', () => { const controller = planner(); const { result } = renderHook(() => useGpxConversion(controller)); diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts index 279e6b9ec..0bad79853 100644 --- a/client/src/components/Tours/conversion/useGpxConversion.ts +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -44,7 +44,8 @@ export function useGpxConversion(planner: TourPlannerController) { const paddle = tour?.tour_type === 'kayak'; // BIK-01: a bike import is planned for its profile. const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null; - // RUN-01: a run import is planned as a run for its profile; its pace is the runner's to enter. + // RUN-01: a run import is planned as a run for its profile, at the pace it holds; a fresh + // import holds none, so its pace is the runner's to enter. const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null; useEffect(() => () => abort.current?.abort(), []); @@ -111,7 +112,9 @@ export function useGpxConversion(planner: TourPlannerController) { revision: tour.revision, ...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}), ...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}), - ...(runProfile ? { tourType: 'run' as const, runProfile } : {}), + ...(runProfile + ? { tourType: 'run' as const, runProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null } + : {}), }); if (started) setState(IDLE); return started; diff --git a/client/src/components/Tours/planner/useTourPlanner.run.test.tsx b/client/src/components/Tours/planner/useTourPlanner.run.test.tsx index 502c81b4d..59b881282 100644 --- a/client/src/components/Tours/planner/useTourPlanner.run.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.run.test.tsx @@ -14,9 +14,9 @@ vi.mock('./tourRouting', () => ({ routeWalkingTour, routeRun, enrichTourElevatio vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) })); -import { useTourPlanner } from './useTourPlanner'; +import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner'; -// FE-TOUR-RUN-001 to FE-TOUR-RUN-007 +// FE-TOUR-RUN-001 to FE-TOUR-RUN-008 const routed = { coordinates: [ @@ -211,4 +211,35 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => { expect(result.current.tourType).toBe('run'); expect(result.current.runProfile).toBe('running'); }); + + it('FE-TOUR-RUN-008: a run planned over keeps the pace it holds, and a draft without one starts with none', async () => { + const { result } = renderHook(() => useTourPlanner({ tripId: 78 })); + const draft: DerivedTourDraft = { + name: 'River loop', + difficulty: 1, + points: [ + { lat: 48, lng: 11 }, + { lat: 48.01, lng: 11.02 }, + ], + sourcePlaceId: 91, + inPlace: true, + revision: 3, + tourType: 'run', + runProfile: 'running', + }; + act(() => { + result.current.runPace.load(400); + result.current.startDerivedTour({ ...draft, runPaceSecondsPerKm: 330 }); + }); + await settle(); + expect(result.current.tourType).toBe('run'); + expect(result.current.runPace.paceSecondsPerKm).toBe(330); + expect(result.current.runPaceMissing).toBe(false); + expect(result.current.canSave).toBe(true); + act(() => { + result.current.startDerivedTour(draft); + }); + expect(result.current.runPace.paceSecondsPerKm).toBeNull(); + expect(result.current.runPaceMissing).toBe(true); + }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 3518bfb84..f6612207a 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -43,6 +43,8 @@ export interface DerivedTourDraft { paddle?: PaddleFieldValues bikeProfile?: BikeProfile runProfile?: RunProfile + /** RUN-01: a run planned over keeps the pace it was planned at, so the runner need not type it again. */ + runPaceSecondsPerKm?: number | null /** CW-07: where the points came from, said in the planner until the draft is saved. */ source?: DraftSource } @@ -639,6 +641,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe runProfileRef.current = draft.runProfile setRunProfileState(draft.runProfile) } + loadRunPace(draft.runPaceSecondsPerKm ?? null) setNameState(draft.name) setDescriptionState(draft.description ?? '') setWebsiteState(draft.website ?? '') @@ -658,7 +661,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setHasUnsavedChanges(true) return true }, - [adoptRevision, loadPaddle, returnToNeutral]) + [adoptRevision, loadPaddle, loadRunPace, returnToNeutral]) const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), []) const toggleElevationProfile = useCallback(() => { diff --git a/client/src/components/Tours/run/RunFields.test.tsx b/client/src/components/Tours/run/RunFields.test.tsx index 887c6d47d..4e264375d 100644 --- a/client/src/components/Tours/run/RunFields.test.tsx +++ b/client/src/components/Tours/run/RunFields.test.tsx @@ -55,7 +55,7 @@ describe('RunFields (RUN-01)', () => { render(); expect(paceField()).toHaveValue(''); expect(paceField()).toHaveAttribute('placeholder', '0:00'); - expect(screen.getByText('min/km')).toBeInTheDocument(); + expect(screen.getByText('tours.run.paceUnit')).toBeInTheDocument(); expect(screen.getByRole('status')).toHaveTextContent('tours.run.paceNeeded'); expect(screen.queryByTestId('tour-run-time-sum')).toBeNull(); expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription( @@ -70,7 +70,7 @@ describe('RunFields (RUN-01)', () => { expect(screen.getByTestId('pace')).toHaveTextContent('360'); const sum = screen.getByTestId('tour-run-time-sum'); expect(sum).toHaveTextContent('8 km'); - expect(sum).toHaveTextContent('6:00 /km'); + expect(sum).toHaveTextContent('6:00 tours.run.paceUnit'); expect(sum).toHaveTextContent('48 min'); expect(sum).toHaveAttribute('aria-label', 'tours.run.timeSum'); expect(screen.queryByRole('status')).toBeNull(); @@ -99,11 +99,23 @@ describe('RunFields (RUN-01)', () => { expect(screen.queryByRole('alert')).toBeNull(); }); - it('FE-TOUR-RUN-FIELDS-005: a pace out of bounds is marked, and clearing the field leaves no pace', () => { + it('FE-TOUR-RUN-FIELDS-005: a pace out of bounds is marked on leaving the field, not while typed, and clearing it leaves no pace', () => { render(); fireEvent.focus(paceField()); + // The first digit of "12:30" reads as 1:00, under the fastest pace: no error while typing on. + fireEvent.change(paceField(), { target: { value: '1' } }); + expect(screen.getByTestId('pace')).toHaveTextContent('none'); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.change(paceField(), { target: { value: '12:30' } }); + expect(screen.getByTestId('pace')).toHaveTextContent('750'); fireEvent.change(paceField(), { target: { value: '1:30' } }); + expect(screen.getByTestId('pace')).toHaveTextContent('750'); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.blur(paceField()); + expect(screen.getByTestId('pace')).toHaveTextContent('90'); + expect(paceField()).toHaveValue('1:30'); expect(screen.getByRole('alert')).toHaveTextContent('tours.run.paceInvalid'); + fireEvent.focus(paceField()); fireEvent.change(paceField(), { target: { value: '' } }); fireEvent.keyDown(paceField(), { key: 'Enter' }); expect(screen.getByTestId('pace')).toHaveTextContent('none'); diff --git a/client/src/components/Tours/run/RunFields.tsx b/client/src/components/Tours/run/RunFields.tsx index 9ce883e06..04bec779a 100644 --- a/client/src/components/Tours/run/RunFields.tsx +++ b/client/src/components/Tours/run/RunFields.tsx @@ -14,7 +14,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 } from './runPace'; +import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid } from './runPace'; const PROFILE_ICON: Record = { running: Activity }; @@ -55,12 +55,15 @@ type RunPlanner = Pick< > & { walkingDurationMinutes: number | null }; /** - * The pace as min:s per km. A readable pace reaches the planner while it is - * typed; text that is no pace reaches it as NaN on leaving the field, so the - * planner marks it and holds Save back, and the text stays for the runner to - * fix. The field never offers a pace of its own: the placeholder is the format. + * The pace as min:s per km. 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 + * marks either and holds Save back, and the text stays for the runner to fix. + * The field never offers a pace of its own: the placeholder is the format. */ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolean }) { + const { t } = useTranslation(); const [draft, setDraft] = useState(null); const [focused, setFocused] = useState(false); const { paceSecondsPerKm: pace, setPace } = planner.runPace; @@ -86,12 +89,14 @@ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolea onChange={(event) => { setDraft(event.target.value); const parsed = parseRunPace(event.target.value); - if (parsed === null || !Number.isNaN(parsed)) setPace(parsed); + if (!runPaceInvalid(parsed)) setPace(parsed); }} onBlur={() => { setFocused(false); - if (draft !== null && Number.isNaN(parseRunPace(draft))) setPace(Number.NaN); - else setDraft(null); + if (draft === null) return; + const parsed = parseRunPace(draft); + setPace(parsed); + if (!Number.isNaN(parsed)) setDraft(null); }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur(); @@ -105,7 +110,7 @@ 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')} > - min/km + {t('tours.run.paceUnit')} ); @@ -137,7 +142,10 @@ function RunTimeSum({ planner }: { planner: RunPlanner }) { -