diff --git a/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx b/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx index 4f76d437c..ab11756ca 100644 --- a/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx @@ -1,4 +1,5 @@ import { act, renderHook } from '@testing-library/react'; +import { runMinutesAtPace } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan } = vi.hoisted(() => ({ @@ -23,7 +24,7 @@ vi.mock('../useTourPermissions', () => ({ import { validDraft } from './tourPlannerDraft'; import { useTourPlanner } from './useTourPlanner'; -// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-009 (MDT-01) +// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-010 (MDT-01) const POINTS: Array<[number, number]> = [ [48, 11], @@ -271,4 +272,19 @@ describe('useTourPlanner, multi-day tours (MDT-01)', () => { expect(result.current.stages.lost).toBe(0); expect(result.current.waypoints.find((point) => point.id === stop)?.stageEnd).toBe(true); }); + + it('FE-TOUR-STAGES-010: a run times every stage at the runner pace, as the whole run is timed, never by the router (RUN-01)', async () => { + const { result } = await plannedHike(71); + act(() => result.current.setTourType('run')); + await settle(); + act(() => result.current.stages.toggleStageEnd(result.current.waypoints[1].id)); + // Without a pace the run has no time, and neither have its stages. + expect(result.current.stages.stages.map((stage) => stage.minutes)).toEqual([null, null]); + act(() => result.current.runPace.setPace(360)); + const stages = result.current.stages.stages; + expect(stages.every((stage) => (stage.distanceKm ?? 0) > 0)).toBe(true); + expect(stages.map((stage) => stage.minutes)).toEqual( + stages.map((stage) => runMinutesAtPace(stage.distanceKm ?? 0, 360)) + ); + }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 22b491269..02842b608 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -552,7 +552,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe : null, [effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths]) const stages = useTourStages({ waypoints, line: routeCurrent ? (enrichedGeometry ?? route) : null, tourType, routerSeconds: durationSeconds, - paddle: stagePaddle, commit: commitWaypoints, mayEdit: mayEditPoints, + paddle: stagePaddle, runPaceSecondsPerKm, commit: commitWaypoints, mayEdit: mayEditPoints, draftKey: `${editingPlaceId ?? 'new'}:${newDraftGeneration}`, lost: stageLoss, }) diff --git a/client/src/components/Tours/stages/savedTourStages.ts b/client/src/components/Tours/stages/savedTourStages.ts index a3aa0d8af..15b227be4 100644 --- a/client/src/components/Tours/stages/savedTourStages.ts +++ b/client/src/components/Tours/stages/savedTourStages.ts @@ -50,6 +50,7 @@ export function savedTourStagesOf( tourType: openedTourFields(tour).tourType, routerSeconds: tour.duration == null ? null : tour.duration * 60, paddle: line ? paddleInputsOf(tour, points, line) : null, + runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null, }); return stages.map((stage) => ({ ...stage, key: points[stage.from]?.key ?? null })); } diff --git a/client/src/components/Tours/stages/tourStages.test.ts b/client/src/components/Tours/stages/tourStages.test.ts index 329905ae1..df3785d80 100644 --- a/client/src/components/Tours/stages/tourStages.test.ts +++ b/client/src/components/Tours/stages/tourStages.test.ts @@ -1,5 +1,5 @@ -// FE-TOUR-STAGE-MODEL-001 to FE-TOUR-STAGE-MODEL-012: the stages of a multi-day tour, cut from its one route (MDT-01, MDT-02). -import { MAX_TOUR_STAGES } from '@trek/shared'; +// FE-TOUR-STAGE-MODEL-001 to FE-TOUR-STAGE-MODEL-013: the stages of a multi-day tour, cut from its one route (MDT-01, MDT-02). +import { MAX_TOUR_STAGES, runMinutesAtPace } from '@trek/shared'; import { describe, expect, it } from 'vitest'; import { hikingTimeMinutes } from '../hikingTime'; import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; @@ -32,7 +32,15 @@ const withEnds = (...ids: string[]) => FIVE.map((p) => (ids.includes(p.id) ? { . const LINE = FIVE.map((p, index): [number, number, number] => [p.lat, p.lng, [500, 600, 550, 650, 600][index]]); function input(points: TourPlannerWaypoint[], extra: Partial = {}): TourStageInputs { - return { points, line: LINE, tourType: 'hike', routerSeconds: 4 * 3600, paddle: null, ...extra }; + return { + points, + line: LINE, + tourType: 'hike', + routerSeconds: 4 * 3600, + paddle: null, + runPaceSecondsPerKm: null, + ...extra, + }; } describe('tourStages', () => { @@ -122,6 +130,21 @@ describe('tourStages', () => { expect(unknown.map((stage) => stage.minutes)).toEqual([null, null]); }); + it('FE-TOUR-STAGE-MODEL-013: a run stage is its distance at the pace, a ride on horseback has no time (RUN-01, HOR-01)', () => { + const run = tourStagesOf(input(withEnds('b'), { tourType: 'run', runPaceSecondsPerKm: 360 })); + expect(run.map((stage) => stage.minutes)).toEqual( + run.map((stage) => runMinutesAtPace(stage.distanceKm ?? 0, 360)) + ); + // The router's walking time never stands in, neither without a pace nor with one out of bounds. + const noPace = tourStagesOf(input(withEnds('b'), { tourType: 'run' })); + expect(noPace.map((stage) => stage.minutes)).toEqual([null, null]); + const outOfBounds = tourStagesOf(input(withEnds('b'), { tourType: 'run', runPaceSecondsPerKm: 5 })); + expect(outOfBounds.map((stage) => stage.minutes)).toEqual([null, null]); + const ride = tourStagesOf(input(withEnds('b'), { tourType: 'horse' })); + expect(ride.map((stage) => stage.minutes)).toEqual([null, null]); + expect(ride.every((stage) => stage.distanceKm !== null && !stage.overDay)).toBe(true); + }); + it('FE-TOUR-STAGE-MODEL-008: a paddle stage has no heights and is timed at the paddler speed, its lock passed once', () => { const lock = { wait_minutes: 10, transit_minutes: 5 }; const points = withEnds('c').map((p) => (p.id === 'c' ? { ...p, lock } : p)); diff --git a/client/src/components/Tours/stages/tourStages.ts b/client/src/components/Tours/stages/tourStages.ts index c6ec62eac..4845dcd8e 100644 --- a/client/src/components/Tours/stages/tourStages.ts +++ b/client/src/components/Tours/stages/tourStages.ts @@ -1,8 +1,14 @@ -import { MAX_PLANNED_TOUR_DURATION_MINUTES, MAX_TOUR_STAGES, type PlannableTourType } from '@trek/shared'; +import { + MAX_PLANNED_TOUR_DURATION_MINUTES, + MAX_TOUR_STAGES, + runMinutesAtPace, + type PlannableTourType, +} from '@trek/shared'; import { analyzeRouteGeometry } from '../../../utils/routeGeometry'; import { hikingTimeMinutes } from '../hikingTime'; import { paddleEstimateOf } from '../planner/paddlePlanOf'; import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { runPaceInvalid } from '../run/runPace'; /** * MDT-01: the stages of a multi-day tour, read off its points in route order. @@ -130,6 +136,8 @@ export interface TourStageInputs { routerSeconds: number | null; /** A paddle tour's speeds; null for any other type. */ paddle: PaddleStageInputs | null; + /** A run's pace in seconds per kilometre (RUN-01); null without one, and ignored for any other type. */ + runPaceSecondsPerKm: number | null; } const METRES_PER_DEGREE = 111_320; @@ -203,13 +211,21 @@ function paddleStageMinutes( } /** - * A stage's moving time by the tour's own rule: a paddle stage at the - * paddler's speed, a hike by the signposts' rule over its distance, climb and - * descent, and otherwise the router's time for the whole tour shared out by - * distance, which is how the router times a ride or a walk on the flat. + * A stage's moving time by the tour's own rule, the same rule the whole tour + * is timed by: a paddle stage at the paddler's speed, a run at the runner's + * pace over its distance, a hike by the signposts' rule over its distance, + * climb and descent, and otherwise the router's time for the whole tour + * shared out by distance, which is how the router times a ride or a walk on + * the flat. A ride on horseback (HOR-01) has no time, as the whole ride has + * none, and a run without a pace has none either. */ function stageMinutes(input: TourStageInputs, span: StageSpan, stretch: Stretch, totalKm: number): number | null { if (input.paddle) return paddleStageMinutes(input.points, span, input.paddle); + if (input.tourType === 'horse') return null; + if (input.tourType === 'run') { + const pace = input.runPaceSecondsPerKm; + return pace === null || runPaceInvalid(pace) ? null : runMinutesAtPace(stretch.distanceKm, pace); + } if (input.tourType === 'hike' && stretch.gain !== null && stretch.loss !== null) { return hikingTimeMinutes(stretch.distanceKm, stretch.gain, stretch.loss); } diff --git a/client/src/components/Tours/stages/useTourStages.ts b/client/src/components/Tours/stages/useTourStages.ts index c25b583f9..c19a10885 100644 --- a/client/src/components/Tours/stages/useTourStages.ts +++ b/client/src/components/Tours/stages/useTourStages.ts @@ -17,6 +17,8 @@ interface TourStagesOptions { tourType: PlannableTourType; routerSeconds: number | null; paddle: PaddleStageInputs | null; + /** A run's pace, seconds per kilometre; its stages are timed at it as the whole run is. */ + runPaceSecondsPerKm: number | null; /** The planner's own change of the points: one step of the history, the draft unsaved again. */ commit: (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void; /** Whether the points may change now: the viewer may edit and no save is on its way. */ @@ -41,6 +43,7 @@ export function useTourStages({ tourType, routerSeconds, paddle, + runPaceSecondsPerKm, commit, mayEdit, draftKey, @@ -50,8 +53,9 @@ export function useTourStages({ const endCount = stageEndCount(waypoints); const multiDay = endCount > 0 || chosenFor === draftKey; const stages = useMemo( - () => (multiDay ? tourStagesOf({ points: waypoints, line, tourType, routerSeconds, paddle }) : []), - [line, multiDay, paddle, routerSeconds, tourType, waypoints] + () => + multiDay ? tourStagesOf({ points: waypoints, line, tourType, routerSeconds, paddle, runPaceSecondsPerKm }) : [], + [line, multiDay, paddle, routerSeconds, runPaceSecondsPerKm, tourType, waypoints] ); const setMultiDay = useCallback(