feat(tours): estimate the walking time of imported tours

A hike imported from GPX, KML/KMZ or wanderer had no time at all, so
several hikes on one day could not be planned against each other. It now
gets the walking time the planner times a hike by (DIN 33466), worked out
from its own distance, ascent and descent; a run gets its distance at the
pace entered. Without heights in the track the time is unknown rather
than the time on the flat, and types timed by a router, by a speed of
their own or by nothing get no estimate.

The estimate is derived on every read and never stored, so tours imported
before get it too and no stored or entered time is touched. The rule moved
to @trek/shared, where the planner, the client and the server read it.
The tour list, the day rows on desktop and phone and the tour detail say
it as an estimate, apart from a planned total; list_tours and
get_tour_plan carry it as time_estimate and name it as derived.
This commit is contained in:
Maurice
2026-10-20 20:31:53 +02:00
parent e653733390
commit c38eb7ebf0
52 changed files with 617 additions and 65 deletions
@@ -0,0 +1,80 @@
import {
hikingTimeMinutes,
tourTimeEstimate,
tourTimeEstimateSchema,
type TourTimeEstimateSource,
} from './tour-time-estimate';
import { describe, expect, it } from 'vitest';
// FE-TOURS-HIKINGTIME-001 to -003 moved here with the rule; TIME-01-001 on: the estimate of a tour without a planned time.
describe('hikingTimeMinutes (DIN 33466)', () => {
it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => {
expect(hikingTimeMinutes(8, 0, 0)).toBe(120);
});
it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => {
// 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance.
expect(hikingTimeMinutes(2, 450, 0)).toBe(105);
});
it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => {
// 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height.
expect(hikingTimeMinutes(12, 300, 500)).toBe(240);
});
});
const imported = (over: Partial<TourTimeEstimateSource> = {}): TourTimeEstimateSource => ({
tour_type: 'hike',
distance: 12,
elevation_gain: 300,
elevation_loss: 500,
duration: null,
run_pace_seconds_per_km: null,
...over,
});
describe('tourTimeEstimate (TIME-01)', () => {
it('TIME-01-001: an imported hike is estimated by the planner rule from its own climb and descent', () => {
expect(tourTimeEstimate(imported())).toEqual({ minutes: 240, basis: 'din33466' });
});
it('TIME-01-002: a hike without heights is unknown, never timed as if it were flat', () => {
expect(tourTimeEstimate(imported({ elevation_gain: null, elevation_loss: null }))).toEqual({
minutes: null,
basis: 'din33466',
});
expect(tourTimeEstimate(imported({ elevation_loss: null }))?.minutes).toBeNull();
});
it('TIME-01-003: a tour the planner timed keeps its own time and is not estimated', () => {
expect(tourTimeEstimate(imported({ duration: 200 }))).toBeNull();
expect(tourTimeEstimate(imported({ duration: 0 }))).toBeNull();
});
it('TIME-01-004: a run is estimated at the pace its runners entered, and without one not at all', () => {
expect(tourTimeEstimate(imported({ tour_type: 'run', distance: 8, run_pace_seconds_per_km: 360 }))).toEqual({
minutes: 48,
basis: 'run_pace',
});
expect(tourTimeEstimate(imported({ tour_type: 'run', run_pace_seconds_per_km: null }))).toBeNull();
expect(tourTimeEstimate(imported({ tour_type: 'run', run_pace_seconds_per_km: undefined }))).toBeNull();
});
it('TIME-01-005: types timed by a router, a speed of their own or nothing get no estimate', () => {
for (const type of ['bike', 'walk', 'kayak', 'ski', 'horse', 'food']) {
expect(tourTimeEstimate(imported({ tour_type: type, run_pace_seconds_per_km: 360 }))).toBeNull();
}
});
it('TIME-01-006: without a distance there is nothing to estimate', () => {
expect(tourTimeEstimate(imported({ distance: null }))).toBeNull();
});
it('TIME-01-007: the wire shape takes whole minutes or null and a known basis only', () => {
expect(tourTimeEstimateSchema.safeParse({ minutes: 240, basis: 'din33466' }).success).toBe(true);
expect(tourTimeEstimateSchema.safeParse({ minutes: null, basis: 'run_pace' }).success).toBe(true);
expect(tourTimeEstimateSchema.safeParse({ minutes: 1.5, basis: 'din33466' }).success).toBe(false);
expect(tourTimeEstimateSchema.safeParse({ minutes: 10, basis: 'router' }).success).toBe(false);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { runMinutesAtPace } from './run-pace';
import { z } from 'zod';
/**
* Walking time the way hiking signposts in the Alps state it (DIN 33466, as the
* DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of
* climb and 500 m of descent an hour, and of the time for the distance and the
* time for the height only the larger counts in full, the smaller by half.
*
* The planner times a hike by it, and an imported hike is estimated by it
* (TIME-01), so both come from this one rule.
*/
const FLAT_KM_PER_HOUR = 4;
const CLIMB_M_PER_HOUR = 300;
const DESCENT_M_PER_HOUR = 500;
export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number {
const flat = distanceKm / FLAT_KM_PER_HOUR;
const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR;
const hours = Math.max(flat, height) + Math.min(flat, height) / 2;
return Math.round(hours * 60);
}
/**
* TIME-01: what an estimate is worked out by. `din33466`: a hike's distance,
* climb and descent by the signposts' rule; `run_pace`: a run's distance at
* the pace its runners entered.
*/
export const tourTimeEstimateBasisSchema = z.enum(['din33466', 'run_pace']);
export type TourTimeEstimateBasis = z.infer<typeof tourTimeEstimateBasisSchema>;
/**
* TIME-01: the time a tour without a planned time is estimated to take, in
* minutes and without breaks, worked out whenever it is read and never
* stored. `minutes` is null when the rule needs heights the track does not
* carry: then the time is unknown, never the time on the flat.
*/
export const tourTimeEstimateSchema = z.object({
minutes: z.number().int().nonnegative().nullable(),
basis: tourTimeEstimateBasisSchema,
});
export type TourTimeEstimate = z.infer<typeof tourTimeEstimateSchema>;
/** What an estimate is read from: the tour's type, its measures, its planned time and a run's pace. */
export interface TourTimeEstimateSource {
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
/** The time the planner worked out, in minutes; a tour that has one is not estimated. */
duration: number | null;
run_pace_seconds_per_km?: number | null;
}
/**
* TIME-01: the estimate of a tour that has no time of its own (an imported
* track, which the planner never timed), or null where there is nothing to
* estimate it by. Only the rules the planner times these types by are used:
* a hike by DIN 33466 from its own climb and descent, a run by the pace its
* runners entered. A bike tour, a city walk and a paddle tour are timed by a
* router or by a speed of their own, a ride and a ski tour by none, so none
* of them is estimated here.
*/
export function tourTimeEstimate(tour: TourTimeEstimateSource): TourTimeEstimate | null {
if (tour.duration != null || tour.distance == null) return null;
if (tour.tour_type === 'hike') {
const known = tour.elevation_gain != null && tour.elevation_loss != null;
return {
minutes: known ? hikingTimeMinutes(tour.distance, tour.elevation_gain ?? 0, tour.elevation_loss ?? 0) : null,
basis: 'din33466',
};
}
const pace = tour.run_pace_seconds_per_km ?? null;
if (tour.tour_type === 'run' && pace !== null) {
return { minutes: runMinutesAtPace(tour.distance, pace), basis: 'run_pace' };
}
return null;
}
+9
View File
@@ -19,6 +19,7 @@ import { tourPackingLinkSchema } from './tour-packing-link.schema';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
import { maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages';
import { tourTimeEstimateSchema } from './tour-time-estimate';
import { z } from 'zod';
@@ -310,6 +311,14 @@ export const tourListItemSchema = tourSchema.extend({
*/
track_started_at: z.string().nullable().optional(),
track_ended_at: z.string().nullable().optional(),
/**
* TIME-01: the time a tour without a planned time (an import) is estimated
* to take, worked out on reading by `tourTimeEstimate` and never stored;
* null where nothing estimates it. Its minutes are null when the track has
* no heights. Apart from the times recorded in the file and from a planned
* total the travellers enter. Absent in a list cached before it existed.
*/
time_estimate: tourTimeEstimateSchema.nullable().optional(),
/** ACT-01: the recordings assigned to this tour, one per execution. Absent in a list cached before it existed. */
activity_tracks: z.array(tourActivityRefSchema).optional(),
/** ACT-01: the tour this recording documents, when it is assigned to one; null otherwise. */