mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +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:
@@ -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(<TourDayRowPills tour={imported} compact />);
|
||||
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(<TourDayRowPills tour={{ ...imported, planned_duration_minutes: 300 }} />);
|
||||
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(<TourDayRowFacts tour={imported} mobile />);
|
||||
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(<TourDayRowFacts tour={{ ...imported, elevation_gain: null, elevation_loss: null }} mobile />);
|
||||
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(<TourDayRowFacts tour={tour} />);
|
||||
expect(screen.queryByTestId('trip-plan-tour-estimate')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof estimateLabel>[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 && (
|
||||
<TourDayRowPill key="time" icon={Clock} value={shown} label={timeParts.join(', ')} testId="trip-plan-tour-time" />
|
||||
);
|
||||
@@ -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 (
|
||||
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{facts.map((fact) => (
|
||||
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
{facts.map(({ said, ...fact }) => (
|
||||
<Fact key={fact.testId} {...fact} label={said ?? `${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
))}
|
||||
{progress && (
|
||||
<Fact
|
||||
|
||||
@@ -285,6 +285,16 @@ describe('TourDetailDialog', () => {
|
||||
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(<TourDetailDialog {...defaultProps} tour={imported} />)
|
||||
expect(screen.getByLabelText('Estimated time: ≈ 4 h')).toHaveTextContent('≈ 4 h')
|
||||
expect(screen.getByText('Estimated time')).toBeInTheDocument()
|
||||
view.unmount()
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...imported, elevation_gain: null, elevation_loss: null }} />)
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown>) => `${key}:${String(params?.time)}`
|
||||
expect(plannedTotalText({ plannedTotalMinutes: null, atLeast: false }, t)).toBeNull()
|
||||
|
||||
@@ -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<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>
|
||||
& Partial<Pick<TourListItem, 'tour_type' | 'station_count' | 'station_stay_minutes' | 'station_stays_unknown'>>
|
||||
& Partial<Pick<TourListItem, 'distance' | 'elevation_gain' | 'elevation_loss' | 'run_pace_seconds_per_km'>>
|
||||
|
||||
/** 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<TourPlannedTimes, 'plannedTotalMinutes' | 'atLeast' | 'estimate'>
|
||||
): { 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
|
||||
|
||||
Reference in New Issue
Block a user