mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user