fix(tours): time a run's stages at its pace and leave a ride's stages untimed

A multi-day run's stages took a share of the foot router's time while the
run itself is timed at the runner's pace, so the stages did not add up to
the run. A ride has no time at all, so its stages have none either.
This commit is contained in:
Maurice
2026-10-20 16:38:42 +02:00
parent f4750352e6
commit ae726ed370
6 changed files with 72 additions and 12 deletions
@@ -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))
);
});
});
@@ -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,
})
@@ -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 }));
}
@@ -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> = {}): 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));
@@ -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);
}
@@ -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(