From c38eb7ebf0c2f7d043bf8775f53878629545efca Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 20:31:53 +0200 Subject: [PATCH] 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. --- .../components/Tours/TourDayRowFacts.test.tsx | 46 +++++++++++ .../src/components/Tours/TourDayRowFacts.tsx | 48 +++++++++-- .../Tours/TourDetailDialog.test.tsx | 10 +++ .../src/components/Tours/TourDetailDialog.tsx | 7 +- .../src/components/Tours/hikingTime.test.ts | 19 ----- client/src/components/Tours/hikingTime.ts | 19 ----- .../Tours/planner/legVariants.test.ts | 2 +- .../components/Tours/planner/legVariants.ts | 3 +- .../Tours/planner/tourPlannerFacts.ts | 2 +- .../Tours/planner/useTourPlanner.test.tsx | 2 +- .../Tours/stages/tourStages.test.ts | 3 +- .../src/components/Tours/stages/tourStages.ts | 2 +- .../components/Tours/tourPresentation.test.ts | 37 ++++++++- .../src/components/Tours/tourPresentation.ts | 72 ++++++++++++++++- server/src/nest/tours/tour-item.helpers.ts | 3 + server/src/nest/tours/tour-plan-read.ts | 7 +- server/src/nest/tours/tours.mcp.ts | 4 +- server/tests/e2e/tours.e2e.test.ts | 8 ++ server/tests/unit/nest/tour-plan-read.test.ts | 12 ++- server/tests/unit/nest/tours.mcp.test.ts | 21 +++++ shared/src/i18n/ar/tours.ts | 6 ++ shared/src/i18n/az/tours.ts | 7 ++ shared/src/i18n/br/tours.ts | 7 ++ shared/src/i18n/ca/tours.ts | 7 ++ shared/src/i18n/cs/tours.ts | 7 ++ shared/src/i18n/de/tours.ts | 7 ++ shared/src/i18n/en/tours.ts | 7 ++ shared/src/i18n/es/tours.ts | 7 ++ shared/src/i18n/et/tours.ts | 7 ++ shared/src/i18n/fr/tours.ts | 7 ++ shared/src/i18n/gr/tours.ts | 7 ++ shared/src/i18n/hu/tours.ts | 7 ++ shared/src/i18n/id/tours.ts | 7 ++ shared/src/i18n/it/tours.ts | 7 ++ shared/src/i18n/ja/tours.ts | 6 ++ shared/src/i18n/ko/tours.ts | 7 ++ shared/src/i18n/nl/tours.ts | 7 ++ shared/src/i18n/pl/tours.ts | 7 ++ shared/src/i18n/ru/tours.ts | 7 ++ shared/src/i18n/sk/tours.ts | 7 ++ shared/src/i18n/sv/tours.ts | 7 ++ shared/src/i18n/th/tours.ts | 6 ++ shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 7 ++ shared/src/i18n/uk/tours.ts | 7 ++ shared/src/i18n/vi/tours.ts | 7 ++ shared/src/i18n/zh-TW/tours.ts | 6 ++ shared/src/i18n/zh/tours.ts | 6 ++ shared/src/index.ts | 1 + shared/src/tours/tour-time-estimate.spec.ts | 80 +++++++++++++++++++ shared/src/tours/tour-time-estimate.ts | 79 ++++++++++++++++++ shared/src/tours/tours.schema.ts | 9 +++ 52 files changed, 617 insertions(+), 65 deletions(-) delete mode 100644 client/src/components/Tours/hikingTime.test.ts delete mode 100644 client/src/components/Tours/hikingTime.ts create mode 100644 shared/src/tours/tour-time-estimate.spec.ts create mode 100644 shared/src/tours/tour-time-estimate.ts diff --git a/client/src/components/Tours/TourDayRowFacts.test.tsx b/client/src/components/Tours/TourDayRowFacts.test.tsx index 4050ea598..72e5bc4a3 100644 --- a/client/src/components/Tours/TourDayRowFacts.test.tsx +++ b/client/src/components/Tours/TourDayRowFacts.test.tsx @@ -225,3 +225,49 @@ describe('the compact facts of a tour list row', () => { ]); }); }); + +describe('TIME-01: the estimated time of an import on a row', () => { + // 12 km, 300 m up and 500 m down, never timed by the planner: 4 h by DIN 33466. + const imported: TourListItem = { + ...tour, + distance: 12, + elevation_gain: 300, + elevation_loss: 500, + duration: null, + break_additional_minutes: null, + max_hiking_difficulty: null, + }; + + it('TIME-01-FE-001: a day-plan and list row shows the estimate marked as one, its words saying how it was worked out', () => { + render(); + const time = screen.getByTestId('trip-plan-tour-time'); + expect(time).toHaveTextContent('≈ 4 h'); + expect(time.getAttribute('aria-label')).toBe( + 'Estimated time: 4 h. Worked out from distance, ascent and descent by the hiking signpost rule (DIN 33466), without breaks.' + ); + }); + + it('TIME-01-FE-002: a total the travellers entered is the time shown, the estimate kept beside it in the words', () => { + render(); + const time = screen.getByTestId('trip-plan-tour-time'); + expect(time).toHaveTextContent('5 h'); + expect(time).not.toHaveTextContent('≈'); + expect(time.getAttribute('aria-label')).toContain('Estimated time: 4 h'); + }); + + it('TIME-01-FE-003: a phone row shows the estimate as a fact of its own, and an unknown one not at all', () => { + const { unmount } = render(); + expect(screen.getByTestId('trip-plan-tour-estimate')).toHaveTextContent('≈ 4 h'); + expect(screen.getByTestId('trip-plan-tour-estimate')).toHaveAccessibleName(/^Estimated time: 4 h\. Worked out/); + expect(screen.queryByTestId('trip-plan-tour-walking-time')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByTestId('trip-plan-tour-estimate')).not.toBeInTheDocument(); + }); + + it('TIME-01-FE-004: a tour the planner timed shows its own time and no estimate', () => { + render(); + expect(screen.queryByTestId('trip-plan-tour-estimate')).not.toBeInTheDocument(); + expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min'); + }); +}); diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 4c6bbc1b2..9e6610eb0 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -12,7 +12,14 @@ import { runPaceText } from './run/runPaceText'; import { SkiCornerMark } from './ski/skiMarks'; import { tourDayRowIcon } from './tourDayRowIcon'; import { movingTimeLabel } from './tourFacts'; -import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation'; +import { + estimateBadgeText, + estimateLabel, + formatPlannedTourDuration, + plannedTotalText, + tourPlannedTimes, + type TourPlannedTimes, +} from './tourPresentation'; import { useWalkProgress } from './walk/walkProgress'; import { walkStaysText } from './walk/walkTimeFields'; @@ -178,6 +185,20 @@ export function TourDayRowPill({ ); } +/** + * TIME-01: what an import is estimated to take as a row says it, the badge + * marked with ≈ and its words saying what it was worked out by; both null + * while it is unknown or there is none, since a row has no room for a badge + * that says nothing. The detail names an unknown one. + */ +function estimateFact( + times: TourPlannedTimes, + t: Parameters[1] +): { value: string | null; label: string | null } { + const value = estimateBadgeText(times.estimate); + return value && times.estimate ? { value, label: estimateLabel(times.estimate, t) } : { value: null, label: null }; +} + /** * A paddle tour's check bench as one fact (PAD-09), or null for a hike, a tour * listed without one, or an area without obstacle data: a row has no room for @@ -204,6 +225,7 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; const total = plannedTotalText(times, t); const staysText = times.walk ? walkStaysText(times.walk, t) : null; const pace = runPaceText(tour, t, unit); + const estimate = estimateFact(times, t); const timeParts = [ walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null, // RUN-01: the pace the running time is planned at. @@ -216,9 +238,11 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; total ? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}` : null, + // TIME-01: an import's estimate, apart from any total the travellers entered. + estimate.label, ].filter((part): part is string => part !== null); const check = paddleCheckOf(tour, t); - const shown = plannedTotalText(times, t, true) ?? walking; + const shown = plannedTotalText(times, t, true) ?? walking ?? estimate.value; const time = shown != null && ( ); @@ -260,9 +284,9 @@ export default function TourDayRowFacts({ const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric'; const progress = useWalkProgress(tour, assignmentId); const times = tourPlannedTimes(tour); - const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = []; - const add = (icon: LucideIcon, value: string, label: string, testId: string) => - facts.push({ icon, value, label, testId }); + const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string; said?: string }> = []; + const add = (icon: LucideIcon, value: string, label: string, testId: string, said?: string) => + facts.push({ icon, value, label, testId, said }); if (tour.distance != null) add(Ruler, formatDistance(tour.distance, unit), t('tours.detail.distance'), 'plan-tour-distance'); if (tour.elevation_gain != null) @@ -274,6 +298,16 @@ export default function TourDayRowFacts({ movingTimeLabel(tour, t('tours.planner.inspector.duration'), t), 'trip-plan-tour-walking-time' ); + // TIME-01: an import's estimate, said in full where it is opened. + const estimate = estimateFact(times, t); + if (estimate.value && estimate.label) + add( + tourDayRowIcon(movingIconType(tour.tour_type)), + estimate.value, + t('tours.estimate.label'), + 'trip-plan-tour-estimate', + estimate.label + ); const pace = runPaceText(tour, t, unit); if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace'); if (times.breakMinutes != null && times.breakMinutes > 0) @@ -295,8 +329,8 @@ export default function TourDayRowFacts({ if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check'); return ( - {facts.map((fact) => ( - + {facts.map(({ said, ...fact }) => ( + ))} {progress && ( { expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' }) }) + it('TIME-01-FE-005: an import shows its estimated time apart from a planned one, and an import without heights says it is unknown', () => { + const imported = { ...tour, has_waypoints: false, origin: 'gpx' as const, distance: 12, elevation_gain: 300, elevation_loss: 500 } + const view = render() + expect(screen.getByLabelText('Estimated time: ≈ 4 h')).toHaveTextContent('≈ 4 h') + expect(screen.getByText('Estimated time')).toBeInTheDocument() + view.unmount() + render() + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => { render() diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 0b980647d..0823010a8 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -63,6 +63,8 @@ import { tourDaysOf } from './tourDays'; import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts'; import { TourDayTiles, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts'; import { + estimateBadgeText, + estimateField, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, @@ -221,6 +223,8 @@ function tourTimeFields( value: formatPlannedTourDuration(times.plannedTotalMinutes), } : null, + // TIME-01: what an import is estimated to take, or that it is unknown, apart from the times above. + times.estimate ? estimateField(times.estimate, t) : null, ].filter((field): field is TourMetricField => field !== null); } @@ -249,7 +253,8 @@ function TourHeadPills({ const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', '); const shownTime = plannedTotalText(times, t, true) ?? - (times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes)); + (times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes)) ?? + estimateBadgeText(times.estimate); const source = tourSource(tour); // The import this tour was planned from, named while it is still in the trip. const derivedFromName = useTripStore((state) => diff --git a/client/src/components/Tours/hikingTime.test.ts b/client/src/components/Tours/hikingTime.test.ts deleted file mode 100644 index 94756e7fe..000000000 --- a/client/src/components/Tours/hikingTime.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466. -import { describe, expect, it } from 'vitest'; -import { hikingTimeMinutes } from './hikingTime'; - -describe('hikingTimeMinutes', () => { - 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); - }); -}); diff --git a/client/src/components/Tours/hikingTime.ts b/client/src/components/Tours/hikingTime.ts deleted file mode 100644 index 80a5b4399..000000000 --- a/client/src/components/Tours/hikingTime.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 router's own estimate is a walking pace along the line and barely sees a - * slope, so a short steep climb came out far too quick. - */ -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); -} diff --git a/client/src/components/Tours/planner/legVariants.test.ts b/client/src/components/Tours/planner/legVariants.test.ts index e8369d6fb..9559b1cd5 100644 --- a/client/src/components/Tours/planner/legVariants.test.ts +++ b/client/src/components/Tours/planner/legVariants.test.ts @@ -1,8 +1,8 @@ // FE-TOUR-LEG-VARIANTS-001 to FE-TOUR-LEG-VARIANTS-008: the model of the ways a hike's section // can take, which section can be compared, and the figures of each way (RTE-02). +import { hikingTimeMinutes } from '@trek/shared'; import { describe, expect, it } from 'vitest'; import type { ValhallaLeg } from '../../Map/valhallaRoute'; -import { hikingTimeMinutes } from '../hikingTime'; import { comparedLegOf, currentVariantOf, diff --git a/client/src/components/Tours/planner/legVariants.ts b/client/src/components/Tours/planner/legVariants.ts index c68e7d12c..417796a21 100644 --- a/client/src/components/Tours/planner/legVariants.ts +++ b/client/src/components/Tours/planner/legVariants.ts @@ -1,8 +1,7 @@ -import type { PlannableTourType } from '@trek/shared'; +import { hikingTimeMinutes, type PlannableTourType } from '@trek/shared'; import { analyzeRouteGeometry } from '../../../utils/routeGeometry'; import { sameRoad } from '../../Map/RouteCalculator'; import type { CostingOptions, ValhallaLeg } from '../../Map/valhallaRoute'; -import { hikingTimeMinutes } from '../hikingTime'; import { legSpanOf } from './routeLegAt'; import type { TourPlannerWaypoint } from './tourPlannerWaypoints'; import type { TourElevationPoint } from './tourRouting'; diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index 2fdd8fcc7..198d6c0ca 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -1,4 +1,5 @@ import { + hikingTimeMinutes, MAX_PLANNED_TOUR_DURATION_MINUTES, maxTourMinutesFor, plannableTourTypeSchema, @@ -15,7 +16,6 @@ import { type TourMaxHikingDifficulty, } from '@trek/shared'; import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry'; -import { hikingTimeMinutes } from '../hikingTime'; import { runPaceInvalid } from '../run/runPace'; import { NO_SKI_CHOICE, skiChoiceMissing, skiChoiceOf, skiFieldsOf, type SkiChoice } from '../ski/skiChoice'; import { walkTimesOf, type WalkTimes } from './stationModel'; diff --git a/client/src/components/Tours/planner/useTourPlanner.test.tsx b/client/src/components/Tours/planner/useTourPlanner.test.tsx index 287512412..bd31d0b36 100644 --- a/client/src/components/Tours/planner/useTourPlanner.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detai vi.mock('../useTourPermissions', () => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ canEdit, canAssign }) })) import { useTourPlanner } from './useTourPlanner' -import { hikingTimeMinutes } from '../hikingTime' +import { hikingTimeMinutes } from '@trek/shared' import { analyzeRouteGeometry } from '../../../utils/routeGeometry' import { TourPlannerRail } from './TourPlannerPanels' diff --git a/client/src/components/Tours/stages/tourStages.test.ts b/client/src/components/Tours/stages/tourStages.test.ts index d5e6b8d01..eda7de306 100644 --- a/client/src/components/Tours/stages/tourStages.test.ts +++ b/client/src/components/Tours/stages/tourStages.test.ts @@ -1,7 +1,6 @@ // 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 { hikingTimeMinutes, MAX_TOUR_STAGES, runMinutesAtPace } from '@trek/shared'; import { describe, expect, it } from 'vitest'; -import { hikingTimeMinutes } from '../hikingTime'; import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; import { canEndStage, diff --git a/client/src/components/Tours/stages/tourStages.ts b/client/src/components/Tours/stages/tourStages.ts index 7de88aafd..cb9d5c28a 100644 --- a/client/src/components/Tours/stages/tourStages.ts +++ b/client/src/components/Tours/stages/tourStages.ts @@ -1,11 +1,11 @@ import { + hikingTimeMinutes, 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'; diff --git a/client/src/components/Tours/tourPresentation.test.ts b/client/src/components/Tours/tourPresentation.test.ts index 8188566ca..2b6cb9c6c 100644 --- a/client/src/components/Tours/tourPresentation.test.ts +++ b/client/src/components/Tours/tourPresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' +import { estimateBadgeText, estimateLabel, filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourDayMinutes, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' describe('tourPresentation', () => { it('formats planned Tour totals using the shared compact duration formatter', () => { @@ -10,10 +10,10 @@ describe('tourPresentation', () => { it('calculates planned total from walking and breaks unless a manual override exists', () => { expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: null, break_additional_minutes: 25 })).toEqual({ - walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, walk: null, atLeast: false, + walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, walk: null, atLeast: false, estimate: null, }) expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: 130, break_additional_minutes: 25 })).toEqual({ - walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, walk: null, atLeast: false, + walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, walk: null, atLeast: false, estimate: null, }) expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: 25 }).plannedTotalMinutes).toBeNull() }) @@ -21,7 +21,7 @@ describe('tourPresentation', () => { it('CW-02: adds a city walk its stays and no breaks, and says the total is at least that while a stay is not entered', () => { const walk = { tour_type: 'walk' as const, duration: 45, planned_duration_minutes: null, break_additional_minutes: 15 } expect(tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0 })).toEqual({ - walkingMinutes: 45, breakMinutes: null, plannedTotalMinutes: 165, manuallyOverridden: false, atLeast: false, + walkingMinutes: 45, breakMinutes: null, plannedTotalMinutes: 165, manuallyOverridden: false, atLeast: false, estimate: null, walk: { walkingMinutes: 45, stayMinutes: 120, unknownStays: 0, stations: 3, totalMinutes: 165, partial: false }, }) const partial = tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 }) @@ -34,6 +34,35 @@ describe('tourPresentation', () => { expect(tourPlannedTimes({ ...walk, station_stay_minutes: 30 }).walk).toMatchObject({ stations: 0, unknownStays: 0, totalMinutes: 75 }) }) + it('TIME-01: estimates an imported hike by the planner rule, apart from any planned total, and never a planned one', () => { + const imported = { tour_type: 'hike' as const, duration: null, planned_duration_minutes: null, break_additional_minutes: null, distance: 12, elevation_gain: 300, elevation_loss: 500 } + expect(tourPlannedTimes(imported)).toMatchObject({ walkingMinutes: null, plannedTotalMinutes: null, estimate: { minutes: 240, basis: 'din33466' } }) + // A total the travellers entered stays theirs; the estimate is kept beside it. + expect(tourPlannedTimes({ ...imported, planned_duration_minutes: 300 })).toMatchObject({ plannedTotalMinutes: 300, estimate: { minutes: 240 } }) + expect(tourPlannedTimes({ ...imported, elevation_gain: null }).estimate).toEqual({ minutes: null, basis: 'din33466' }) + expect(tourPlannedTimes({ ...imported, duration: 200 }).estimate).toBeNull() + expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: null }).estimate).toBeNull() + expect(tourPlannedTimes({ ...imported, tour_type: 'run', run_pace_seconds_per_km: 360 }).estimate).toEqual({ minutes: 72, basis: 'run_pace' }) + }) + + it('TIME-01: reckons a day with the planned total first, else the estimate, else nothing', () => { + const estimate = { minutes: 240, basis: 'din33466' as const } + expect(tourDayMinutes({ plannedTotalMinutes: 300, atLeast: true, estimate })).toEqual({ minutes: 300, estimated: false, atLeast: true }) + expect(tourDayMinutes({ plannedTotalMinutes: null, atLeast: false, estimate })).toEqual({ minutes: 240, estimated: true, atLeast: false }) + expect(tourDayMinutes({ plannedTotalMinutes: null, atLeast: false, estimate: { ...estimate, minutes: null } })).toBeNull() + expect(tourDayMinutes({ plannedTotalMinutes: null, atLeast: false, estimate: null })).toBeNull() + }) + + it('TIME-01: says an estimate as one, with what it was worked out by, and an unknown one as unknown', () => { + const t = (key: string) => key + expect(estimateBadgeText({ minutes: 200, basis: 'din33466' })).toBe('≈ 3 h 20 min') + expect(estimateBadgeText({ minutes: null, basis: 'din33466' })).toBeNull() + expect(estimateBadgeText(null)).toBeNull() + expect(estimateLabel({ minutes: 200, basis: 'din33466' }, t)).toBe('tours.estimate.label: 3 h 20 min. tours.estimate.basisDin') + expect(estimateLabel({ minutes: 48, basis: 'run_pace' }, t)).toBe('tours.estimate.label: 48 min. tours.estimate.basisPace') + expect(estimateLabel({ minutes: null, basis: 'din33466' }, t)).toBe('tours.estimate.label: tours.estimate.unknown. tours.estimate.noHeights') + }) + it('CW-02: says a planned total as it is, at least that much, or as a badge with ≥', () => { const t = (key: string, params?: Record) => `${key}:${String(params?.time)}` expect(plannedTotalText({ plannedTotalMinutes: null, atLeast: false }, t)).toBeNull() diff --git a/client/src/components/Tours/tourPresentation.ts b/client/src/components/Tours/tourPresentation.ts index f7edc27d3..5b1408ca8 100644 --- a/client/src/components/Tours/tourPresentation.ts +++ b/client/src/components/Tours/tourPresentation.ts @@ -1,7 +1,8 @@ import type { TourListItem, TourOrigin } from '@trek/shared' -import { tourWebsiteSchema } from '@trek/shared' +import { tourTimeEstimate, tourWebsiteSchema, type TourTimeEstimate } from '@trek/shared' import { formatDurationShort } from '@trek/shared/roadtrip' import { walkTimesOf, type WalkTimes } from './planner/stationModel' +import type { TourMetricField } from './tourParts' export type TourSource = TourOrigin @@ -26,10 +27,30 @@ export interface TourPlannedTimes { walk: WalkTimes | null /** The planned total leaves out a stay nobody entered yet: it is at least that much. */ atLeast: boolean + /** + * TIME-01: what a tour without a time of its own (an import) is estimated to + * take, worked out from its measures by the planner's own rule; null where + * nothing estimates it. Never part of the planned total. + */ + estimate: TourTimeEstimate | null } type PlannedTimesSource = Pick & Partial> + & Partial> + +/** TIME-01: the estimate of a tour, by the rule `@trek/shared` keeps for the app and the server alike. */ +function estimateOf(tour: PlannedTimesSource): TourTimeEstimate | null { + if (!tour.tour_type) return null + return tourTimeEstimate({ + tour_type: tour.tour_type, + distance: tour.distance ?? null, + elevation_gain: tour.elevation_gain ?? null, + elevation_loss: tour.elevation_loss ?? null, + duration: tour.duration, + run_pace_seconds_per_km: tour.run_pace_seconds_per_km ?? null, + }) +} /** CW-02: a city walk's times from what the list says of its stations; null where it says nothing of them. */ function walkTimesOfTour(tour: PlannedTimesSource): WalkTimes | null { @@ -59,9 +80,58 @@ export function tourPlannedTimes(tour: PlannedTimesSource): TourPlannedTimes { manuallyOverridden: manualOverride !== null, walk, atLeast: manualOverride === null && !!walk?.partial, + estimate: estimateOf(tour), } } +/** + * TIME-01: the time a tour is reckoned to take in a day: the planned total, + * else the estimate. Null where neither is known. `estimated` says the time + * is an estimate, `atLeast` that it leaves out a stay nobody entered yet. + */ +export function tourDayMinutes( + times: Pick +): { minutes: number; estimated: boolean; atLeast: boolean } | null { + if (times.plannedTotalMinutes != null) { + return { minutes: times.plannedTotalMinutes, estimated: false, atLeast: times.atLeast } + } + const estimated = times.estimate?.minutes + return estimated == null ? null : { minutes: estimated, estimated: true, atLeast: false } +} + +/** TIME-01: an estimate as a badge says it, `≈ 3 h 20 min`, or null while it is unknown. */ +export function estimateBadgeText(estimate: TourTimeEstimate | null): string | null { + return estimate?.minutes == null ? null : `≈ ${formatPlannedTourDuration(estimate.minutes)}` +} + +/** TIME-01: the words for what an estimate was worked out by, or why it is unknown. */ +function estimateBasisText(estimate: TourTimeEstimate, t: Translate): string { + if (estimate.minutes == null) return t('tours.estimate.noHeights') + return t(estimate.basis === 'run_pace' ? 'tours.estimate.basisPace' : 'tours.estimate.basisDin') +} + +/** + * TIME-01: an estimate as a field of the tour detail: labelled estimated, its + * time or unknown, and what it was worked out by behind its info dot. + */ +export function estimateField(estimate: TourTimeEstimate, t: Translate): TourMetricField { + return { + label: t('tours.estimate.label'), + shortLabel: t('tours.estimate.short'), + value: estimateBadgeText(estimate) ?? t('tours.estimate.unknown'), + info: { label: t('tours.estimate.label'), description: estimateBasisText(estimate, t) }, + } +} + +/** + * TIME-01: an estimate in words for a tooltip or a field: its time, or that + * it is unknown, and what it was worked out by. + */ +export function estimateLabel(estimate: TourTimeEstimate, t: Translate): string { + const value = estimate.minutes == null ? t('tours.estimate.unknown') : formatPlannedTourDuration(estimate.minutes) + return `${t('tours.estimate.label')}: ${value}. ${estimateBasisText(estimate, t)}` +} + /** * The planned total as said, or null without one: "at least" where a city * walk's stay is not entered yet (CW-02). A badge says it as "≥", the way a diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts index 44c77bdad..52d214bd4 100644 --- a/server/src/nest/tours/tour-item.helpers.ts +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -12,6 +12,7 @@ import { activityFacts } from './tour-activities.helpers'; import { LOW_CONFIDENCE_THRESHOLD } from './tours.helpers'; import { tourStageStopsOf, + tourTimeEstimate, walkStationFacts, type BikeProfile, type PaddleBoatType, @@ -79,6 +80,8 @@ export function tourItemOf( route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints), track_started_at: utcSuffix(r.track_started_at), track_ended_at: utcSuffix(r.track_ended_at), + // TIME-01: what a tour without a planned time is estimated to take, worked out here on every read. + time_estimate: tourTimeEstimate(r), ...activityFacts(r.place_id, activities), // MDT-01: a multi-day tour's stages, one more than the stops that end one. stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null, diff --git a/server/src/nest/tours/tour-plan-read.ts b/server/src/nest/tours/tour-plan-read.ts index 661f5943e..0d4708c49 100644 --- a/server/src/nest/tours/tour-plan-read.ts +++ b/server/src/nest/tours/tour-plan-read.ts @@ -169,8 +169,9 @@ interface PlanExtras { * with the tour. The distance, heights and walking time of a tour are stored * when it is saved, and the days are the trip's own assignments, so neither * is listed here. What a recorded track measures (ACT-01) is read off its - * line on every read, and a multi-day tour's stages (MDT-01) are counted from - * its stage ends, so both are. + * line on every read, a multi-day tour's stages (MDT-01) are counted from + * its stage ends and the time an import is estimated to take (TIME-01) is + * worked out from its measures, so all three are. */ export function derivedFields(detail: TourDetailResponse, { controlsFromTrack, line, run }: PlanExtras): string[] { const type = detail.tour.tour_type; @@ -187,5 +188,7 @@ export function derivedFields(detail: TourDetailResponse, { controlsFromTrack, l ...(type === 'kayak' ? ['tour.paddle_check', 'obstacle_check'] : []), ...((detail.activities?.length ?? 0) > 0 ? ['activities[].metrics'] : []), ...(detail.tour.stage_count != null ? ['tour.stage_count', 'tour.stage_stops'] : []), + // TIME-01: an estimate is worked out on every read, never stored. + ...(detail.tour.time_estimate ? ['tour.time_estimate'] : []), ]; } diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index ac6025fe3..e6a914171 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -102,7 +102,7 @@ export class ToursMcp { @Tool({ name: 'list_tours', description: - 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride and a ski tour have no time worked out, so their duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has every leg drawn straight between its points, never routed along pistes, lifts or trails, an imported one is kept as recorded; its distance and heights come from that line or the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).', + 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride and a ski tour have no time worked out, so their duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has every leg drawn straight between its points, never routed along pistes, lifts or trails, an imported one is kept as recorded; its distance and heights come from that line or the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), the time a tour without a planned time (an import) is estimated to take (time_estimate: minutes and basis, din33466 for a hike by the hiking signpost rule DIN 33466 from its distance, ascent and descent, run_pace for a run at the pace entered; worked out on reading and without breaks, never a recorded or planned time; minutes null when the track has no heights, and time_estimate null where nothing estimates it, a tour the planner timed among them, whose duration is its time), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).', inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), @@ -117,7 +117,7 @@ export class ToursMcp { @Tool({ name: 'get_tour_plan', description: - 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; every leg of a planned ski tour is free, a straight line between its points), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.', + 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; every leg of a planned ski tour is free, a straight line between its points), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. tour.time_estimate, where there is one, is such a field: an estimate, never a time the travellers planned or recorded. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.', inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index a35177906..19babb539 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/tour-plan-read.test.ts b/server/tests/unit/nest/tour-plan-read.test.ts index 3b488a558..8228adb67 100644 --- a/server/tests/unit/nest/tour-plan-read.test.ts +++ b/server/tests/unit/nest/tour-plan-read.test.ts @@ -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'); + }); }); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index 6ad13fe60..5bee1e266 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -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'); + }); }); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 153198f98..0d547eda5 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -1364,5 +1364,11 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'الجولة كاملة', 'tours.stages.totalPartial': 'معروف لـ {known} من {of} مراحل', 'tours.stages.openInPlanner': 'فتح هذه المرحلة في المخطط', + 'tours.estimate.label': 'الوقت المقدَّر', + 'tours.estimate.short': 'تقديري', + 'tours.estimate.unknown': 'غير معروف', + 'tours.estimate.noHeights': 'لا يحتوي المسار على ارتفاعات، لذا لا يمكن تقدير وقته.', + 'tours.estimate.basisDin': 'محسوب من المسافة والصعود والنزول وفق قاعدة لافتات المشي (DIN 33466)، دون استراحات.', + 'tours.estimate.basisPace': 'محسوب من المسافة بالوتيرة المُدخلة.', }; export default tours; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 0f4be22a1..ed1fa43f1 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -1304,5 +1304,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Bütün tur', 'tours.stages.totalPartial': '{of} mərhələdən {known} üçün məlumdur', 'tours.stages.openInPlanner': 'Bu mərhələni planlayıcıda aç', + 'tours.estimate.label': 'Təxmini vaxt', + 'tours.estimate.short': 'Təxmini', + 'tours.estimate.unknown': 'Naməlum', + 'tours.estimate.noHeights': 'Trekdə hündürlük yoxdur, buna görə vaxtı təxmin etmək olmur.', + 'tours.estimate.basisDin': + 'Məsafə, qalxma və enmədən piyada nişanları qaydası ilə (DIN 33466) hesablanıb, fasilələr daxil deyil.', + 'tours.estimate.basisPace': 'Məsafədən daxil edilmiş templə hesablanıb.', }; export default tours; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 0d6b9c994..d269719a3 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -1317,5 +1317,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Roteiro completo', 'tours.stages.totalPartial': 'conhecido para {known} de {of} etapas', 'tours.stages.openInPlanner': 'Abrir esta etapa no planejador', + 'tours.estimate.label': 'Tempo estimado', + 'tours.estimate.short': 'Estimado', + 'tours.estimate.unknown': 'Desconhecido', + 'tours.estimate.noHeights': 'A trilha não tem altitudes, então o tempo não pode ser estimado.', + 'tours.estimate.basisDin': + 'Calculado pela distância, subida e descida segundo a regra das placas de trilha (DIN 33466), sem pausas.', + 'tours.estimate.basisPace': 'Calculado pela distância no ritmo informado.', }; export default tours; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 326843896..d2b422495 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -1323,5 +1323,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Ruta sencera', 'tours.stages.totalPartial': 'conegut per a {known} de {of} etapes', 'tours.stages.openInPlanner': 'Obre aquesta etapa al planificador', + 'tours.estimate.label': 'Temps estimat', + 'tours.estimate.short': 'Estimat', + 'tours.estimate.unknown': 'Desconegut', + 'tours.estimate.noHeights': 'El track no té altituds, així que no se’n pot estimar el temps.', + 'tours.estimate.basisDin': + 'Calculat a partir de la distància, la pujada i la baixada segons la regla dels senyals excursionistes (DIN 33466), sense pauses.', + 'tours.estimate.basisPace': 'Calculat a partir de la distància al ritme introduït.', }; export default tours; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 7bbac58f5..6ecc24212 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -1308,5 +1308,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Celá trasa', 'tours.stages.totalPartial': 'známo pro {known} z {of} etap', 'tours.stages.openInPlanner': 'Otevřít tuto etapu v plánovači', + 'tours.estimate.label': 'Odhadovaný čas', + 'tours.estimate.short': 'Odhad', + 'tours.estimate.unknown': 'Neznámý', + 'tours.estimate.noHeights': 'Trasa nemá výšky, proto nelze čas odhadnout.', + 'tours.estimate.basisDin': + 'Vypočteno ze vzdálenosti, stoupání a klesání podle pravidla turistických rozcestníků (DIN 33466), bez přestávek.', + 'tours.estimate.basisPace': 'Vypočteno ze vzdálenosti při zadaném tempu.', }; export default tours; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 6af8137f3..51b05cf58 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -1335,6 +1335,13 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Ganze Tour', 'tours.stages.totalPartial': 'bekannt für {known} von {of} Etappen', 'tours.stages.openInPlanner': 'Diese Etappe im Planer öffnen', + 'tours.estimate.label': 'Geschätzte Zeit', + 'tours.estimate.short': 'Geschätzt', + 'tours.estimate.unknown': 'Unbekannt', + 'tours.estimate.noHeights': 'Der Track hat keine Höhen, daher lässt sich keine Zeit schätzen.', + 'tours.estimate.basisDin': + 'Aus Strecke, Auf- und Abstieg nach der Wegweiser-Regel (DIN 33466) berechnet, ohne Pausen.', + 'tours.estimate.basisPace': 'Aus der Strecke beim eingegebenen Tempo berechnet.', }; export default tours; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index eb8e30ead..f79c43309 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -1290,5 +1290,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Whole tour', 'tours.stages.totalPartial': 'known for {known} of {of} stages', 'tours.stages.openInPlanner': 'Open this stage in the planner', + 'tours.estimate.label': 'Estimated time', + 'tours.estimate.short': 'Estimated', + 'tours.estimate.unknown': 'Unknown', + 'tours.estimate.noHeights': 'The track has no heights, so its time cannot be estimated.', + 'tours.estimate.basisDin': + 'Worked out from distance, ascent and descent by the hiking signpost rule (DIN 33466), without breaks.', + 'tours.estimate.basisPace': 'Worked out from the distance at the pace entered.', }; export default tours; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index c1a4e667d..81c6909a6 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -1316,5 +1316,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Ruta completa', 'tours.stages.totalPartial': 'conocido para {known} de {of} etapas', 'tours.stages.openInPlanner': 'Abrir esta etapa en el planificador', + 'tours.estimate.label': 'Tiempo estimado', + 'tours.estimate.short': 'Estimado', + 'tours.estimate.unknown': 'Desconocido', + 'tours.estimate.noHeights': 'El track no tiene altitudes, así que no se puede estimar su tiempo.', + 'tours.estimate.basisDin': + 'Calculado a partir de la distancia, el ascenso y el descenso según la regla de las señales de senderismo (DIN 33466), sin pausas.', + 'tours.estimate.basisPace': 'Calculado a partir de la distancia al ritmo introducido.', }; export default tours; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 77a1eddd6..daaef2522 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -1299,5 +1299,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Kogu matk', 'tours.stages.totalPartial': 'teada {known} etapi kohta {of}-st', 'tours.stages.openInPlanner': 'Ava see etapp planeerijas', + 'tours.estimate.label': 'Hinnanguline aeg', + 'tours.estimate.short': 'Hinnang', + 'tours.estimate.unknown': 'Teadmata', + 'tours.estimate.noHeights': 'Rajal pole kõrgusi, seega aega ei saa hinnata.', + 'tours.estimate.basisDin': + 'Arvutatud vahemaa, tõusu ja laskumise järgi matkaviitade reegli (DIN 33466) alusel, pausideta.', + 'tours.estimate.basisPace': 'Arvutatud vahemaast sisestatud tempoga.', }; export default tours; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index c23441e8e..416c81179 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -1343,5 +1343,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Itinéraire complet', 'tours.stages.totalPartial': 'connu pour {known} étapes sur {of}', 'tours.stages.openInPlanner': 'Ouvrir cette étape dans le planificateur', + 'tours.estimate.label': 'Temps estimé', + 'tours.estimate.short': 'Estimé', + 'tours.estimate.unknown': 'Inconnu', + 'tours.estimate.noHeights': 'Le tracé n’a pas d’altitudes, son temps ne peut donc pas être estimé.', + 'tours.estimate.basisDin': + 'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle des panneaux de randonnée (DIN 33466), sans pauses.', + 'tours.estimate.basisPace': 'Calculé à partir de la distance à l’allure saisie.', }; export default tours; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 438ddadd8..bbe937af5 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -1345,5 +1345,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Όλη η διαδρομή', 'tours.stages.totalPartial': 'γνωστό για {known} από {of} σκέλη', 'tours.stages.openInPlanner': 'Άνοιγμα αυτού του σκέλους στον σχεδιαστή', + 'tours.estimate.label': 'Εκτιμώμενος χρόνος', + 'tours.estimate.short': 'Εκτίμηση', + 'tours.estimate.unknown': 'Άγνωστος', + 'tours.estimate.noHeights': 'Η διαδρομή δεν έχει υψόμετρα, οπότε ο χρόνος δεν μπορεί να εκτιμηθεί.', + 'tours.estimate.basisDin': + 'Υπολογισμένος από απόσταση, ανάβαση και κατάβαση με τον κανόνα των πινακίδων πεζοπορίας (DIN 33466), χωρίς διαλείμματα.', + 'tours.estimate.basisPace': 'Υπολογισμένος από την απόσταση με τον ρυθμό που δόθηκε.', }; export default tours; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 5b3e7e86e..7bc13fae7 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -1322,5 +1322,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Teljes túra', 'tours.stages.totalPartial': 'ismert {known} szakasznál a {of} közül', 'tours.stages.openInPlanner': 'A szakasz megnyitása a tervezőben', + 'tours.estimate.label': 'Becsült idő', + 'tours.estimate.short': 'Becsült', + 'tours.estimate.unknown': 'Ismeretlen', + 'tours.estimate.noHeights': 'A nyomvonalnak nincsenek magasságai, így az ideje nem becsülhető.', + 'tours.estimate.basisDin': + 'A távból, emelkedésből és lejtésből számolva a turistaútjelzők szabálya szerint (DIN 33466), szünetek nélkül.', + 'tours.estimate.basisPace': 'A távból számolva a megadott tempóval.', }; export default tours; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 1f262d26c..a73ad0d4f 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -1296,5 +1296,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Seluruh tur', 'tours.stages.totalPartial': 'diketahui untuk {known} dari {of} etape', 'tours.stages.openInPlanner': 'Buka etape ini di perencana', + 'tours.estimate.label': 'Perkiraan waktu', + 'tours.estimate.short': 'Perkiraan', + 'tours.estimate.unknown': 'Tidak diketahui', + 'tours.estimate.noHeights': 'Jalur tidak memiliki data ketinggian, jadi waktunya tidak dapat diperkirakan.', + 'tours.estimate.basisDin': + 'Dihitung dari jarak, tanjakan, dan turunan menurut aturan papan penunjuk pendakian (DIN 33466), tanpa istirahat.', + 'tours.estimate.basisPace': 'Dihitung dari jarak dengan kecepatan yang dimasukkan.', }; export default tours; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 3537f2ed3..54f6d02da 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -1330,5 +1330,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Tour completo', 'tours.stages.totalPartial': 'noto per {known} tappe su {of}', 'tours.stages.openInPlanner': 'Apri questa tappa nel pianificatore', + 'tours.estimate.label': 'Tempo stimato', + 'tours.estimate.short': 'Stimato', + 'tours.estimate.unknown': 'Sconosciuto', + 'tours.estimate.noHeights': 'La traccia non ha quote, quindi il suo tempo non si può stimare.', + 'tours.estimate.basisDin': + 'Calcolato da distanza, salita e discesa secondo la regola dei cartelli escursionistici (DIN 33466), senza pause.', + 'tours.estimate.basisPace': 'Calcolato dalla distanza al ritmo inserito.', }; export default tours; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 120defcc7..6d48601ff 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -1265,5 +1265,11 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'ツアー全体', 'tours.stages.totalPartial': '{of} 件中 {known} 件のステージで判明', 'tours.stages.openInPlanner': 'このステージをプランナーで開く', + 'tours.estimate.label': '推定時間', + 'tours.estimate.short': '推定', + 'tours.estimate.unknown': '不明', + 'tours.estimate.noHeights': 'トラックに標高データがないため、時間を推定できません。', + 'tours.estimate.basisDin': '距離・登り・下りから登山道標識の基準(DIN 33466)で算出、休憩は含みません。', + 'tours.estimate.basisPace': '距離と入力したペースから算出。', }; export default tours; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 741393a13..603b5c965 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -1261,5 +1261,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': '전체 투어', 'tours.stages.totalPartial': '{of}개 구간 중 {known}개에서 알려짐', 'tours.stages.openInPlanner': '이 구간을 플래너에서 열기', + 'tours.estimate.label': '예상 시간', + 'tours.estimate.short': '예상', + 'tours.estimate.unknown': '알 수 없음', + 'tours.estimate.noHeights': '트랙에 고도 정보가 없어 시간을 추정할 수 없습니다.', + 'tours.estimate.basisDin': + '거리, 오르막, 내리막으로 등산로 표지 규칙(DIN 33466)에 따라 계산했으며 휴식은 포함하지 않습니다.', + 'tours.estimate.basisPace': '거리와 입력한 페이스로 계산했습니다.', }; export default tours; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 30d70a8b5..ca08dd44c 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -1326,5 +1326,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Hele tocht', 'tours.stages.totalPartial': 'bekend voor {known} van {of} etappes', 'tours.stages.openInPlanner': 'Deze etappe openen in de planner', + 'tours.estimate.label': 'Geschatte tijd', + 'tours.estimate.short': 'Geschat', + 'tours.estimate.unknown': 'Onbekend', + 'tours.estimate.noHeights': 'De track heeft geen hoogtes, dus de tijd kan niet worden geschat.', + 'tours.estimate.basisDin': + 'Berekend uit afstand, stijging en daling volgens de regel van de wandelwegwijzers (DIN 33466), zonder pauzes.', + 'tours.estimate.basisPace': 'Berekend uit de afstand bij het ingevoerde tempo.', }; export default tours; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index db54834df..5816666ff 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -1359,5 +1359,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Cała trasa', 'tours.stages.totalPartial': 'znane dla {known} z {of} etapów', 'tours.stages.openInPlanner': 'Otwórz ten etap w planerze', + 'tours.estimate.label': 'Szacowany czas', + 'tours.estimate.short': 'Szacunek', + 'tours.estimate.unknown': 'Nieznany', + 'tours.estimate.noHeights': 'Ślad nie ma wysokości, więc nie da się oszacować czasu.', + 'tours.estimate.basisDin': + 'Wyliczony z dystansu, podejść i zejść według reguły drogowskazów turystycznych (DIN 33466), bez przerw.', + 'tours.estimate.basisPace': 'Wyliczony z dystansu przy podanym tempie.', }; export default tours; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 07d6bc95b..c701a7099 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -1365,5 +1365,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Весь маршрут', 'tours.stages.totalPartial': 'известно для {known} из {of} этапов', 'tours.stages.openInPlanner': 'Открыть этот этап в планировщике', + 'tours.estimate.label': 'Расчётное время', + 'tours.estimate.short': 'Оценка', + 'tours.estimate.unknown': 'Неизвестно', + 'tours.estimate.noHeights': 'В треке нет высот, поэтому время оценить нельзя.', + 'tours.estimate.basisDin': + 'Рассчитано по расстоянию, набору и сбросу высоты по правилу туристических указателей (DIN 33466), без перерывов.', + 'tours.estimate.basisPace': 'Рассчитано по расстоянию при введённом темпе.', }; export default tours; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 73bfd3c8a..22ed453a1 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -1312,5 +1312,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Celá trasa', 'tours.stages.totalPartial': 'známe pre {known} z {of} etáp', 'tours.stages.openInPlanner': 'Otvoriť túto etapu v plánovači', + 'tours.estimate.label': 'Odhadovaný čas', + 'tours.estimate.short': 'Odhad', + 'tours.estimate.unknown': 'Neznámy', + 'tours.estimate.noHeights': 'Trasa nemá výšky, preto nemožno čas odhadnúť.', + 'tours.estimate.basisDin': + 'Vypočítané zo vzdialenosti, stúpania a klesania podľa pravidla turistických smerovníkov (DIN 33466), bez prestávok.', + 'tours.estimate.basisPace': 'Vypočítané zo vzdialenosti pri zadanom tempe.', }; export default tours; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 7ae620d50..2acfa6aaf 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -1295,5 +1295,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Hela turen', 'tours.stages.totalPartial': 'känt för {known} av {of} etapper', 'tours.stages.openInPlanner': 'Öppna etappen i planeraren', + 'tours.estimate.label': 'Uppskattad tid', + 'tours.estimate.short': 'Uppskattad', + 'tours.estimate.unknown': 'Okänd', + 'tours.estimate.noHeights': 'Spåret har inga höjder, så tiden kan inte uppskattas.', + 'tours.estimate.basisDin': + 'Beräknad från sträcka, stigning och nedstigning enligt vandringsskyltarnas regel (DIN 33466), utan pauser.', + 'tours.estimate.basisPace': 'Beräknad från sträckan i angivet tempo.', }; export default tours; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 183b0ab53..736f0fcde 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -1240,5 +1240,11 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'ทั้งเส้นทาง', 'tours.stages.totalPartial': 'ทราบสำหรับ {known} จาก {of} ช่วง', 'tours.stages.openInPlanner': 'เปิดช่วงนี้ในตัววางแผน', + 'tours.estimate.label': 'เวลาโดยประมาณ', + 'tours.estimate.short': 'ประมาณ', + 'tours.estimate.unknown': 'ไม่ทราบ', + 'tours.estimate.noHeights': 'เส้นทางไม่มีข้อมูลความสูง จึงประมาณเวลาไม่ได้', + 'tours.estimate.basisDin': 'คำนวณจากระยะทาง ระยะขึ้นและลงตามกฎป้ายบอกทางเดินป่า (DIN 33466) ไม่รวมเวลาพัก', + 'tours.estimate.basisPace': 'คำนวณจากระยะทางตามเพซที่กรอกไว้', }; export default tours; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index aca76aeb6..6ac0e97d0 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [ { file: 'tours', - generalKeyCount: 1177, + generalKeyCount: 1183, modules: toursModules, }, { diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 250feb097..72fc35315 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -1302,5 +1302,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Tüm tur', 'tours.stages.totalPartial': '{of} etabın {known} tanesi için biliniyor', 'tours.stages.openInPlanner': 'Bu etabı planlayıcıda aç', + 'tours.estimate.label': 'Tahmini süre', + 'tours.estimate.short': 'Tahmini', + 'tours.estimate.unknown': 'Bilinmiyor', + 'tours.estimate.noHeights': 'İzde yükseklik yok, bu yüzden süresi tahmin edilemiyor.', + 'tours.estimate.basisDin': + 'Mesafe, tırmanış ve inişten yürüyüş tabelası kuralına (DIN 33466) göre hesaplandı, molalar hariç.', + 'tours.estimate.basisPace': 'Mesafeden, girilen tempoyla hesaplandı.', }; export default tours; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index d7acccee1..624c43861 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -1351,5 +1351,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Увесь маршрут', 'tours.stages.totalPartial': 'відомо для {known} з {of} етапів', 'tours.stages.openInPlanner': 'Відкрити цей етап у планувальнику', + 'tours.estimate.label': 'Орієнтовний час', + 'tours.estimate.short': 'Оцінка', + 'tours.estimate.unknown': 'Невідомо', + 'tours.estimate.noHeights': 'У треку немає висот, тому час оцінити не можна.', + 'tours.estimate.basisDin': + 'Розраховано за відстанню, набором і скиданням висоти за правилом туристичних вказівників (DIN 33466), без перерв.', + 'tours.estimate.basisPace': 'Розраховано за відстанню при введеному темпі.', }; export default tours; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index bdb96d780..6f051f920 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -1284,5 +1284,12 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': 'Cả hành trình', 'tours.stages.totalPartial': 'đã biết cho {known} trên {of} chặng', 'tours.stages.openInPlanner': 'Mở chặng này trong trình lập kế hoạch', + 'tours.estimate.label': 'Thời gian ước tính', + 'tours.estimate.short': 'Ước tính', + 'tours.estimate.unknown': 'Không rõ', + 'tours.estimate.noHeights': 'Lộ trình không có độ cao, nên không thể ước tính thời gian.', + 'tours.estimate.basisDin': + 'Tính từ quãng đường, độ cao lên và xuống theo quy tắc biển chỉ dẫn leo núi (DIN 33466), không tính nghỉ.', + 'tours.estimate.basisPace': 'Tính từ quãng đường theo tốc độ đã nhập.', }; export default tours; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index c450378a4..039448b33 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -1203,5 +1203,11 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': '整條路線', 'tours.stages.totalPartial': '{of} 個階段中 {known} 個已知', 'tours.stages.openInPlanner': '在規劃器中開啟此階段', + 'tours.estimate.label': '預估時間', + 'tours.estimate.short': '預估', + 'tours.estimate.unknown': '未知', + 'tours.estimate.noHeights': '軌跡沒有海拔資料,無法估算時間。', + 'tours.estimate.basisDin': '依健行路標規則(DIN 33466)由距離、爬升與下降計算,不含休息。', + 'tours.estimate.basisPace': '依距離與輸入的配速計算。', }; export default tours; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 58445aac6..e04470bb6 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -1204,5 +1204,11 @@ const tours: TranslationStrings = { 'tours.stages.wholeTour': '整条路线', 'tours.stages.totalPartial': '{of} 个阶段中 {known} 个已知', 'tours.stages.openInPlanner': '在规划器中打开此阶段', + 'tours.estimate.label': '预计用时', + 'tours.estimate.short': '预计', + 'tours.estimate.unknown': '未知', + 'tours.estimate.noHeights': '轨迹没有海拔数据,无法估算用时。', + 'tours.estimate.basisDin': '按徒步路标规则(DIN 33466)根据距离、爬升和下降计算,不含休息。', + 'tours.estimate.basisPace': '按距离和输入的配速计算。', }; export default tours; diff --git a/shared/src/index.ts b/shared/src/index.ts index 6832d48d0..93f581558 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -57,6 +57,7 @@ export * from './roadtrip/roadtrip.schema'; export * from './place/track-colors'; export * from './tours/tours.schema'; export * from './tours/run-pace'; +export * from './tours/tour-time-estimate'; export * from './tours/ski.schema'; export * from './tours/tour-stages'; export * from './tours/tour-provenance.schema'; diff --git a/shared/src/tours/tour-time-estimate.spec.ts b/shared/src/tours/tour-time-estimate.spec.ts new file mode 100644 index 000000000..aad05b89c --- /dev/null +++ b/shared/src/tours/tour-time-estimate.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/shared/src/tours/tour-time-estimate.ts b/shared/src/tours/tour-time-estimate.ts new file mode 100644 index 000000000..45168673b --- /dev/null +++ b/shared/src/tours/tour-time-estimate.ts @@ -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; + +/** + * 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; + +/** 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; +} diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index a8113a88e..6653e2471 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -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. */