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
+8
View File
@@ -504,6 +504,12 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
max_hiking_difficulty: null,
has_waypoints: false,
});
// TIME-01: the walking time it is estimated to take, worked out from its own heights and stored nowhere.
expect(tour.duration).toBeNull();
expect(tour.time_estimate).toEqual({ minutes: expect.any(Number), basis: 'din33466' });
const listed = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
const again = listed.body.tours.find((item: { place_id: number }) => item.place_id === tour.place_id);
expect(again.time_estimate).toEqual(tour.time_estimate);
expect(await placeColumns(tour.place_id, 'trip_id')).toEqual({ trip_id: 5 });
expect(await findRow(orm, Tours, { place: tour.place_id })).toMatchObject({
tour_type: 'hike',
@@ -517,6 +523,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(res.status).toBe(201);
expect(res.body.caution).toBe(true);
expect(res.body.tours[0]).toMatchObject({ match_confidence: 0.3, caution: true });
// TIME-01: without heights the estimate is unknown, never the time on the flat.
expect(res.body.tours[0].time_estimate).toEqual({ minutes: null, basis: 'din33466' });
});
it('a second import of the same track is skipped as a duplicate', async () => {
+11 -1
View File
@@ -4,7 +4,7 @@ import type { TourDetailResponse, TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// TOUR-PLAN-READ-001 to TOUR-PLAN-READ-007
// TOUR-PLAN-READ-001 to TOUR-PLAN-READ-009
/** The detail of a tour of this type, only as far as the plan reads it. */
function detailOf(tourType: TourDetailResponse['tour']['tour_type']): TourDetailResponse {
@@ -142,4 +142,14 @@ describe('tour plan read (MCP-T01)', () => {
expect.objectContaining({ kind: 'track', length_km: null }),
]);
});
it('TOUR-PLAN-READ-009: an estimated time is named as worked out, a tour without one names none (TIME-01)', () => {
const none = { controlsFromTrack: false, line: null, run: null };
const estimated = {
...detailOf('hike'),
tour: { tour_type: 'hike', time_estimate: { minutes: 240, basis: 'din33466' } },
} as unknown as TourDetailResponse;
expect(derivedFields(estimated, none)).toContain('tour.time_estimate');
expect(derivedFields(detailOf('hike'), none)).not.toContain('tour.time_estimate');
});
});
+21
View File
@@ -676,4 +676,25 @@ describe('ToursMcp', () => {
expect(plan.legs[0].length_km).toBeCloseTo(1.11, 1);
expect(plan.legs[1].length_km).toBeCloseTo(0.76, 1);
});
it('TOURS-MCP-026: list_tours and get_tour_plan give an import its estimated time, named as worked out, and keep a planned time (TIME-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { distance: 12, elevation_gain: 300, elevation_loss: 500 });
await updateRows(orm, Tours, { place: gorge.id }, { distance: 6, duration: 95 });
await orm.close();
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
expect(byId.get(ridge.id)).toMatchObject({ duration: null, time_estimate: { minutes: 240, basis: 'din33466' } });
expect(byId.get(gorge.id)).toMatchObject({ duration: 95, time_estimate: null });
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(user.id)));
expect(plan.tour.time_estimate).toEqual({ minutes: 240, basis: 'din33466' });
expect(plan.derived).toContain('tour.time_estimate');
const planned = read(await mcp.getTourPlan({ tripId: trip.id, placeId: gorge.id }, ctx(user.id)));
expect(planned.derived).not.toContain('tour.time_estimate');
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toContain('time_estimate');
expect(description).toContain('never a recorded or planned time');
});
});