feat(tours): plan a run at your own pace

RUN-01. Run joins the tour types in the planner, with Running as its first
profile, chosen on purpose like a bike tour's. A run is routed on foot for
runnable, mostly paved ways: tracks and ferries only where there is no other
way (use_tracks 0, use_ferry 0, measured against the public Valhalla), no
hiking limit and no via ferrata. The runner types a pace in min/km; the
running time is the distance at that pace and nothing else, shown under the
field as badges (8 km x 6:00 /km = 48 min). TREK suggests no pace: without
one the time stays unknown and Save waits, text that is no pace or a pace
outside 2:00 to 20:00 is marked. The info dot, the overview and the head say
climbs, descents and difficulty are not in the time, and the time reads as
planned everywhere, so it never passes for a recorded one; the heights are
shown as for any tour on land.

Type, profile, pace and route come back on opening, survive a draft reload,
an undo of the type, a copy and a take-over; switching to another type drops
profile and pace. Day assignment, the 3D terrain view (with topo and
satellite, no ferratas) and a routing failure keeping the last route work as
for every tour. A GPX file can be imported as a run with its profile and no
pace, and planned over as a run. Runs export to GPX as running and get their
own places along the way (water, toilets, parks, viewpoints).

The server keeps tours.run_profile (CHECK list, already holding trail for
RUN-02, since SQLite cannot widen a CHECK in place) and
tours.run_pace_seconds_per_km (120 to 1200), switches the run type on,
refuses a run without a profile and run fields on any other type, and
list_tours names both with what the planned time does not include.
This commit is contained in:
Maurice
2026-10-20 12:51:29 +02:00
parent cd407cf80c
commit c83eb42730
86 changed files with 2524 additions and 99 deletions
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { plannableTourTypeSchema, type TourListItem } from '@trek/shared';
import { ArrowUp, CheckCheck, Clock, Gauge, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -8,6 +8,7 @@ import { WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { runPaceText } from './run/runPaceText';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
@@ -15,9 +16,19 @@ import { useWalkProgress } from './walk/walkProgress';
import { walkStaysText } from './walk/walkTimeFields';
import { WinterCornerMark } from './winter/winterMarks';
/** Whether the app names this type: one it plans, not a key only the catalogue holds. */
function knownType(type: string): boolean {
return plannableTourTypeSchema.safeParse(type).success;
}
/** The icon the time on the move goes by: a ride, a run or a paddle by its type's own, any walk by footprints. */
function movingIconType(type: string): string {
return type === 'kayak' || type === 'bike' || type === 'run' ? type : 'walk';
}
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
const known = knownType(type);
return (
<span
data-testid="plan-tour-icon"
@@ -112,7 +123,7 @@ function TourGradeAvatar({
}) {
const { t } = useTranslation();
const level = tour.max_hiking_difficulty;
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
const known = knownType(tour.tour_type);
const label =
level != null
? t(`tours.planner.difficulty.t${level}`)
@@ -187,8 +198,11 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
const total = plannedTotalText(times, t);
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
const pace = runPaceText(tour);
const timeParts = [
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
// RUN-01: the pace the running time is planned at.
pace ? `${t('tours.run.pace')}: ${pace}` : null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
: null,
@@ -250,11 +264,13 @@ export default function TourDayRowFacts({
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
tourDayRowIcon(movingIconType(tour.tour_type)),
formatPlannedTourDuration(times.walkingMinutes),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
);
const pace = runPaceText(tour);
if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace');
if (times.breakMinutes != null && times.breakMinutes > 0)
add(
Pause,
@@ -51,6 +51,7 @@ import { TourGpxExportButton } from './TourGpxExportButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { RunHeadPills } from './run/RunHeadPills';
import { tourDayRowIcon } from './tourDayRowIcon';
import { tourDaysOf } from './tourDays';
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
@@ -209,7 +210,7 @@ function tourTimeFields(
}
/**
* The head's facts as pills: the type and a hike's winter label, where it came from, the time (its parts
* The head's facts as pills: the type, a hike's winter label or a run's profile and pace, where it came from, the time (its parts
* in the tooltip), distance, ascent, descent, and a caution.
*/
function TourHeadPills({
@@ -253,6 +254,8 @@ function TourHeadPills({
</span>
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
{/* RUN-01: a run's profile and the pace its time is planned at. */}
<RunHeadPills tour={tour} className={HEAD_PILL} />
{/* A paddle tour names its boat and its water, each a badge of its own. */}
{tour.boat_type && (
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
@@ -4,6 +4,7 @@ import {
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type RunProfile,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
@@ -28,6 +29,7 @@ import {
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { RunProfileField } from './run/RunFields';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
@@ -69,6 +71,24 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
);
}
/** What the dialog has chosen besides the type: a paddle import's boat and water, a bike or run import's profile. */
interface ImportChoices {
boat: PaddleBoatType | null;
water: PaddleWaterContext | null;
bikeProfile: BikeProfile;
runProfile: RunProfile;
}
/** What the import is asked to store the tracks as: the type and the fields that type names. */
function importFields(type: PlannableTourType, chosen: ImportChoices): TourGpxImportFields {
if (type === 'kayak' && chosen.boat && chosen.water)
return { tour_type: 'kayak', boat_type: chosen.boat, water_context: chosen.water };
if (type === 'bike') return { tour_type: 'bike', bike_profile: chosen.bikeProfile };
// RUN-01: a run comes in with its profile and no pace; its time stays unknown until one is planned from it.
if (type === 'run') return { tour_type: 'run', run_profile: chosen.runProfile };
return { tour_type: 'hike' };
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
@@ -90,6 +110,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const [runProfile, setRunProfile] = useState<RunProfile>('running');
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -103,12 +124,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
const focusPoints = useMemo(() => lines.flat(), [lines]);
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: type === 'bike'
? { tour_type: 'bike', bike_profile: bikeProfile }
: { tour_type: 'hike' };
const fields = importFields(type, { boat, water, bikeProfile, runProfile });
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -195,6 +211,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
/>
</div>
)}
{type === 'run' && <RunProfileField value={runProfile} onChange={setRunProfile} />}
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
@@ -363,6 +363,26 @@ describe('ToursSidebar', () => {
vi.restoreAllMocks()
})
it('RUN-01: a run import names its profile, on purpose, and sends no pace', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'laps.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-run'))
expect(screen.getByRole('button', { name: 'Running' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByTestId('gpx-preview-import'))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'run', run_profile: 'running' }))
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
@@ -9,9 +9,9 @@ import type { TourPlannerController } from '../planner/useTourPlanner';
import TourConversionSection from './TourConversionSection';
import { useGpxConversion } from './useGpxConversion';
const routing = vi.hoisted(() => ({ route: vi.fn() }));
const routing = vi.hoisted(() => ({ route: vi.fn(), run: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route }));
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route, routeRun: routing.run }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const imported: TourListItem = {
@@ -52,6 +52,7 @@ beforeEach(() => {
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
routing.route.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
routing.run.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
});
describe('useGpxConversion', () => {
@@ -165,6 +166,31 @@ describe('useGpxConversion', () => {
);
});
it('RUN-01: a run import is routed for its profile and planned as a run, its pace left to the runner', async () => {
const run = {
...imported,
tour_type: 'run' as const,
run_profile: 'running' as const,
max_hiking_difficulty: null,
};
const controller = planner({
mode: { type: 'view-gpx', placeId: run.place_id, tour: run },
readOnlyGpxTour: { tour: run, routeGeometry: null },
} as Partial<TourPlannerController>);
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
expect(routing.run).toHaveBeenCalledWith(result.current.points, 'running', expect.any(AbortSignal));
expect(routing.route).not.toHaveBeenCalled();
act(() => {
result.current.adopt('new');
});
const draft = vi.mocked(controller.startDerivedTour).mock.calls[0]?.[0];
expect(draft).toMatchObject({ tourType: 'run', runProfile: 'running' });
expect(draft).not.toHaveProperty('paddle');
});
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
@@ -2,7 +2,7 @@ import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { isTourRoute } from '../planner/tourRouteOutcome';
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
import { routeBikeTour, routeRun, routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
@@ -44,6 +44,8 @@ export function useGpxConversion(planner: TourPlannerController) {
const paddle = tour?.tour_type === 'kayak';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
// RUN-01: a run import is planned as a run for its profile; its pace is the runner's to enter.
const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null;
useEffect(() => () => abort.current?.abort(), []);
@@ -68,7 +70,9 @@ export function useGpxConversion(planner: TourPlannerController) {
: points;
const routed = bikeProfile
? await routeBikeTour(legs, bikeProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
: runProfile
? await routeRun(legs, runProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!isTourRoute(routed)) {
setState({ ...base, status: 'failed', error: 'routing' });
@@ -81,7 +85,7 @@ export function useGpxConversion(planner: TourPlannerController) {
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [bikeProfile, difficulty, paddle, tour, track]);
}, [bikeProfile, difficulty, paddle, runProfile, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
@@ -107,11 +111,12 @@ export function useGpxConversion(planner: TourPlannerController) {
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
...(runProfile ? { tourType: 'run' as const, runProfile } : {}),
});
if (started) setState(IDLE);
return started;
},
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
[bikeProfile, current.points, current.status, difficulty, paddle, runProfile, startDerivedTour, tour]
);
return useMemo(
@@ -81,6 +81,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
bike: t('tours.planner.namePlaceholder.bike'),
kayak: t('tours.planner.namePlaceholder.kayak'),
walk: t('tours.planner.namePlaceholder.walk'),
run: t('tours.planner.namePlaceholder.run'),
}[planner.tourType]
}
className={INPUT}
@@ -993,3 +993,25 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
expect(safetyRow()).not.toBeInTheDocument()
})
})
describe('a run in the planner rails (RUN-01)', () => {
const run = { mode: { type: 'new-draft' as const }, tourType: 'run' as const, isRun: true }
it('TOUR-PLANNER-RAIL-RUN-001: the overview names the time as planned and says climbs and difficulty stay out of it', () => {
render(<TourPlannerToursRail planner={planner({ ...run, walkingDurationMinutes: 48, plannedTotalMinutes: 48 })} tours={[tour]} {...railProps} />)
const facts = screen.getAllByRole('definition').map(value => [value.previousElementSibling?.textContent, value.textContent])
expect(facts).toEqual(expect.arrayContaining([[expect.stringMatching(/^Running time \(planned\)/), '48 min']]))
expect(screen.getByRole('button', { name: 'How the running time is planned' })).toHaveAccessibleDescription(
'Distance × your pace and nothing else: climbs, descents and difficulty are not included. A plan, not a recorded time.'
)
})
it('TOUR-PLANNER-RAIL-RUN-002: the editor asks for the profile and pace, and a point off every runnable way says so', () => {
const waypoints = [{ id: 'a', lat: 48, lng: 11, role: 'start' as const }, { id: 'b', lat: 48.01, lng: 11.02, role: 'end' as const }]
render(<TourPlannerRail planner={planner({ ...run, runPaceMissing: true, waypoints, unreachable: { unreachable: 1, snappedMeters: 900 } })} />)
expect(screen.getByTestId('tour-planner-run-fields')).toBeInTheDocument()
expect(screen.getByText('Enter your pace to plan the running time and save this run.')).toBeInTheDocument()
expect(screen.getByTestId('tour-planner-unreachable')).toHaveTextContent('No runnable way reaches point 2')
expect(screen.getByTestId('tour-planner-unreachable')).toHaveTextContent('900 m')
})
})
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import type { PlannableTourType, TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowLeft, Circle, History, Loader2, MapPin, Mountain, Plus, Redo2, RotateCcw, Route, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
@@ -612,14 +612,24 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
onTakenOver?: (tour: TourListItem) => Promise<void>
}
/** The words of the time estimate: walking, riding (BIK-01) or paddling, each saying how it is reckoned. */
/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned. */
const ESTIMATE_KEYS = {
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' },
run: { labelKey: 'tours.run.timeEstimated', shortKey: 'tours.run.timeEstimatedShort', infoLabelKey: 'tours.run.timeEstimatedInfoLabel', infoKey: 'tours.run.timeEstimatedInfo' },
} as const
/** Which ways a point is out of reach of: paths for hikes and city walks, ways for bikes, runnable ways. */
const UNREACHABLE_WAYS: Record<PlannableTourType, 'hike' | 'bike' | 'run'> = {
hike: 'hike',
walk: 'hike',
kayak: 'hike',
bike: 'bike',
run: 'run',
}
/**
* A point no way of the tour's type reaches: the router would have bound it
* that far off, so no route is drawn to a place the tour does not go. The
@@ -635,7 +645,7 @@ function UnreachableNotice({ planner }: { planner: TourPlannerController }) {
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-col items-start gap-1.5">
<span data-testid="tour-planner-unreachable">
{t(planner.isBike ? 'tours.planner.unreachable.bike' : 'tours.planner.unreachable.hike', {
{t(`tours.planner.unreachable.${UNREACHABLE_WAYS[planner.tourType]}`, {
point: point.unreachable + 1,
distance: formatDistance(point.snappedMeters / 1000, distanceUnit),
})}
@@ -13,6 +13,7 @@ import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { crossesLake } from '../paddle/lakeStyleAdvice';
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
import { RunFields } from '../run/RunFields';
import { TourNotice } from '../tourParts';
import { TourTypeSelect } from './TourTypeSelect';
import type { TourPlannerController } from './useTourPlanner';
@@ -104,10 +105,11 @@ function BikeProfileField({ planner }: { planner: TourPlannerController }) {
}
/**
* PAD-01, BIK-01: what the tour is. A hike keeps its fields where they were; a
* bike tour names its profile; a paddle tour asks for exactly one boat and the
* water it is on, and offers the boat's planning speed beside the paddler's
* own. Nothing here puts the tour on a day.
* PAD-01, BIK-01, RUN-01: what the tour is. A hike keeps its fields where they
* were; a bike tour names its profile; a run its profile and the runner's pace;
* a paddle tour asks for exactly one boat and the water it is on, and offers
* the boat's planning speed beside the paddler's own. Nothing here puts the
* tour on a day.
*/
export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
const { t } = useTranslation();
@@ -130,6 +132,8 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
{planner.isBike && <BikeProfileField planner={planner} />}
{planner.isRun && <RunFields planner={planner} locked={locked} />}
{planner.isPaddle && (
<div data-testid="tour-planner-paddle-fields" className="flex min-w-0 flex-col gap-3">
<div className="min-w-0">
@@ -41,7 +41,7 @@ export function TourTypeSelect({
}) {
const { t } = useTranslation();
const valueId = useId();
const list = usePopoverList(PLANNER_TOUR_TYPES.indexOf(value), { estimatedHeight: 220 });
const list = usePopoverList(PLANNER_TOUR_TYPES.indexOf(value), { estimatedHeight: 270 });
const { open, toggle, triggerRef, listId } = list;
const label = t('tours.planner.tourType');
const pick = (type: PlannableTourType) => {
@@ -4,10 +4,12 @@ import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
paddleLockSchema,
plannableTourTypeSchema,
runProfileSchema,
winterActivitySchema,
type BikeProfile,
type HikingPreferences,
type PlannableTourType,
type RunProfile,
type TourMaxHikingDifficulty,
type WinterActivity,
} from '@trek/shared';
@@ -47,6 +49,9 @@ export interface DraftPayload {
bikeProfile?: BikeProfile;
/** HIK-01: the hike's winter label; absent in a draft older than it. */
winterActivity?: WinterActivity | null;
/** RUN-01: the profile a run is routed for, and its pace in seconds per km; absent in a draft older than them. */
runProfile?: RunProfile;
runPaceSecondsPerKm?: number | null;
}
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -87,11 +92,21 @@ function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
);
}
/**
* RUN-01: a run's pace as a draft keeps it, whole seconds. Text that was no
* pace yet (NaN in the planner) is written as JSON null and comes back as none.
*/
function optionalPace(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value > 0);
}
/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
return (
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
(draft.bikeProfile === undefined || bikeProfileSchema.safeParse(draft.bikeProfile).success) &&
(draft.runProfile === undefined || runProfileSchema.safeParse(draft.runProfile).success) &&
optionalPace(draft.runPaceSecondsPerKm) &&
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
(draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) &&
optionalDifficulty(draft.maxHikingDifficulty)
@@ -1,6 +1,7 @@
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannableTourTypeSchema,
runMinutesAtPace,
tourWebsiteSchema,
type BikeProfile,
type HikingPreferences,
@@ -8,12 +9,14 @@ import {
type PaddleLakeStyle,
type PaddleWaterContext,
type PlannableTourType,
type RunProfile,
type TourListItem,
type TourMaxHikingDifficulty,
type WinterActivity,
} from '@trek/shared';
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
import { hikingTimeMinutes } from '../hikingTime';
import { runPaceInvalid } from '../run/runPace';
import { walkTimesOf, type WalkTimes } from './stationModel';
/** The editable fields of a saved tour as the planner opens them, each with its empty value. */
@@ -30,6 +33,8 @@ export function openedTourFields(tour: TourListItem) {
tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType,
bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile,
winterActivity: tour.winter_activity ?? null,
runProfile: (tour.run_profile ?? 'running') as RunProfile,
runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null,
};
}
@@ -43,6 +48,9 @@ export interface TourPlannerFactsInput {
bikeProfile: BikeProfile;
/** HIK-01: a hike's winter label, none by default; it changes nothing the planner works out. */
winterActivity: WinterActivity | null;
/** RUN-01: the profile a run is routed for, and the pace it is planned at (null while none is entered). */
runProfile: RunProfile;
runPaceSecondsPerKm: number | null;
website: string;
plannedDurationMinutes: number | null;
breakAdditionalMinutes: number | null;
@@ -71,14 +79,25 @@ function invalidMinutes(value: number | null): boolean {
}
/**
* The time on the move: a paddle tour on the water at its speed, a bike tour
* by the router, which rides the profile at its speed and weighs the climbs, a
* hike by the signposts' rule over distance, climb and descent once the route
* has its heights, and by the router's own time before that.
* RUN-01: a run's time is its distance at the runner's pace and nothing else:
* no climb, no descent and no difficulty go in. Without a pace, or before the
* route is in, it is unknown; the router's walking time never stands in.
*/
function runMinutes({ runPaceSecondsPerKm: pace, routeAnalysis }: TourPlannerFactsInput): number | null {
if (pace === null || runPaceInvalid(pace) || !routeAnalysis) return null;
return runMinutesAtPace(routeAnalysis.distanceKm, pace);
}
/**
* The time on the move: a paddle tour on the water at its speed, a run at its
* pace, a bike tour by the router, which rides the profile at its speed and
* weighs the climbs, a hike by the signposts' rule over distance, climb and
* descent once the route has its heights, and by the router's own time before that.
*/
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
const { paddleEstimate, routeAnalysis } = input;
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
if (input.tourType === 'run') return runMinutes(input);
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
if (input.tourType === 'bike' || input.tourType === 'walk') return routerMinutes;
return routeAnalysis?.gain != null && routeAnalysis.loss != null
@@ -98,16 +117,20 @@ function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | nul
/**
* What the type adds to a save: a hike its routing limit and its winter label,
* a bike tour its profile, a paddle tour its boat, water and speed.
* a bike tour its profile, a run its profile and pace, a paddle tour its boat,
* water and speed.
*/
function typeFieldsOf(input: TourPlannerFactsInput) {
const isBike = input.tourType === 'bike';
const isRun = input.tourType === 'run';
if (input.tourType === 'kayak') {
const { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle } = input.paddle;
return {
tour_type: input.tourType,
max_hiking_difficulty: null,
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
boat_type: boatType,
water_context: waterContext,
paddle_speed_kmh: speedKmh,
@@ -122,6 +145,8 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
tour_type: input.tourType,
max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null,
bike_profile: isBike ? input.bikeProfile : null,
run_profile: isRun ? input.runProfile : null,
run_pace_seconds_per_km: isRun ? input.runPaceSecondsPerKm : null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
@@ -138,6 +163,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
*/
export function tourPlannerFacts(input: TourPlannerFactsInput) {
const isPaddle = input.tourType === 'kayak';
const isRun = input.tourType === 'run';
const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60);
const walkingDurationMinutes = movingMinutes(input, routerMinutes);
@@ -152,6 +178,9 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
// A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up.
breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes),
paddleIncomplete: isPaddle && (boatType === null || waterContext === null),
/** RUN-01: a run is planned with a pace; without one Save waits, and a pace that is none is marked. */
runPaceMissing: isRun && input.runPaceSecondsPerKm === null,
runPaceInvalid: isRun && runPaceInvalid(input.runPaceSecondsPerKm),
walkingDurationMinutes,
/** CW-02: a city walk's walking time, stays and total; null for any other type. */
stationStays: walk,
@@ -163,9 +192,9 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
// A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once.
savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes,
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
// The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace.
savedDurationSeconds:
walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60,
walkingDurationMinutes === null ? (isPaddle || isRun ? null : durationSeconds) : walkingDurationMinutes * 60,
typeFields: typeFieldsOf(input),
};
}
@@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { tourPlannerFacts, type TourPlannerFactsInput } from './tourPlannerFacts';
import { openedTourFields, tourPlannerFacts, type TourPlannerFactsInput } from './tourPlannerFacts';
import { usePlannerHistory } from './usePlannerHistory';
import { useTourDraftStorage } from './useTourDraftStorage';
@@ -10,6 +10,8 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
hikingPreferences: null,
bikeProfile: 'touring',
winterActivity: null,
runProfile: 'running',
runPaceSecondsPerKm: null,
website: '',
plannedDurationMinutes: null,
breakAdditionalMinutes: null,
@@ -77,6 +79,67 @@ describe('tourPlannerFacts', () => {
}
});
it('RUN-01: times a run as its distance at the pace, nothing else, and saves its profile and pace', () => {
// 8 km with 600 m up and down at 6:00 min/km: 48 minutes, the climb left out.
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
const facts = tourPlannerFacts(
hike({ tourType: 'run', runPaceSecondsPerKm: 360, routeAnalysis: analysis, durationSeconds: 7200 })
);
expect(facts.walkingDurationMinutes).toBe(48);
expect(facts.plannedTotalMinutes).toBe(48);
expect(facts.savedDurationSeconds).toBe(48 * 60);
expect(facts.runPaceMissing).toBe(false);
expect(facts.runPaceInvalid).toBe(false);
expect(facts.typeFields).toMatchObject({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
max_hiking_difficulty: null,
hiking_preferences: null,
winter_activity: null,
bike_profile: null,
});
const withBreak = hike({
tourType: 'run',
runPaceSecondsPerKm: 360,
routeAnalysis: analysis,
breakAdditionalMinutes: 12,
});
expect(tourPlannerFacts(withBreak).plannedTotalMinutes).toBe(60);
});
it('RUN-01: a run without a pace, or with one that is none, has no time and no save, never the router time', () => {
const analysis = { distanceKm: 8, gain: 0, loss: 0 } as TourPlannerFactsInput['routeAnalysis'];
const missing = tourPlannerFacts(hike({ tourType: 'run', routeAnalysis: analysis, durationSeconds: 7200 }));
expect(missing.walkingDurationMinutes).toBeNull();
expect(missing.savedDurationSeconds).toBeNull();
expect(missing.runPaceMissing).toBe(true);
for (const pace of [Number.NaN, 0, -360, 1201]) {
const facts = tourPlannerFacts(hike({ tourType: 'run', runPaceSecondsPerKm: pace, routeAnalysis: analysis }));
expect(facts.runPaceInvalid).toBe(true);
expect(facts.walkingDurationMinutes).toBeNull();
}
// Before the route is in, the time waits for it.
expect(tourPlannerFacts(hike({ tourType: 'run', runPaceSecondsPerKm: 360 })).walkingDurationMinutes).toBeNull();
// Another type carries neither, and is never held back by a pace.
const bike = tourPlannerFacts(hike({ tourType: 'bike', runPaceSecondsPerKm: 360 }));
expect(bike.typeFields).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
expect([bike.runPaceMissing, bike.runPaceInvalid]).toEqual([false, false]);
const paddle = tourPlannerFacts(hike({ tourType: 'kayak', runPaceSecondsPerKm: 360 }));
expect(paddle.typeFields).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
});
it('RUN-01: opens a saved run with its profile and pace, and any tour cached before them with none', () => {
const run = { name: 'River loop', tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 330 };
expect(openedTourFields(run as Parameters<typeof openedTourFields>[0])).toMatchObject({
tourType: 'run',
runProfile: 'running',
runPaceSecondsPerKm: 330,
});
const hikeOpened = openedTourFields({ name: 'Ridge', tour_type: 'hike' } as Parameters<typeof openedTourFields>[0]);
expect(hikeOpened).toMatchObject({ runProfile: 'running', runPaceSecondsPerKm: null });
});
it('holds the fields to their bounds, and a total that runs past a day without an override', () => {
const facts = tourPlannerFacts(
hike({ website: 'http://plain.example', plannedDurationMinutes: 1.5, breakAdditionalMinutes: -1 })
@@ -237,4 +300,23 @@ describe('useTourDraftStorage', () => {
localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' }));
expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull();
});
it('RUN-01: gives back a draft with its run profile and pace, and drops one it cannot read', () => {
localStorage.clear();
const draft = { version: 10, name: 'River loop', waypoints: [{ id: 'a', lat: 48, lng: 11, role: 'start' }] };
const run = { ...draft, tourType: 'run', runProfile: 'running', runPaceSecondsPerKm: 345 };
localStorage.setItem('tour-draft-7', JSON.stringify(run));
expect(renderHook(() => useTourDraftStorage(7)).result.current.restored).toMatchObject({
tourType: 'run',
runProfile: 'running',
runPaceSecondsPerKm: 345,
});
localStorage.setItem('tour-draft-8', JSON.stringify({ ...draft, runPaceSecondsPerKm: null }));
const unpaced = renderHook(() => useTourDraftStorage(8)).result.current.restored;
expect(unpaced).toMatchObject({ runPaceSecondsPerKm: null });
localStorage.setItem('tour-draft-9', JSON.stringify({ ...draft, runProfile: 'sprint' }));
expect(renderHook(() => useTourDraftStorage(9)).result.current.restored).toBeNull();
localStorage.setItem('tour-draft-10', JSON.stringify({ ...draft, runPaceSecondsPerKm: -5 }));
expect(renderHook(() => useTourDraftStorage(10)).result.current.restored).toBeNull();
});
});
@@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => ({
valhallaTurn,
}))
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeCityWalk, routeWalkingTour, type TourRouteResult } from './tourRouting'
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeCityWalk, routeRun, routeWalkingTour, type TourRouteResult } from './tourRouting'
/** A route as the planner reads it, for the cases that expect one. */
const route = async (...args: Parameters<typeof routeWalkingTour>) => (await routeWalkingTour(...args)) as TourRouteResult | null
@@ -235,4 +235,14 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
expect(await routeCityWalk(waypoints)).toMatchObject({ distanceMeters: 1500, durationSeconds: 300 })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, { use_ferry: 0 })
})
it('TOUR-ROUTING-034: a run goes on foot, keeps off tracks and ferries where it can, with no hiking limit (RUN-01)', async () => {
valhallaRun.mockResolvedValueOnce(run([3, 4]))
const signal = new AbortController().signal
expect(await routeRun(waypoints, 'running', signal)).toMatchObject({ distanceMeters: 1500 })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], signal, { use_tracks: 0, use_ferry: 0 })
// A point no runnable way reaches is named, as for any tour on land.
valhallaRun.mockResolvedValueOnce(run([3, 900]))
expect(await routeRun(waypoints, 'running')).toEqual({ unreachable: 1, snappedMeters: 900 })
})
})
@@ -1,4 +1,4 @@
import type { BikeProfile, HikingPreferences, TourFixedLeg } from '@trek/shared'
import type { BikeProfile, HikingPreferences, RunProfile, TourFixedLeg } from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn, type CostingOptions } from '../../Map/valhallaRoute'
import { densifyLine, lineLengthM } from './tourFixedLegs'
@@ -119,6 +119,33 @@ export async function routeCityWalk(
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
/**
* RUN-01: what each run profile asks Valhalla's pedestrian costing for.
* Running keeps to runnable, mostly paved ways: farm and forest tracks are
* taken only where there is no other way (`use_tracks` 0), and a run is run,
* so a ferry only where no way on land exists (`use_ferry` 0). Checked with
* trace_attributes against the public instance on 20.10.2026 (shares of the
* length): Berlin S Grunewald into the forest went from 95 % tracks with 3 %
* paved to 67 % smooth paved paths over 3.58 km instead of 3.50 km; Munich
* Großhesseloher Brücke to Pullach from 79 % to 88 % smooth asphalt and no
* track left; Marienplatz to the Englischer Garten stayed as it was. No hiking
* limit is sent, so the router keeps to the ways it takes for walking anyway
* (T1). The router's own time is a walker's and is never used: a run's time is
* its distance at the runner's pace. Nothing here says a way can be run.
*/
const RUN_COSTING: Record<RunProfile, Readonly<CostingOptions>> = {
running: { use_tracks: 0, use_ferry: 0 },
}
/** RUN-01: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
export async function routeRun(
waypoints: LandWaypoint[],
profile: RunProfile,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', RUN_COSTING[profile], signal)
}
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
export async function routeBikeTour(
waypoints: LandWaypoint[],
@@ -0,0 +1,214 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeRun, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
routeWalkingTour: vi.fn(),
routeRun: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeRun, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-RUN-001 to FE-TOUR-RUN-007
const routed = {
coordinates: [
[48, 11],
[48.01, 11.02],
] as [number, number][],
distanceMeters: 1860,
// A walker's time: a run never takes it.
durationSeconds: 1500,
};
const enriched = [
[48, 11, 520],
[48.01, 11.02, 560],
] as [number, number, number][];
const savedRun = {
place_id: 91,
name: 'River loop',
tour_type: 'run' as const,
run_profile: 'running' as const,
run_pace_seconds_per_km: 330,
distance: 1.86,
elevation_gain: 40,
elevation_loss: 0,
duration: 10,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
revision: 3,
};
const savedPoints = [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
];
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
async function plannedRun(tripId: number) {
const hook = renderHook(() => useTourPlanner({ tripId }));
act(() => {
hook.result.current.setName('River loop');
hook.result.current.setTourType('run');
hook.result.current.addWaypoint(48, 11);
hook.result.current.addWaypoint(48.01, 11.02);
});
await settle();
return hook;
}
describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
routeRun.mockReset().mockResolvedValue(routed);
enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset();
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-RUN-001: a run is routed on foot for its profile, and has no time and no save until a pace is entered', async () => {
const { result } = await plannedRun(71);
expect(result.current.isRun).toBe(true);
expect(result.current.runProfile).toBe('running');
expect(routeRun).toHaveBeenCalledWith(expect.any(Array), 'running', expect.any(AbortSignal));
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(result.current.status).toBe('ready');
// No pace of TREK's own: the time stays unknown and the router's walking time never stands in.
expect(result.current.runPace.paceSecondsPerKm).toBeNull();
expect(result.current.walkingDurationMinutes).toBeNull();
expect(result.current.runPaceMissing).toBe(true);
expect(result.current.canSave).toBe(false);
});
it('FE-TOUR-RUN-002: the time is the distance at the pace, and a new pace plans nothing again', async () => {
const { result } = await plannedRun(72);
routeRun.mockClear();
act(() => result.current.runPace.setPace(360));
await settle();
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
expect(distanceKm).toBeGreaterThan(1);
expect(result.current.walkingDurationMinutes).toBe(Math.round(distanceKm * 6));
expect(result.current.plannedTotalMinutes).toBe(Math.round(distanceKm * 6));
expect(routeRun).not.toHaveBeenCalled();
expect(result.current.canSave).toBe(true);
act(() => result.current.runPace.setPace(300));
expect(result.current.walkingDurationMinutes).toBe(Math.round(distanceKm * 5));
});
it('FE-TOUR-RUN-003: a pace that is no pace, or out of bounds, holds Save back', async () => {
const { result } = await plannedRun(73);
act(() => result.current.runPace.setPace(Number.NaN));
expect(result.current.runPaceInvalid).toBe(true);
expect(result.current.walkingDurationMinutes).toBeNull();
expect(result.current.canSave).toBe(false);
act(() => result.current.runPace.setPace(90));
expect(result.current.runPaceInvalid).toBe(true);
expect(result.current.canSave).toBe(false);
act(() => result.current.runPace.setPace(420));
expect(result.current.runPaceInvalid).toBe(false);
expect(result.current.canSave).toBe(true);
});
it('FE-TOUR-RUN-004: saves the type, profile, pace and planned time with the route', async () => {
createTour.mockResolvedValue({ tour: savedRun, waypoints: savedPoints });
const { result } = await plannedRun(74);
act(() => result.current.runPace.setPace(330));
await act(async () => {
await result.current.save();
});
const minutes = result.current.walkingDurationMinutes ?? 0;
expect(createTour).toHaveBeenCalledWith(
74,
expect.objectContaining({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 330,
max_hiking_difficulty: null,
bike_profile: null,
winter_activity: null,
duration_seconds: minutes * 60,
})
);
});
it('FE-TOUR-RUN-005: opens a saved run with its profile and pace, and another type saves neither', async () => {
detailTour.mockResolvedValue({ tour: savedRun, waypoints: savedPoints });
updateTour.mockResolvedValue({ tour: { ...savedRun, tour_type: 'hike', revision: 4 }, waypoints: savedPoints });
const { result } = renderHook(() => useTourPlanner({ tripId: 75 }));
await act(async () => {
await result.current.openTour({ place_id: 91 });
});
expect(result.current.tourType).toBe('run');
expect(result.current.runProfile).toBe('running');
expect(result.current.runPace.paceSecondsPerKm).toBe(330);
expect(result.current.hasUnsavedChanges).toBe(false);
// The same pace again, or the same profile, is no edit.
act(() => {
result.current.runPace.setPace(330);
result.current.setRunProfile('running');
});
expect(result.current.hasUnsavedChanges).toBe(false);
act(() => result.current.setTourType('hike'));
await settle();
await act(async () => {
await result.current.save();
});
expect(updateTour).toHaveBeenCalledWith(
75,
91,
expect.objectContaining({ tour_type: 'hike', run_profile: null, run_pace_seconds_per_km: null })
);
});
it('FE-TOUR-RUN-006: a draft comes back as a run with its pace, and a new tour starts as a hike without one', () => {
const first = renderHook(() => useTourPlanner({ tripId: 76 }));
act(() => {
first.result.current.setTourType('run');
first.result.current.runPace.setPace(345);
});
first.unmount();
expect(JSON.parse(localStorage.getItem('tour-draft-76') || 'null')).toMatchObject({
tourType: 'run',
runProfile: 'running',
runPaceSecondsPerKm: 345,
});
const again = renderHook(() => useTourPlanner({ tripId: 76 }));
expect(again.result.current.tourType).toBe('run');
expect(again.result.current.runPace.paceSecondsPerKm).toBe(345);
act(() => again.result.current.startNewTour(true));
expect(again.result.current.tourType).toBe('hike');
expect(again.result.current.runPace.paceSecondsPerKm).toBeNull();
});
it('FE-TOUR-RUN-007: choosing run is one step of the history and the pace none of it', () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 77 }));
act(() => result.current.setTourType('run'));
act(() => result.current.runPace.setPace(360));
act(() => result.current.undo());
expect(result.current.tourType).toBe('hike');
expect(result.current.runPace.paceSecondsPerKm).toBe(360);
act(() => result.current.redo());
expect(result.current.tourType).toBe('run');
expect(result.current.runProfile).toBe('running');
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
@@ -19,6 +19,7 @@ import { paddleEstimateOf } from './paddlePlanOf'
import { adoptChangedPoints } from './paddleRouting'
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
import { useRunPace } from '../run/useRunPace'
/** What a tour planned from an import starts with. */
export interface DerivedTourDraft {
@@ -37,10 +38,11 @@ export interface DerivedTourDraft {
inPlace: boolean
/** P2C-03: the import's revision, which a save planned over in place names. */
revision?: number
/** A paddle import stays a paddle tour, with its boat and water, and a bike import a bike tour with its profile. */
/** A paddle import stays a paddle tour, with its boat and water, and a bike or run import its type with its profile. */
tourType?: PlannableTourType
paddle?: PaddleFieldValues
bikeProfile?: BikeProfile
runProfile?: RunProfile
/** CW-07: where the points came from, said in the planner until the draft is saved. */
source?: DraftSource
}
@@ -59,7 +61,7 @@ export type TourPlannerStatus =
/** One step of the history: the route, and what the route depends on. */
interface HistoryEntry {
waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; preferences: HikingPreferences | null; bikeProfile: BikeProfile
tourType: PlannableTourType
runProfile: RunProfile; tourType: PlannableTourType
}
/** CW-02: two steps plan the same route: their points alike but for a station's own fields, and the same choices. */
@@ -124,6 +126,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [hikingPreferences, setHikingPreferencesState] = useState<HikingPreferences | null>(restored?.hikingPreferences ?? null)
const [bikeProfile, setBikeProfileState] = useState<BikeProfile>(restored?.bikeProfile ?? 'touring')
const [winterActivity, setWinterActivityState] = useState<WinterActivity | null>(restored?.winterActivity ?? null)
const [runProfile, setRunProfileState] = useState<RunProfile>(restored?.runProfile ?? 'running')
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const history = usePlannerHistory<HistoryEntry>()
@@ -171,12 +174,13 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const difficultyRef = useRef(maxHikingDifficulty)
const preferencesRef = useRef(hikingPreferences)
const bikeProfileRef = useRef(bikeProfile)
const runProfileRef = useRef(runProfile)
const tourTypeRef = useRef(tourType)
waypointsRef.current = waypoints
difficultyRef.current = maxHikingDifficulty
const snapshot = useCallback((): HistoryEntry => ({
waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current,
bikeProfile: bikeProfileRef.current, tourType: tourTypeRef.current,
bikeProfile: bikeProfileRef.current, runProfile: runProfileRef.current, tourType: tourTypeRef.current,
}), [])
useEffect(() => {
@@ -221,6 +225,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [invalidateDetailRequest])
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
const runPace = useRunPace(restored?.runPaceSecondsPerKm ?? null, markEdited)
const loadRunPace = runPace.load
const loadPaddle = paddle.load
const paddleBoat = paddle.boatType
// The router's own default where the tour names none, so a plan and its cache key agree.
@@ -233,7 +239,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [])
const forgetFocus = useCallback(() => setRouteProfileFocus(null), [])
const routing = useTourRoute({
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, paddleBoat, paddleLakeStyle, retryToken,
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, retryToken,
error, setStatus, setError, onCleared: forgetFocus, patchWaypoints,
})
const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds,
@@ -279,16 +285,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
/**
* The routing choices, as one step of the history: a hike's limit and its
* preferences (MF-08), a bike tour's profile (BIK-01). The route is planned again.
* preferences (MF-08), a bike tour's profile (BIK-01), a run's (RUN-01). The route is planned again.
*/
const setRoutingChoice = useCallback((change: {
difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null; bikeProfile?: BikeProfile
difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null; bikeProfile?: BikeProfile; runProfile?: RunProfile
}) => {
if (!editPermissionRef.current) return
const difficulty = change.difficulty ?? difficultyRef.current
const preferences = change.preferences === undefined ? preferencesRef.current : hikingPreferencesOf(change.preferences)
const profile = change.bikeProfile ?? bikeProfileRef.current
if (difficulty === difficultyRef.current && profile === bikeProfileRef.current
const run = change.runProfile ?? runProfileRef.current
if (difficulty === difficultyRef.current && profile === bikeProfileRef.current && run === runProfileRef.current
&& JSON.stringify(preferences) === JSON.stringify(preferencesRef.current)) return
invalidateDetailRequest()
interruptRouting()
@@ -297,9 +304,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
difficultyRef.current = difficulty
preferencesRef.current = preferences
bikeProfileRef.current = profile
runProfileRef.current = run
setMaxHikingDifficultyState(difficulty)
setHikingPreferencesState(preferences)
setBikeProfileState(profile)
setRunProfileState(run)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
@@ -308,6 +317,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const setMaxHikingDifficulty = useCallback((difficulty: TourMaxHikingDifficulty) => setRoutingChoice({ difficulty }), [setRoutingChoice])
const setHikingPreferences = useCallback((preferences: HikingPreferences | null) => setRoutingChoice({ preferences }), [setRoutingChoice])
const setBikeProfile = useCallback((profile: BikeProfile) => setRoutingChoice({ bikeProfile: profile }), [setRoutingChoice])
const setRunProfile = useCallback((profile: RunProfile) => setRoutingChoice({ runProfile: profile }), [setRoutingChoice])
const addWaypoint = useCallback((lat: number, lng: number) => {
if (!editPermissionRef.current) return
@@ -420,11 +430,13 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
difficultyRef.current = entry.difficulty
preferencesRef.current = entry.preferences
bikeProfileRef.current = entry.bikeProfile
runProfileRef.current = entry.runProfile
tourTypeRef.current = entry.tourType
setWaypoints(entry.waypoints)
setMaxHikingDifficultyState(entry.difficulty)
setHikingPreferencesState(entry.preferences)
setBikeProfileState(entry.bikeProfile)
setRunProfileState(entry.runProfile)
setTourTypeState(entry.tourType)
setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null)
setHasUnsavedChanges(true)
@@ -474,9 +486,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
/**
* PAD-01, BIK-01: hike, bike or paddle tour, as one step of the history with
* the route, since a paddle tour draws every leg by hand and a bike tour
* rides no via ferrata. Nothing is put on a day by the choice itself.
* PAD-01, BIK-01, RUN-01: the type, as one step of the history with the
* route, since a paddle tour draws every leg by hand and neither a bike tour
* nor a run takes a via ferrata. Nothing is put on a day by the choice itself.
*/
const setTourType = useCallback((value: PlannableTourType) => {
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
@@ -486,7 +498,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
recordStep(snapshot())
tourTypeRef.current = value
setTourTypeState(value)
const next = normalizePlannerWaypoints(value === 'bike' ? ridablePoints(waypointsRef.current) : waypointsRef.current, value === 'kayak')
const noFerrata = value === 'bike' || value === 'run'
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value === 'kayak')
waypointsRef.current = next
setWaypoints(next)
setHasUnsavedChanges(true)
@@ -508,15 +521,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
// CW-02: a city walk's stays, what of them is known.
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
const runPaceSecondsPerKm = runPace.paceSecondsPerKm
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, website, plannedDurationMinutes, breakAdditionalMinutes,
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm, website,
plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website, winterActivity])
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stays, tourType, waterContext, website,
winterActivity])
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays } = facts
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays,
runPaceMissing, runPaceInvalid } = facts
const returnToNeutral = useCallback(() => {
if (saveInFlightRef.current) return
@@ -541,6 +558,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setHikingPreferencesState(null)
bikeProfileRef.current = 'touring'
setBikeProfileState('touring')
runProfileRef.current = 'running'
setRunProfileState('running')
loadRunPace(null)
setWinterActivityState(null)
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
@@ -563,7 +583,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setElevationProfileExpanded(true)
setNewDraftGeneration(generation => generation + 1)
setMapFocusKey(key => key + 1)
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadPaddle, forgetDraft])
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadPaddle, loadRunPace, forgetDraft])
const forgetDeletedTour = useCallback((placeId: number) => {
deletedTourPlaceIdsRef.current.add(placeId)
@@ -615,6 +635,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
bikeProfileRef.current = draft.bikeProfile
setBikeProfileState(draft.bikeProfile)
}
if (draft.runProfile) {
runProfileRef.current = draft.runProfile
setRunProfileState(draft.runProfile)
}
setNameState(draft.name)
setDescriptionState(draft.description ?? '')
setWebsiteState(draft.website ?? '')
@@ -712,6 +736,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setHikingPreferencesState(opened.preferences)
bikeProfileRef.current = opened.bikeProfile
setBikeProfileState(opened.bikeProfile)
runProfileRef.current = opened.runProfile
setRunProfileState(opened.runProfile)
loadRunPace(opened.runPaceSecondsPerKm)
setWinterActivityState(opened.winterActivity)
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
@@ -741,7 +768,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setOpeningTourId(null)
}
}
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadPaddle, forgetDraft, tripId])
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadPaddle, loadRunPace, forgetDraft, tripId])
const retry = useCallback(() => {
if (waypointsRef.current.length < 2) return
@@ -773,13 +800,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity,
runProfile, runPaceSecondsPerKm,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
}
keepDraft(draft)
}, [breakAdditionalMinutes,
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
bikeProfile, winterActivity])
bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm])
useEffect(() => {
if (!hasUnsavedChanges) return
@@ -791,7 +819,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeCurrent && hasCompleteElevation(enrichedGeometry)
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid)
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid
/**
* Saves the draft: over the tour it edits, naming the revision it was loaded
@@ -880,6 +908,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
tripId, canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity,
runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid,
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
@@ -4,6 +4,7 @@ import type {
PaddleBoatType,
PaddleLakeStyle,
PlannableTourType,
RunProfile,
TourMaxHikingDifficulty,
} from '@trek/shared';
import {
@@ -29,6 +30,7 @@ import {
enrichTourElevations,
routeBikeTour,
routeCityWalk,
routeRun,
routeWalkingTour,
type TourElevationPoint,
type TourRoutePart,
@@ -45,6 +47,8 @@ export interface TourRouteInputs {
maxHikingDifficulty: TourMaxHikingDifficulty;
hikingPreferences: HikingPreferences | null;
bikeProfile: BikeProfile;
/** RUN-01: the profile a run is routed for. */
runProfile: RunProfile;
paddleBoat: PaddleBoatType | null;
paddleLakeStyle: PaddleLakeStyle;
/** Raised to plan the same points again. */
@@ -124,16 +128,23 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann
setError(failed ? 'paddle-route' : null);
}
// A hike or a bike tour on land, then the heights along it.
/**
* A route on land for the type of the draft: a bike tour and a run for their
* profile, a city walk, a hike within its limit.
*/
function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) {
const { waypoints, tourType, bikeProfile, runProfile, maxHikingDifficulty, hikingPreferences } = inputs;
if (tourType === 'bike') return routeBikeTour(waypoints, bikeProfile, signal);
if (tourType === 'run') return routeRun(waypoints, runProfile, signal);
if (tourType === 'walk') return routeCityWalk(waypoints, signal);
return routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
}
// A hike, a run or a bike tour on land, then the heights along it.
async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
const { waypoints, tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, setStatus, setError } = inputs;
const { setStatus, setError } = inputs;
setStatus('routing');
const routed =
tourType === 'bike'
? await routeBikeTour(waypoints, bikeProfile, signal)
: tourType === 'walk'
? await routeCityWalk(waypoints, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
const routed = await routeLand(inputs, signal);
if (stale()) return;
if (!isTourRoute(routed)) {
setUnreachable(routed);
@@ -168,9 +179,9 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
/**
* The route of the planner's draft, planned again a moment after its points
* or its choices change: a hike on foot within its limit, a bike tour for its
* profile (BIK-01), a paddle tour on the water by the server's router. A hike
* and a bike tour then get their heights; a later change makes an answer
* or its choices change: a hike on foot within its limit, a bike tour and a
* run for their profile (BIK-01, RUN-01), a paddle tour on the water by the
* server's router. A tour on land then gets its heights; a later change makes an answer
* still on its way stale, and a failure keeps the last route that was planned.
*/
export function useTourRoute(inputs: TourRouteInputs) {
@@ -181,6 +192,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
maxHikingDifficulty,
hikingPreferences,
bikeProfile,
runProfile,
paddleBoat,
paddleLakeStyle,
retryToken,
@@ -208,7 +220,15 @@ export function useTourRoute(inputs: TourRouteInputs) {
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]);
// What a route was planned for besides its points: a route of another type or profile is not this one's.
const choices = [tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, paddleBoat, paddleLakeStyle];
const choices = [
tourType,
bikeProfile,
runProfile,
maxHikingDifficulty,
hikingPreferences,
paddleBoat,
paddleLakeStyle,
];
const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`;
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
// so a change to them alone plans nothing again.
@@ -247,6 +267,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
maxHikingDifficulty,
hikingPreferences,
bikeProfile,
runProfile,
paddleBoat,
paddleLakeStyle,
setStatus,
@@ -287,6 +308,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
retryToken,
routeInputs,
runKey,
runProfile,
setError,
setStatus,
tourType,
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useState, type ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { RunFields, RunProfileField } from './RunFields';
import { runPaceInvalid } from './runPace';
// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-006
type Planner = ComponentProps<typeof RunFields>['planner'];
const analysis = { distanceKm: 8 } as Planner['routeAnalysis'];
/** The fields over a planner that keeps the pace it is given and times it as the real one does. */
function Stateful({
initial = null,
distance = analysis,
}: {
initial?: number | null;
distance?: Planner['routeAnalysis'];
}) {
const [pace, setPace] = useState<number | null>(initial);
const invalid = runPaceInvalid(pace);
const planner: Planner = {
runProfile: 'running',
setRunProfile: vi.fn(),
runPace: { paceSecondsPerKm: pace, setPace, load: setPace },
runPaceMissing: pace === null,
runPaceInvalid: invalid,
routeAnalysis: distance,
walkingDurationMinutes:
pace === null || invalid || !distance ? null : Math.round((distance.distanceKm * pace) / 60),
};
return (
<>
<RunFields planner={planner} locked={false} />
<output data-testid="pace">{pace === null ? 'none' : String(pace)}</output>
</>
);
}
const paceField = () => screen.getByRole('textbox');
describe('RunFields (RUN-01)', () => {
it('FE-TOUR-RUN-FIELDS-001: offers the profile on purpose, with what it asks the router for', () => {
const onChange = vi.fn();
render(<RunProfileField value="running" onChange={onChange} />);
const running = screen.getByRole('button', { name: 'tours.run.profile.running' });
expect(running).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('tours.run.profileHint.running')).toBeInTheDocument();
fireEvent.click(running);
expect(onChange).toHaveBeenCalledWith('running');
});
it('FE-TOUR-RUN-FIELDS-002: asks for a pace without offering one, and says a run is saved with one', () => {
render(<Stateful />);
expect(paceField()).toHaveValue('');
expect(paceField()).toHaveAttribute('placeholder', '0:00');
expect(screen.getByText('min/km')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('tours.run.paceNeeded');
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription(
'tours.run.paceInfo'
);
});
it('FE-TOUR-RUN-FIELDS-003: a pace typed shows the sum the time is: 8 km at 6:00 min/km is 48 minutes', () => {
render(<Stateful />);
fireEvent.focus(paceField());
fireEvent.change(paceField(), { target: { value: '6:00' } });
expect(screen.getByTestId('pace')).toHaveTextContent('360');
const sum = screen.getByTestId('tour-run-time-sum');
expect(sum).toHaveTextContent('8 km');
expect(sum).toHaveTextContent('6:00 /km');
expect(sum).toHaveTextContent('48 min');
expect(sum).toHaveAttribute('aria-label', 'tours.run.timeSum');
expect(screen.queryByRole('status')).toBeNull();
fireEvent.blur(paceField());
expect(paceField()).toHaveValue('6:00');
});
it('FE-TOUR-RUN-FIELDS-004: text that is no pace is kept and marked on leaving the field, and the sum goes', () => {
render(<Stateful initial={360} />);
fireEvent.focus(paceField());
fireEvent.change(paceField(), { target: { value: '6:75' } });
// While typing, the last readable pace stands.
expect(screen.getByTestId('pace')).toHaveTextContent('360');
fireEvent.blur(paceField());
expect(screen.getByTestId('pace')).toHaveTextContent('NaN');
expect(paceField()).toHaveValue('6:75');
expect(paceField()).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('alert')).toHaveTextContent('tours.run.paceInvalid');
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
// Back in the field, the text is there to fix, and a pace fixes it.
fireEvent.focus(paceField());
expect(paceField()).toHaveValue('6:75');
fireEvent.change(paceField(), { target: { value: '6:15' } });
fireEvent.blur(paceField());
expect(screen.getByTestId('pace')).toHaveTextContent('375');
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-TOUR-RUN-FIELDS-005: a pace out of bounds is marked, and clearing the field leaves no pace', () => {
render(<Stateful />);
fireEvent.focus(paceField());
fireEvent.change(paceField(), { target: { value: '1:30' } });
expect(screen.getByRole('alert')).toHaveTextContent('tours.run.paceInvalid');
fireEvent.change(paceField(), { target: { value: '' } });
fireEvent.keyDown(paceField(), { key: 'Enter' });
expect(screen.getByTestId('pace')).toHaveTextContent('none');
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-TOUR-RUN-FIELDS-006: no sum before the route is in, whatever the pace', () => {
render(<Stateful initial={300} distance={null} />);
expect(paceField()).toHaveValue('5:00');
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
});
});
@@ -0,0 +1,186 @@
import {
MAX_RUN_PACE_SECONDS_PER_KM,
MIN_RUN_PACE_SECONDS_PER_KM,
RUN_PROFILES,
type RunProfile,
} from '@trek/shared';
import { Activity, Clock, Gauge, Info, Ruler, type LucideIcon } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDistance } from '../../../utils/units';
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { FigureBadge } from '../paddle/FigureBadge';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { InfoDot, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH } from './runPace';
const PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity };
/**
* RUN-01: what a run is planned as, chosen on purpose like a bike tour's
* profile, with a line on the ways it asks the router for. The planner and
* the GPX import share it.
*/
export function RunProfileField({ value, onChange }: { value: RunProfile; onChange: (profile: RunProfile) => void }) {
const { t } = useTranslation();
return (
<div data-testid="tour-run-profile" className="min-w-0">
<span className={LABEL}>{t('tours.run.profile')}</span>
<Segmented<RunProfile>
value={value}
onChange={onChange}
label={t('tours.run.profile')}
fill
options={RUN_PROFILES.map((profile) => {
const Icon = PROFILE_ICON[profile];
return {
value: profile,
label: t(`tours.run.profile.${profile}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
<span className="mt-1.5 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.run.profileHint.${value}`)}
</span>
</div>
);
}
type RunPlanner = Pick<
TourPlannerController,
'runProfile' | 'setRunProfile' | 'runPace' | 'runPaceMissing' | 'runPaceInvalid' | 'routeAnalysis'
> & { walkingDurationMinutes: number | null };
/**
* The pace as min:s per km. A readable pace reaches the planner while it is
* typed; text that is no pace reaches it as NaN on leaving the field, so the
* planner marks it and holds Save back, and the text stays for the runner to
* fix. The field never offers a pace of its own: the placeholder is the format.
*/
function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolean }) {
const [draft, setDraft] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const { paceSecondsPerKm: pace, setPace } = planner.runPace;
const unreadable = pace !== null && Number.isNaN(pace);
const shown = pace === null || unreadable ? '' : formatRunPace(pace);
const showDraft = draft !== null && (focused || unreadable);
return (
<div className="relative">
<input
id="tour-planner-run-pace"
type="text"
inputMode="text"
autoComplete="off"
maxLength={RUN_PACE_INPUT_MAX_LENGTH}
placeholder="0:00"
disabled={locked}
value={showDraft ? draft : shown}
onFocus={() => {
setFocused(true);
// Text that is no pace yet stays to be fixed; otherwise editing starts from the pace as it reads.
setDraft(unreadable ? (draft ?? '') : shown);
}}
onChange={(event) => {
setDraft(event.target.value);
const parsed = parseRunPace(event.target.value);
if (parsed === null || !Number.isNaN(parsed)) setPace(parsed);
}}
onBlur={() => {
setFocused(false);
if (draft !== null && Number.isNaN(parseRunPace(draft))) setPace(Number.NaN);
else setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
aria-invalid={planner.runPaceInvalid || undefined}
aria-describedby={planner.runPaceInvalid ? 'tour-planner-run-pace-error' : undefined}
className={`${INPUT} pe-16 tabular-nums`}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 end-3 flex items-center text-content-faint"
style={fs(12, 'body')}
>
min/km
</span>
</div>
);
}
/**
* The sum the running time is, laid out as badges: the distance in km, times
* the pace, is the time. Shown once all three are known, so the runner sees
* where the time comes from and that nothing else goes into it.
*/
function RunTimeSum({ planner }: { planner: RunPlanner }) {
const { t } = useTranslation();
const distanceKm = planner.routeAnalysis?.distanceKm ?? null;
const pace = planner.runPace.paceSecondsPerKm;
const minutes = planner.walkingDurationMinutes;
if (distanceKm === null || pace === null || planner.runPaceInvalid || minutes === null) return null;
// The pace is per km, so the sum reads in km whatever unit the app shows distances in.
const distance = formatDistance(distanceKm, 'metric');
const time = formatPlannedTourDuration(minutes);
const operator = 'font-geist font-semibold text-content-faint';
return (
<div
data-testid="tour-run-time-sum"
role="note"
aria-label={t('tours.run.timeSum', { distance, pace: formatRunPace(pace), time })}
className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5"
>
<FigureBadge icon={<Ruler size={11} strokeWidth={2} aria-hidden="true" />} value={distance} />
<span aria-hidden="true" className={operator} style={fs(12)}>
×
</span>
<FigureBadge icon={<Gauge size={11} strokeWidth={2} aria-hidden="true" />} value={`${formatRunPace(pace)} /km`} />
<span aria-hidden="true" className={operator} style={fs(12)}>
=
</span>
<FigureBadge icon={<Clock size={11} strokeWidth={2} aria-hidden="true" />} value={time} />
</div>
);
}
/**
* RUN-01: a run's profile and the pace it is planned at. The time is the
* distance at that pace; the info dot says climbs and difficulty stay out of
* it, and the sum below the field shows it. Without a pace the run cannot be
* saved, since its time would be unknown.
*/
export function RunFields({ planner, locked }: { planner: RunPlanner; locked: boolean }) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-run-fields" className="flex min-w-0 flex-col gap-3">
<RunProfileField value={planner.runProfile} onChange={planner.setRunProfile} />
<div className="min-w-0">
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-run-pace">
{t('tours.run.pace')}
</label>
<InfoDot label={t('tours.run.paceInfoLabel')} description={t('tours.run.paceInfo')} />
</div>
<RunPaceInput planner={planner} locked={locked} />
{planner.runPaceInvalid ? (
<span id="tour-planner-run-pace-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.run.paceInvalid', {
fastest: formatRunPace(MIN_RUN_PACE_SECONDS_PER_KM),
slowest: formatRunPace(MAX_RUN_PACE_SECONDS_PER_KM),
})}
</span>
) : (
<RunTimeSum planner={planner} />
)}
</div>
{planner.runPaceMissing && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.run.paceNeeded')}
</TourNotice>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import type { TourListItem } from '@trek/shared';
import { Activity, Gauge } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { runPaceText } from './runPaceText';
/**
* RUN-01: a run's profile and planned pace as pills of a tour's head, beside
* its type. The pace's tooltip says the running time is that pace over the
* distance and nothing more; a run without a pace shows no pace pill.
*/
export function RunHeadPills({
tour,
className,
}: {
tour: Pick<TourListItem, 'tour_type' | 'run_profile' | 'run_pace_seconds_per_km'>;
className: string;
}) {
const { t } = useTranslation();
if (tour.tour_type !== 'run') return null;
const pace = runPaceText(tour);
return (
<>
{tour.run_profile && (
<span data-testid="tour-detail-run-profile" className={className} style={fs(10.5)}>
<Activity size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
{t(`tours.run.profile.${tour.run_profile}`)}
</span>
)}
{pace && (
<Tooltip label={t('tours.run.paceInfo')}>
<span data-testid="tour-detail-run-pace" className={className} style={fs(10.5)}>
<Gauge size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
<span className="sr-only">{t('tours.run.pace')}:</span>
{pace}
</span>
</Tooltip>
)}
</>
);
}
@@ -0,0 +1,88 @@
import type { TourListItem } from '@trek/shared';
import { Activity } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import TourDayRowFacts, { TourDayRowPills } from '../TourDayRowFacts';
import { tourDayRowIcon } from '../tourDayRowIcon';
import { movingTimeLabel } from '../tourFacts';
import { tourMapLayersFor } from '../tourMapLayers';
import { TourGradeTile } from '../tourParts';
import { RunHeadPills } from './RunHeadPills';
import { runPaceText } from './runPaceText';
// FE-TOUR-RUN-MARK-001 to FE-TOUR-RUN-MARK-006
const run: TourListItem = {
place_id: 7,
name: 'River loop',
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
distance: 8,
elevation_gain: 40,
elevation_loss: 40,
duration: 48,
break_additional_minutes: null,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
has_waypoints: true,
};
describe('a run wherever a tour shows (RUN-01)', () => {
it('FE-TOUR-RUN-MARK-001: says a run pace as min:s per km, and nothing for a run without one or another type', () => {
expect(runPaceText(run)).toBe('6:00 min/km');
expect(runPaceText({ ...run, run_pace_seconds_per_km: null })).toBeNull();
expect(runPaceText({ ...run, run_pace_seconds_per_km: 5 })).toBeNull();
expect(runPaceText({ ...run, tour_type: 'hike' })).toBeNull();
});
it('FE-TOUR-RUN-MARK-002: the time is named as planned, and the run goes by its own icon and maps', () => {
expect(movingTimeLabel(run, 'walking', (key) => key)).toBe('tours.run.timeEstimated');
expect(tourDayRowIcon('run')).toBe(Activity);
expect(tourMapLayersFor('run')).toEqual({
bases: ['default', 'topo', 'satellite'],
defaultBase: 'default',
overlays: [],
});
});
it('FE-TOUR-RUN-MARK-003: the day plan shows the planned running time and the pace it is planned at', () => {
render(<TourDayRowFacts tour={run} />);
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Running time (planned): 48 min');
expect(screen.getByTestId('plan-tour-run-pace')).toHaveAccessibleName('Planned pace: 6:00 min/km');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('40');
});
it('FE-TOUR-RUN-MARK-004: an imported run without a pace shows no time and no pace, rather than made-up ones', () => {
render(<TourDayRowFacts tour={{ ...run, run_pace_seconds_per_km: null, duration: null, has_waypoints: false }} />);
expect(screen.queryByTestId('trip-plan-tour-walking-time')).toBeNull();
expect(screen.queryByTestId('plan-tour-run-pace')).toBeNull();
expect(screen.getByTestId('plan-tour-distance')).toBeInTheDocument();
});
it('FE-TOUR-RUN-MARK-005: the tour list names the pace in the time tooltip, and the tile the type', () => {
render(<TourDayRowPills tour={run} compact />);
expect(screen.getByTestId('trip-plan-tour-time')).toHaveAccessibleName(
'Running time (planned): 48 min, Planned pace: 6:00 min/km, Planned total duration: 48 min'
);
render(<TourGradeTile tour={run} />);
expect(screen.getByLabelText('Run')).toBeInTheDocument();
});
it('FE-TOUR-RUN-MARK-006: the head names the profile and the pace, a run without a pace only its profile', () => {
const { rerender } = render(<RunHeadPills tour={run} className="head-pill" />);
expect(screen.getByTestId('tour-detail-run-profile')).toHaveTextContent('Running');
const pace = screen.getByTestId('tour-detail-run-pace');
expect(pace).toHaveTextContent('6:00 min/km');
expect(pace).toHaveClass('head-pill');
rerender(<RunHeadPills tour={{ ...run, run_pace_seconds_per_km: null }} className="head-pill" />);
expect(screen.queryByTestId('tour-detail-run-pace')).toBeNull();
rerender(<RunHeadPills tour={{ ...run, tour_type: 'hike', run_profile: null }} className="head-pill" />);
expect(screen.queryByTestId('tour-detail-run-profile')).toBeNull();
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { formatRunPace, parseRunPace, runPaceInvalid } from './runPace';
// FE-TOUR-RUN-PACE-001 to FE-TOUR-RUN-PACE-003
describe('run pace (RUN-01)', () => {
it('FE-TOUR-RUN-PACE-001: reads min:s, whole minutes and minutes with a part, and nothing as no pace', () => {
expect(parseRunPace('6:00')).toBe(360);
expect(parseRunPace(' 5:07 ')).toBe(307);
expect(parseRunPace('6')).toBe(360);
expect(parseRunPace('5,5')).toBe(330);
expect(parseRunPace('5.25')).toBe(315);
expect(parseRunPace('')).toBeNull();
expect(parseRunPace(' ')).toBeNull();
});
it('FE-TOUR-RUN-PACE-002: anything else is no pace, seconds of 60 or more included', () => {
for (const text of ['5:60', '5:7', 'fast', '6 min', '-5', '5:30:00', '123']) {
expect(parseRunPace(text)).toBeNaN();
}
});
it('FE-TOUR-RUN-PACE-003: writes a pace as min:s and holds it to the bounds a run is saved with', () => {
expect(formatRunPace(360)).toBe('6:00');
expect(formatRunPace(307)).toBe('5:07');
expect(formatRunPace(1200)).toBe('20:00');
expect(runPaceInvalid(null)).toBe(false);
expect(runPaceInvalid(360)).toBe(false);
expect([Number.NaN, 0, 119, 1201].map(runPaceInvalid)).toEqual([true, true, true, true]);
});
});
@@ -0,0 +1,39 @@
import { runPaceSecondsPerKmSchema } from '@trek/shared';
/** The longest text the pace field takes: "12:30" and a little room. */
export const RUN_PACE_INPUT_MAX_LENGTH = 6;
const CLOCK = /^(\d{1,2}):(\d{2})$/;
const MINUTES = /^(\d{1,2})(?:[.,](\d{1,2}))?$/;
/**
* RUN-01: a pace as typed, read as whole seconds per kilometre. "5:30" is
* minutes and seconds, "6" whole minutes and "5,5" or "5.5" minutes and a
* part. An empty field is no pace (null); anything else, seconds of 60 or more
* included, is NaN, so the planner marks it and holds Save back while the
* text stays for the runner to fix. Whether the pace is in bounds is the
* contract's to say, not this one's.
*/
export function parseRunPace(text: string): number | null {
const value = text.trim();
if (value === '') return null;
const clock = CLOCK.exec(value);
if (clock) {
const seconds = Number(clock[2]);
return seconds < 60 ? Number(clock[1]) * 60 + seconds : Number.NaN;
}
const minutes = MINUTES.exec(value);
if (!minutes) return Number.NaN;
return Math.round(Number(`${minutes[1]}.${minutes[2] ?? '0'}`) * 60);
}
/** A pace as the runner reads it, minutes and seconds per kilometre: 360 is "6:00". */
export function formatRunPace(secondsPerKm: number): string {
const whole = Math.round(secondsPerKm);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
/** Whether a pace held by the planner is one a run can be saved with: unset, or in the contract's bounds. */
export function runPaceInvalid(secondsPerKm: number | null): boolean {
return secondsPerKm !== null && !runPaceSecondsPerKmSchema.safeParse(secondsPerKm).success;
}
@@ -0,0 +1,14 @@
import type { TourListItem } from '@trek/shared';
import { formatRunPace, runPaceInvalid } from './runPace';
/**
* RUN-01: a saved run's pace as its facts say it, "6:00 min/km", or null for
* another type and for a run without one (an import, a run whose runner has
* not entered one yet): unknown stays unknown.
*/
export function runPaceText(
tour: Pick<TourListItem, 'tour_type'> & Partial<Pick<TourListItem, 'run_pace_seconds_per_km'>>
): string | null {
const pace = tour.tour_type === 'run' ? (tour.run_pace_seconds_per_km ?? null) : null;
return pace === null || runPaceInvalid(pace) ? null : `${formatRunPace(pace)} min/km`;
}
@@ -0,0 +1,24 @@
import { useCallback, useState } from 'react';
/**
* RUN-01: the pace a run is planned at, seconds per kilometre, a field of the
* draft like its description: it plans no route again and is no step of the
* history, it only changes the time. Null while the runner entered none; NaN
* while the text typed is no pace. `onEdit` tells the planner the draft
* changed, and says whether it may.
*/
export function useRunPace(initial: number | null, onEdit: () => boolean) {
const [paceSecondsPerKm, setPaceState] = useState<number | null>(initial);
const setPace = useCallback(
(next: number | null) => {
if (!Object.is(next, paceSecondsPerKm) && onEdit()) setPaceState(next);
},
[onEdit, paceSecondsPerKm]
);
return {
paceSecondsPerKm,
setPace,
/** Loaded from a tour or a draft, or cleared; not an edit. */
load: setPaceState,
};
}
@@ -1,4 +1,4 @@
import { Bike, Footprints, Mountain, Route, Waves, type LucideIcon } from 'lucide-react';
import { Activity, Bike, Footprints, Mountain, Route, Waves, type LucideIcon } from 'lucide-react';
export function tourDayRowIcon(type: string): LucideIcon {
switch (type) {
@@ -8,6 +8,8 @@ export function tourDayRowIcon(type: string): LucideIcon {
return Bike;
case 'walk':
return Footprints;
case 'run':
return Activity;
case 'kayak':
return Waves;
default:
+4 -1
View File
@@ -47,7 +47,9 @@ export function withoutHeights<
/**
* What the time on the move is called: walking for a hike (in the wording the
* place asks for) and for a city walk (CW-02), riding for a bike tour,
* paddling for a paddle tour.
* paddling for a paddle tour, and for a run (RUN-01) the running time it is
* planned with, said as planned wherever it shows, so it never reads as a
* time someone recorded.
*/
export function movingTimeLabel(
tour: { tour_type?: string } | undefined,
@@ -56,5 +58,6 @@ export function movingTimeLabel(
): string {
if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort');
if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort');
if (tour?.tour_type === 'run') return t('tours.run.timeEstimated');
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
}
@@ -16,6 +16,8 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
* allows it) and satellite beside it; via ferratas.
* - Bike (BIK-01): the standard map, topo for the climbs, CyclOSM (BIK-06, while the admin
* allows it) and satellite. No ferratas, a bike tour climbs none.
* - Run (RUN-01): the standard map, topo for the climbs and satellite. No ferratas, a run
* takes none.
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
* no ferratas, those are hiking maps. Seamarks as an overlay.
* - Any other type (switched off, or one this build does not plan): the standard map
@@ -39,6 +41,7 @@ const HIKE: TourMapLayerSet = {
const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'topo', 'hiking', 'satellite'] };
const BIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const BIKE_WITH_CYCLE_MAP: TourMapLayerSet = { ...BIKE, bases: ['default', 'topo', 'cycle', 'satellite'] };
const RUN: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] };
const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] };
@@ -49,6 +52,7 @@ export function tourMapLayersFor(
): TourMapLayerSet {
if (type === 'hike') return hikingMap ? HIKE_WITH_HIKING_MAP : HIKE;
if (type === 'bike') return cycleMap ? BIKE_WITH_CYCLE_MAP : BIKE;
if (type === 'run') return RUN;
if (type === 'kayak') return PADDLE;
return OTHER;
}
+2 -2
View File
@@ -28,8 +28,8 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
// A tour without a grade (a paddle or a bike tour, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'hike'
// A tour without a grade (a paddle or a bike tour, a run, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type === 'kayak' || tour.tour_type === 'bike' || tour.tour_type === 'run' ? tour.tour_type : 'hike'
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null
return (
<span className="relative flex-none">
@@ -534,6 +534,19 @@ describe('TourPlannerMapStage', () => {
expect(stage.flatMap.props?.onRouteClick).toBeUndefined();
});
it('FE-PAGE-TOURSTAGE-019: a run opens in 3D as any tour does, with its own maps, and back in 2D nothing of it changed (RUN-01)', async () => {
const tourPlanner = planner('edit-saved', { tourType: 'run' });
renderStage(tourPlanner);
expect(stage.flatMap.props?.tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
await openTerrain();
expect(screen.queryByRole('button', { name: 'flat map' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '3D' }));
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
// The switch is a view: no point and no step of the run went with it.
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
expect(tourPlanner.undo).not.toHaveBeenCalled();
});
describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => {
const flat = () => stage.flatMap.props as MapViewProps;
@@ -561,7 +574,7 @@ describe('TourPlannerMapStage', () => {
]);
});
it('an imported paddle tour is shown with the paddle maps, a bike tour with its own, a type nobody plans with the standard set', () => {
it('an imported paddle tour is shown with the paddle maps, a bike tour and a run with their own, a type nobody plans with the standard set', () => {
const { unmount } = renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'kayak' } } })
);
@@ -572,9 +585,16 @@ describe('TourPlannerMapStage', () => {
);
expect(flat().tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
bike.unmount();
renderStage(
// RUN-01: a run has topo for its climbs and no ferratas.
const run = renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'run' } } })
);
expect(flat().tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
expect(flat().ferrataLayer).toBeUndefined();
run.unmount();
renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'food' } } })
);
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
expect(flat().tourLayers?.overlays).toEqual([]);
expect(flat().ferrataLayer).toBeUndefined();
+23 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042, FE-REPO-TOUR-043
// Tours through the offline core: read-through to Dexie, offline create and
// edit written there and queued, and the online paths keeping the copy fresh.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
@@ -209,6 +209,28 @@ describe('tourRepo.update', () => {
expect((await offlineDb.tours.get(40))?.winter_activity).toBeNull()
})
it('FE-REPO-TOUR-043: offline, a run shows its profile, pace and planned time at once, and another type drops them (RUN-01)', async () => {
setOnline(false)
await offlineDb.tours.put({ ...tour(), trip_id: 3 })
const run = {
...body,
tour_type: 'run' as const,
run_profile: 'running' as const,
run_pace_seconds_per_km: 360,
max_hiking_difficulty: null,
duration_seconds: 48 * 60,
}
expect((await tourRepo.update(3, 40, run)).tour).toMatchObject({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
duration: 48,
})
expect((await tourRepo.update(3, 40, body)).tour).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null })
expect(await offlineDb.tours.get(40)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null })
})
it('FE-REPO-TOUR-012: online edits and GPX imports refresh the cache', async () => {
server.use(
http.put('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour({ name: 'Edited' }), waypoints: body.waypoints })),
+10
View File
@@ -67,6 +67,15 @@ function winterField(body: TourCreateRequest): Pick<TourListItem, 'winter_activi
return body.winter_activity === undefined ? {} : { winter_activity: body.winter_activity }
}
/** RUN-01: a run's profile and pace as the server keeps them; any other type has neither. */
function runFields(body: TourCreateRequest): Pick<TourListItem, 'run_profile' | 'run_pace_seconds_per_km'> {
const run = body.tour_type === 'run'
return {
run_profile: run ? (body.run_profile ?? null) : null,
run_pace_seconds_per_km: run ? (body.run_pace_seconds_per_km ?? null) : null,
}
}
/**
* What the server will derive from a create or edit, worked out here for the
* copy shown until the queued write replays: the same metrics, the duration
@@ -92,6 +101,7 @@ function routeFields(body: TourCreateRequest) {
caution: false,
max_hiking_difficulty: body.max_hiking_difficulty,
...winterField(body),
...runFields(body),
has_waypoints: true,
...walkStationFacts(body.tour_type, body.waypoints),
...(body.planned_duration_minutes !== undefined ? { planned_duration_minutes: body.planned_duration_minutes } : {}),
+7
View File
@@ -16,6 +16,13 @@ export function hikePlannerDefaults() {
// HIK-01: a hike without a winter label, as every hike starts.
winterActivity: null,
setWinterActivity: vi.fn(),
// RUN-01: no run, so no pace asked for.
isRun: false,
runProfile: 'running' as const,
setRunProfile: vi.fn(),
runPace: { paceSecondsPerKm: null, setPace: vi.fn(), load: vi.fn() },
runPaceMissing: false,
runPaceInvalid: false,
unreachable: null,
paddle: {
boatType: null,
+18 -1
View File
@@ -40,6 +40,8 @@ export class Tours {
taken_over_from_tour_name?: string | null;
bike_profile?: TToursBikeProfile | null;
winter_activity?: TToursWinterActivity | null;
run_profile?: TToursRunProfile | null;
run_pace_seconds_per_km?: number | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -93,6 +95,13 @@ export const ToursWinterActivity = {
export type TToursWinterActivity = (typeof ToursWinterActivity)[keyof typeof ToursWinterActivity];
export const ToursRunProfile = {
RUNNING: 'running',
TRAIL: 'trail',
} as const;
export type TToursRunProfile = (typeof ToursRunProfile)[keyof typeof ToursRunProfile];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -103,7 +112,13 @@ export const ToursSchema = defineEntity({
properties: ['derived_from_place_id'],
},
],
checks: [{ name: 'tours_revision_check', expression: 'revision >= 1' }],
checks: [
{ name: 'tours_revision_check', expression: 'revision >= 1' },
{
name: 'tours_run_pace_seconds_per_km_check',
expression: 'run_pace_seconds_per_km IS NULL OR (run_pace_seconds_per_km >= 120 AND run_pace_seconds_per_km <= 1200)',
},
],
properties: {
place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
@@ -136,6 +151,8 @@ export const ToursSchema = defineEntity({
taken_over_from_tour_name: p.text().nullable(),
bike_profile: p.enum(() => ToursBikeProfile).nullable(),
winter_activity: p.enum(() => ToursWinterActivity).nullable(),
run_profile: p.enum(() => ToursRunProfile).nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+2
View File
@@ -32,4 +32,6 @@ export interface ToursTable {
taken_over_from_tour_name: InsertOptional<string | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
}
@@ -0,0 +1,26 @@
import { Migration } from '@mikro-orm/migrations';
/**
* RUN-01: a tour can be a run. The `run` key has been in `tour_types`,
* disabled, since every contract type was seeded; it is switched on here. The
* profile it is routed for and the pace the runner plans it at are columns of
* the tour, NULL on every other type, so no existing tour changes its meaning.
*
* The profile list already holds trail running, which RUN-02 brings to the
* contract: SQLite cannot widen a column's CHECK without rebuilding the table.
* The pace is whole seconds per kilometre, from 2:00 to 20:00 min/km as the
* contract takes it, NULL while the runner has entered none.
*/
export class Migration20200101045510_a_tour_can_be_a_run extends Migration {
override name = 'Migration20200101045510_a_tour_can_be_a_run';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN run_profile TEXT CHECK (run_profile IS NULL OR run_profile IN ('running', 'trail'))",
);
this.addSql(
'ALTER TABLE tours ADD COLUMN run_pace_seconds_per_km INTEGER CHECK (run_pace_seconds_per_km IS NULL OR (run_pace_seconds_per_km >= 120 AND run_pace_seconds_per_km <= 1200))',
);
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'run'");
}
}
+26 -1
View File
@@ -2,7 +2,14 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext, WinterActivity } from '@trek/shared';
import type {
BikeProfile,
PaddleBoatType,
PaddleLakeStyle,
PaddleWaterContext,
RunProfile,
WinterActivity,
} from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -32,6 +39,9 @@ export interface TourListRow {
bike_profile: string | null;
/** HIK-01: a hike's winter label; NULL for none and on every other type. */
winter_activity: string | null;
/** RUN-01: a run's profile and the pace it is planned at, seconds per km; NULL on every other type. */
run_profile: string | null;
run_pace_seconds_per_km: number | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -100,6 +110,8 @@ export interface TourRow {
taken_over_from_tour_name: string | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -119,6 +131,9 @@ export interface TourInsert {
bike_profile?: BikeProfile | null;
/** HIK-01: a hike's winter label; left out, NULL on a new tour and kept as it is by an edit. */
winter_activity?: WinterActivity | null;
/** RUN-01: a run's profile and its pace, seconds per km; left out, NULL. */
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -136,6 +151,8 @@ export type TourUpdate = Omit<
| 'planned_duration_minutes'
| 'break_additional_minutes'
| 'bike_profile'
| 'run_profile'
| 'run_pace_seconds_per_km'
| 'boat_type'
| 'water_context'
| 'paddle_speed_kmh'
@@ -145,6 +162,8 @@ export type TourUpdate = Omit<
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
bike_profile: BikeProfile | null;
run_profile: RunProfile | null;
run_pace_seconds_per_km: number | null;
boat_type: PaddleBoatType | null;
water_context: PaddleWaterContext | null;
paddle_speed_kmh: number | null;
@@ -248,6 +267,8 @@ export class ToursRepository extends TrekRepository<Tours> {
't.origin as origin',
't.bike_profile as bike_profile',
't.winter_activity as winter_activity',
't.run_profile as run_profile',
't.run_pace_seconds_per_km as run_pace_seconds_per_km',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -614,6 +635,8 @@ export class ToursRepository extends TrekRepository<Tours> {
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.bike_profile ?? null,
winter_activity: input.winter_activity ?? null,
run_profile: input.run_profile ?? null,
run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -699,6 +722,8 @@ export class ToursRepository extends TrekRepository<Tours> {
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.bike_profile,
run_profile: input.run_profile,
run_pace_seconds_per_km: input.run_pace_seconds_per_km,
boat_type: input.boat_type,
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
@@ -1,4 +1,4 @@
import type { BikeProfile, PaddleBoatType } from '@trek/shared';
import type { BikeProfile, PaddleBoatType, RunProfile } from '@trek/shared';
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
@@ -16,20 +16,27 @@ const BIKE_ACTIVITY: Record<BikeProfile, string> = {
mtb: 'mountain_biking',
};
/** RUN-01: a run says how it is run, in the same words. */
const RUN_ACTIVITY: Record<RunProfile, string> = {
running: 'running',
};
/**
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
* tour its profile, a hike on snowshoes snowshoeing (HIK-01) and any other
* tour and a run their profile, a hike on snowshoes snowshoeing (HIK-01) and any other
* hike hiking; whatever is missing falls back to the plainest.
*/
export function gpxActivityOf(tour: {
tour_type: string;
boat_type?: string | null;
bike_profile?: string | null;
run_profile?: string | null;
winter_activity?: string | null;
}): string {
const { tour_type: type, boat_type: boat, bike_profile: bike } = tour;
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running;
if (type !== 'hike') return type;
// A winter hike is walked as any hike is; only snowshoes make it an activity of its own.
return tour.winter_activity === 'snowshoe' ? 'snowshoeing' : 'hiking';
+2 -2
View File
@@ -85,7 +85,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx 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 winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), 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), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, runs, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx 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 winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks; 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 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), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -190,7 +190,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
'Search places along the saved line of a tour: what a hike, a run, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
+22 -1
View File
@@ -47,6 +47,7 @@ import {
type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext,
type RunProfile,
type TourCreateRequest,
type TourCreateResponse,
type TourDetailResponse,
@@ -113,6 +114,16 @@ function winterField(input: TourCreateCommand): WinterActivity | null | undefine
return input.tour_type === 'hike' ? input.winter_activity : null;
}
/**
* RUN-01: a save's run profile and pace as stored. Only a run keeps them;
* another type takes them off. A run without a pace keeps none: its time stays
* unknown rather than reckoned at a pace nobody chose.
*/
function runFields(input: TourCreateCommand): Pick<TourUpdate, 'run_profile' | 'run_pace_seconds_per_km'> {
if (input.tour_type !== 'run') return { run_profile: null, run_pace_seconds_per_km: null };
return { run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null };
}
/**
* CW-03: every point with its key, kept from the write where it came with one,
* so the visits of a city walk's stations outlast a reorder or a new point. A
@@ -166,6 +177,8 @@ export class ToursService {
origin: r.origin as TourOrigin,
bike_profile: r.bike_profile as BikeProfile | null,
winter_activity: r.winter_activity as WinterActivity | null,
run_profile: r.run_profile as RunProfile | null,
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
boat_type: r.boat_type as PaddleBoatType | null,
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
@@ -208,6 +221,7 @@ export class ToursService {
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
winter_activity: winterField(input),
...runFields(input),
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -505,6 +519,8 @@ export class ToursService {
? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null)
: null,
bike_profile: row.bike_profile as BikeProfile | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -614,6 +630,8 @@ export class ToursService {
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile as BikeProfile | null,
winter_activity: row.winter_activity as WinterActivity | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -797,9 +815,12 @@ export class ToursService {
const rows = this.readGpxTracks(fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
// What the type adds to each imported tour: a bike tour its profile, a paddle tour its boat and water.
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water.
// A run comes in without a pace: its time stays unknown until one is planned from it.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
+279
View File
@@ -0,0 +1,279 @@
/**
* Tours e2e for runs (RUN-01): a run saved with its profile and pace, listed
* and opened so, its time the distance at the pace, its pace changed or taken
* out without touching the route or the days, refused without a profile or
* with a pace out of bounds, copied, exported and imported, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours-winter.e2e.test.ts.
*
* Fixtures: user 1 owns trip 5, user 2 is a member of trip 5 whom the
* permission spy denies 'place_edit'.
*/
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Tours } from '../../src/db/entities/Tours.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { MapsService } from '../../src/nest/maps/maps.service';
import { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
let orm: TestOrm;
let checkPermission: MockInstance;
const OWNER = 1;
const VIEWER = 2;
const GEOMETRY = [
[48.137, 11.575, 520],
[48.15, 11.59, 515],
[48.164, 11.605, 510],
];
// What the planner sends for 8 km at 6:00 min/km: 48 minutes.
const runBody = (overrides: Record<string, unknown> = {}) => ({
name: 'River loop',
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
route_geometry: GEOMETRY,
waypoints: [
{ lat: 48.137, lng: 11.575, role: 'start', sequence: 0 },
{ lat: 48.164, lng: 11.605, role: 'end', sequence: 1 },
],
duration_seconds: 48 * 60,
...overrides,
});
const GPX = `<?xml version="1.0"?>
<gpx version="1.1" creator="e2e"><trk><name>Park laps</name><trkseg>
<trkpt lat="48.137" lon="11.575"><ele>520</ele></trkpt>
<trkpt lat="48.15" lon="11.59"><ele>515</ele></trkpt>
</trkseg></trk></gpx>`;
describe('Runs e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.overrideProvider(MapsService)
.useValue({ pois: vi.fn() })
// No network in a test: the trail grades read OpenStreetMap through Overpass.
.overrideProvider(OverpassElementsClient)
.useValue({ query: vi.fn() })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const send = (method: 'post' | 'put', path: string, body: Record<string, unknown>, userId = OWNER) =>
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
const createViaApi = async (body: Record<string, unknown> = runBody()) => {
const res = await send('post', '', body);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned ids: the session cookies sign for exactly these users.
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Running week' }]);
await addTripMember(orm, 5, 2);
await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-05-10' }]);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
(action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === VIEWER),
);
await setAddonEnabled(orm, 'tours', true);
realtime.broadcastMock.mockClear();
await deleteRows(orm, DayAssignments);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('TOURS-RUN-E2E-001: saves a run with its profile, pace and planned time, and lists and opens it so', async () => {
const placeId = await createViaApi();
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
max_hiking_difficulty: null,
duration: 48,
});
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
expect(list.status).toBe(200);
expect(list.body.tours[0]).toMatchObject({ place_id: placeId, run_profile: 'running', run_pace_seconds_per_km: 360 });
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
expect(detail.body.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', duration: 48 });
expect(detail.body.waypoints).toHaveLength(2);
});
it('TOURS-RUN-E2E-002: a run without a pace has no time rather than one at a pace nobody chose', async () => {
const placeId = await createViaApi(runBody({ run_pace_seconds_per_km: null, duration_seconds: null }));
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
run_pace_seconds_per_km: null,
duration: null,
});
});
it('TOURS-RUN-E2E-003: a new pace changes the time and keeps the route and the days', async () => {
const placeId = await createViaApi();
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
const faster = await send(
'put',
`/${placeId}`,
runBody({ run_pace_seconds_per_km: 300, duration_seconds: 40 * 60, expected_revision: 1 }),
);
expect(faster.status).toBe(200);
expect(faster.body.tour).toMatchObject({ run_pace_seconds_per_km: 300, duration: 40, planned: true, revision: 2 });
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(JSON.stringify(GEOMETRY));
});
it('TOURS-RUN-E2E-004: refuses a run without a profile, a pace out of bounds or not positive, and run fields on a hike', async () => {
const refused = await Promise.all(
[
runBody({ run_profile: null }),
runBody({ run_profile: 'trail' }),
runBody({ run_pace_seconds_per_km: 0 }),
runBody({ run_pace_seconds_per_km: -360 }),
runBody({ run_pace_seconds_per_km: 1201 }),
runBody({ run_pace_seconds_per_km: 330.5 }),
runBody({ max_hiking_difficulty: 2 }),
runBody({ tour_type: 'hike', run_profile: undefined }),
].map((body) => send('post', '', body)),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400, 400, 400, 400]);
expect(await countRows(orm, Tours)).toBe(0);
});
it('TOURS-RUN-E2E-005: a run made into a hike loses its profile and pace', async () => {
const placeId = await createViaApi();
const hike = await send(
'put',
`/${placeId}`,
runBody({
tour_type: 'hike',
max_hiking_difficulty: 2,
run_profile: undefined,
run_pace_seconds_per_km: undefined,
}),
);
expect(hike.status).toBe(200);
expect(hike.body.tour).toMatchObject({ tour_type: 'hike', run_profile: null, run_pace_seconds_per_km: null });
});
it('TOURS-RUN-E2E-006: a member who may only read cannot save one', async () => {
expect((await send('post', '', runBody(), VIEWER)).status).toBe(403);
});
it('TOURS-RUN-E2E-007: a copy keeps the profile and the pace', async () => {
const placeId = await createViaApi();
const copy = await send('post', `/${placeId}/copy`, { name: 'Again' });
expect(copy.status).toBe(201);
expect(copy.body.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
});
it('TOURS-RUN-E2E-008: exports a run as running', async () => {
const placeId = await createViaApi();
const res = await request(server)
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
.set('Cookie', sessionCookie(OWNER))
.buffer(true)
.parse((response, done) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (text += chunk));
response.on('end', () => done(null, text));
});
expect(/<type>([^<]+)<\/type>/.exec(String(res.body))?.[1]).toBe('running');
});
it('TOURS-RUN-E2E-009: imports a GPX file as a run only with its profile, and without a pace', async () => {
const importGpx = (fields: Record<string, string>) => {
let req = request(server).post('/api/trips/5/tours/import/gpx').set('Cookie', sessionCookie(OWNER));
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
return req.attach('file', Buffer.from(GPX), { filename: 'laps.gpx', contentType: 'application/gpx+xml' });
};
expect((await importGpx({ tour_type: 'run' })).status).toBe(400);
const res = await importGpx({ tour_type: 'run', run_profile: 'running' });
expect(res.status).toBe(201);
expect(res.body.tours[0]).toMatchObject({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: null,
duration: null,
origin: 'gpx',
});
});
});
@@ -67,6 +67,7 @@ const UNNUMBERED = [
'Migration20200101045300_a_tour_can_be_a_city_walk',
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
'Migration20200101045510_a_tour_can_be_a_run',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -106,6 +106,10 @@ describe('ToursRepository reads', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
},
{
place_id: older.id,
@@ -139,6 +143,10 @@ describe('ToursRepository reads', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
@@ -253,6 +261,8 @@ describe('ToursRepository writes', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
});
});
@@ -260,6 +270,8 @@ describe('ToursRepository writes', () => {
const HIKE_UPDATE = {
...METRICS,
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
@@ -306,6 +318,21 @@ describe('ToursRepository writes', () => {
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBeNull();
});
it('TOURREPO-017: a run keeps its profile and pace, an edit replaces them, and another type takes them off (RUN-01)', async () => {
const { trip } = fixture();
const place = createPlace(testDb, trip.id, { name: 'River loop' });
const run = { ...METRICS, tour_type: 'run', max_hiking_difficulty: null };
await repo.insertTour({ place_id: place.id, ...run, run_profile: 'running', run_pace_seconds_per_km: 360 });
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: 'running', run_pace_seconds_per_km: 360 });
const faster = { ...HIKE_UPDATE, ...run, run_profile: 'running' as const, run_pace_seconds_per_km: 300 };
expect(await repo.updateInTrip(trip.id, place.id, faster)).toBe(true);
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'run', run_pace_seconds_per_km: 300 });
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
@@ -352,6 +379,10 @@ describe('ToursRepository writes', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
taken_over_from_trip_title: null,
taken_over_from_tour_name: null,
},
@@ -0,0 +1,70 @@
/**
* RUN-01: `Migration20200101045510_a_tour_can_be_a_run` switches the run type
* on and gives a tour its run profile and pace. The profile column holds a
* CHECK list the entity carries as its enum, the pace a range the contract
* holds too: a value one side takes and the other refuses would be a run the
* planner can save and the database cannot store.
*/
import { ToursRunProfile } from '../../../src/db/entities/Tours.entity';
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { MAX_RUN_PACE_SECONDS_PER_KM, MIN_RUN_PACE_SECONDS_PER_KM, RUN_PROFILES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101045510_a_tour_can_be_a_run';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
describe('run columns match the contract', () => {
it('TOURRUNMIG-001: stores every profile the contract knows, and besides them only trail running (RUN-02)', () => {
const stored = Object.values(ToursRunProfile);
expect(RUN_PROFILES.every((profile) => (stored as readonly string[]).includes(profile))).toBe(true);
expect(stored.filter((profile) => !(RUN_PROFILES as readonly string[]).includes(profile))).toEqual(['trail']);
});
it('TOURRUNMIG-002: switches the run type on, leaves every tour as it was and refuses a pace out of bounds', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(
orm,
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
);
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(
orm,
"INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk'), (101, 10, 'River loop')",
);
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance) VALUES (100, 'hike', 12.5)");
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, "SELECT enabled FROM tour_types WHERE key = 'run'")).toEqual([{ enabled: 1 }]);
expect(await rawQuery(orm, 'SELECT run_profile, run_pace_seconds_per_km FROM tours')).toEqual([
{ run_profile: null, run_pace_seconds_per_km: null },
]);
await rawExec(
orm,
"INSERT INTO tours (place_id, tour_type, run_profile, run_pace_seconds_per_km) VALUES (101, 'run', 'running', 360)",
);
const pace = (value: number) =>
rawExec(orm, 'UPDATE tours SET run_pace_seconds_per_km = ? WHERE place_id = 101', [value]);
await pace(MIN_RUN_PACE_SECONDS_PER_KM);
await pace(MAX_RUN_PACE_SECONDS_PER_KM);
await expect(pace(MIN_RUN_PACE_SECONDS_PER_KM - 1)).rejects.toThrow(/CHECK/);
await expect(pace(MAX_RUN_PACE_SECONDS_PER_KM + 1)).rejects.toThrow(/CHECK/);
await expect(rawExec(orm, "UPDATE tours SET run_profile = 'sprint' WHERE place_id = 101")).rejects.toThrow(
/CHECK/,
);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -2,9 +2,9 @@ import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers';
import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-004
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-005
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => {
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking');
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling');
@@ -28,4 +28,10 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01)', () => {
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: null })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'walk', winter_activity: 'snowshoe' })).toBe('walk');
});
it('TOUR-GPX-ACTIVITY-005: a run is running, and stays running without a profile this build knows (RUN-01)', () => {
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'running' })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: null })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'sprint' })).toBe('running');
});
});
+21
View File
@@ -371,4 +371,25 @@ describe('ToursMcp', () => {
expect(description).toMatch(/winter_activity: winter_hike or snowshoe/);
expect(description).toContain('never a statement that a way can be walked in winter');
});
it('TOURS-MCP-016: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
orm,
Tours,
{ place: ridge.id },
{ tourTypeRef: 'run', max_hiking_difficulty: null, run_profile: 'running', run_pace_seconds_per_km: 360 },
);
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({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
expect(byId.get(gorge.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/run_profile: running/);
expect(description).toContain('no climb, descent or difficulty is in it');
expect(description).toContain('never a recorded time');
});
});
@@ -193,6 +193,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
paddle_lake_style: null,
hiking_preferences: null,
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
});
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(request.waypoints));
@@ -311,6 +313,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
paddle_lake_style: null,
hiking_preferences: null,
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
});
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(update.waypoints));
+48 -2
View File
@@ -254,7 +254,7 @@ describe('ToursService planner creation', () => {
it('TOURS-SVC-010: a type the catalogue holds but has not enabled is a 400 without a write', async () => {
// The contract only lets `hike` through today; this pins what happens the
// day it lets more keys in before the planner offers them.
const bike = { ...request, tour_type: 'run' } as unknown as typeof request;
const bike = { ...request, tour_type: 'food' } as unknown as typeof request;
// A DomainError: REST answers its text as { error } at 400, an MCP tool as its error result.
const refusal = await catchDomainError(() => service.createTour(tripId, bike));
@@ -271,7 +271,7 @@ describe('ToursService planner creation', () => {
it('TOURS-SVC-011: an edit to a disabled type is a 400 and leaves the saved tour alone', async () => {
const created = await service.createTour(tripId, request);
broadcast.mockReset();
const bike = { ...request, name: 'Renamed', tour_type: 'run' } as unknown as typeof request;
const bike = { ...request, name: 'Renamed', tour_type: 'food' } as unknown as typeof request;
const refusal = await catchDomainError(() => service.updateTour(tripId, String(created.tour.place_id), bike));
expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([
@@ -634,3 +634,49 @@ describe('ToursService restoring a recorded track (PR1-05B)', () => {
await expect(service.restoreRecordedTrack(tripId, 'abc')).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ToursService runs (RUN-01)', () => {
const run = {
...request,
tour_type: 'run' as const,
max_hiking_difficulty: null,
run_profile: 'running' as const,
run_pace_seconds_per_km: 360,
};
it('TOURS-SVC-012: a run keeps its profile and pace, and a hike made of it keeps neither', async () => {
const created = await service.createTour(tripId, run);
expect(created.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
expect(await firstTour()).toMatchObject({ run_profile: 'running', run_pace_seconds_per_km: 360 });
const placeId = String(created.tour.place_id);
const hike = await service.updateTour(tripId, placeId, { ...request, max_hiking_difficulty: 2 });
expect(hike.tour).toMatchObject({ tour_type: 'hike', run_profile: null, run_pace_seconds_per_km: null });
});
it('TOURS-SVC-013: a run without a pace stores none, and a copy and a restored import keep what the run has', async () => {
const created = await service.createTour(tripId, { ...run, run_pace_seconds_per_km: null });
expect(created.tour.run_pace_seconds_per_km).toBeNull();
const placeId = String(created.tour.place_id);
await service.updateTour(tripId, placeId, run);
const copy = await service.copyTour(tripId, placeId, 'Again');
expect(copy.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
await updateRows(
await sharedTestOrm(testDb),
Tours,
{ place: Number(placeId) },
{
recorded_route_geometry: JSON.stringify([
[47.45, 11.1],
[47.46, 11.11],
]),
},
);
(await sharedTestOrm(testDb)).clear();
const restored = await service.restoreRecordedTrack(tripId, placeId);
expect(restored.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
});
});
+20
View File
@@ -414,6 +414,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': 'مشي شتوي',
'tours.winter.activity.snowshoe': 'مشي بأحذية الثلج',
'tours.winter.labelOnly': 'وضعها المسافرون. يُخطَّط المسار والوقت كما في أي نزهة، دون فحص لظروف الشتاء.',
'tours.planner.tourTypeHint.run': 'سيرًا على الأقدام بإيقاعك الخاص، على طرق مناسبة للجري',
'tours.planner.namePlaceholder.run': 'جري بلا عنوان',
'tours.planner.unreachable.run':
'لا يصل أي طريق صالح للجري إلى النقطة {point}: أقرب طريق يعرفه الموجّه يبعد {distance}. انقل النقطة إلى طريق، أو ارسم المقطع إليها يدويًا.',
'tours.run.profile': 'الملف',
'tours.run.profile.running': 'جري',
'tours.run.profileHint.running':
'يفضّل طرقًا مناسبة للجري، معبّدة قدر الإمكان، في المدينة وفي الحدائق وعلى ضفاف الأنهار.',
'tours.run.pace': 'الوتيرة المخططة',
'tours.run.paceInfoLabel': 'كيف يُحسب وقت الجري',
'tours.run.paceInfo':
'وتيرتك الخاصة لكل كيلومتر. وقت الجري هو المسافة × الوتيرة؛ ولا تُحتسب الصعودات والنزولات ولا الصعوبة التقنية.',
'tours.run.paceInvalid': 'أدخل الوتيرة بصيغة دقيقة:ثانية لكل كم، من {fastest} إلى {slowest}.',
'tours.run.paceNeeded': 'أدخل وتيرتك لتخطيط وقت الجري وحفظ هذا الجري.',
'tours.run.timeEstimated': 'وقت الجري (مخطط)',
'tours.run.timeEstimatedShort': 'وقت الجري',
'tours.run.timeEstimatedInfoLabel': 'كيف يُخطط وقت الجري',
'tours.run.timeEstimatedInfo':
'المسافة × وتيرتك ولا شيء غير ذلك: لا تُحتسب الصعودات والنزولات ولا الصعوبة. إنه تخطيط، وليس وقتًا مسجّلًا.',
'tours.run.timeSum': '{distance} بوتيرة {pace} دقيقة/كم تساوي {time}',
'tours.bike.profile': 'الملف',
'tours.bike.profile.touring': 'تجوال',
'tours.bike.profile.road': 'طريق',
+20
View File
@@ -404,6 +404,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Qar ayaqqabısı ilə yürüş',
'tours.winter.labelOnly':
'Səyahətçilər tərəfindən qeyd edilib. Marşrut və vaxt istənilən yürüşdəki kimi planlaşdırılır, qış şəraiti üçün yoxlanılmır.',
'tours.planner.tourTypeHint.run': 'Piyada öz tempinizlə, qaçış üçün rahat yollarla',
'tours.planner.namePlaceholder.run': 'Adsız qaçış',
'tours.planner.unreachable.run':
'{point} nöqtəsinə qaçış üçün yararlı heç bir yol çatmır: marşrutlaşdırıcının bildiyi ən yaxın yol {distance} uzaqdadır. Nöqtəni yola köçürün və ya ora gedən hissəni əl ilə çəkin.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Qaçış',
'tours.run.profileHint.running':
'Şəhərdə, parklarda və çay boyunca qaçış üçün rahat, mümkün qədər örtüklü yollara üstünlük verir.',
'tours.run.pace': 'Planlaşdırılan temp',
'tours.run.paceInfoLabel': 'Qaçış vaxtı necə hesablanır',
'tours.run.paceInfo':
'Kilometr başına öz tempiniz. Qaçış vaxtı məsafə × tempdir; yoxuşlar, enişlər və texniki çətinlik nəzərə alınmır.',
'tours.run.paceInvalid': 'Tempi km başına dəq:san kimi, {fastest} ilə {slowest} arasında daxil edin.',
'tours.run.paceNeeded': 'Qaçış vaxtını planlaşdırmaq və bu qaçışı saxlamaq üçün tempinizi daxil edin.',
'tours.run.timeEstimated': 'Qaçış vaxtı (planlaşdırılan)',
'tours.run.timeEstimatedShort': 'Qaçış vaxtı',
'tours.run.timeEstimatedInfoLabel': 'Qaçış vaxtı necə planlaşdırılır',
'tours.run.timeEstimatedInfo':
'Məsafə × sizin temp, başqa heç nə: yoxuşlar, enişlər və çətinlik nəzərə alınmır. Bu plandır, qeydə alınmış vaxt deyil.',
'tours.run.timeSum': '{distance}, {pace} dəq/km tempdə: {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turinq',
'tours.bike.profile.road': 'Yol',
+20
View File
@@ -410,6 +410,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Caminhada com raquetes de neve',
'tours.winter.labelOnly':
'Marcado pelos viajantes. Rota e tempo são planejados como em qualquer caminhada, sem verificação para o inverno.',
'tours.planner.tourTypeHint.run': 'A pé no seu ritmo, por caminhos bons para correr',
'tours.planner.namePlaceholder.run': 'Corrida sem título',
'tours.planner.unreachable.run':
'Nenhum caminho bom para correr chega ao ponto {point}: o mais próximo que o roteador conhece fica a {distance}. Mova o ponto para um caminho ou desenhe o trecho até ele à mão.',
'tours.run.profile': 'Perfil',
'tours.run.profile.running': 'Corrida',
'tours.run.profileHint.running':
'Prefere caminhos bons para correr, pavimentados sempre que possível, na cidade, em parques e à beira de rios.',
'tours.run.pace': 'Ritmo planejado',
'tours.run.paceInfoLabel': 'Como o tempo de corrida é calculado',
'tours.run.paceInfo':
'Seu próprio ritmo por quilômetro. O tempo de corrida é distância × ritmo; subidas, descidas e dificuldade técnica não entram.',
'tours.run.paceInvalid': 'Informe o ritmo como min:s por km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Informe seu ritmo para planejar o tempo de corrida e salvar esta corrida.',
'tours.run.timeEstimated': 'Tempo de corrida (planejado)',
'tours.run.timeEstimatedShort': 'Tempo de corrida',
'tours.run.timeEstimatedInfoLabel': 'Como o tempo de corrida é planejado',
'tours.run.timeEstimatedInfo':
'Distância × seu ritmo e nada mais: subidas, descidas e dificuldade não entram. Um plano, não um tempo registrado.',
'tours.run.timeSum': '{distance} a {pace} min/km dão {time}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Estrada',
+20
View File
@@ -411,6 +411,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Excursió amb raquetes de neu',
'tours.winter.labelOnly':
'Marcat pels viatgers. La ruta i el temps es planifiquen com en qualsevol excursió, sense comprovar l’hivern.',
'tours.planner.tourTypeHint.run': 'A peu al teu ritme, per camins bons per córrer',
'tours.planner.namePlaceholder.run': 'Cursa sense títol',
'tours.planner.unreachable.run':
'Cap camí apte per córrer arriba al punt {point}: el més proper que coneix l’enrutador és a {distance}. Mou el punt a un camí o dibuixa el tram fins allà a mà.',
'tours.run.profile': 'Perfil',
'tours.run.profile.running': 'Córrer',
'tours.run.profileHint.running':
'Prefereix camins bons per córrer, asfaltats on es pugui, a la ciutat, als parcs i vora els rius.',
'tours.run.pace': 'Ritme previst',
'tours.run.paceInfoLabel': 'Com es calcula el temps de cursa',
'tours.run.paceInfo':
'El teu ritme per quilòmetre. El temps de cursa és distància × ritme; el desnivell i la dificultat tècnica no hi compten.',
'tours.run.paceInvalid': 'Introdueix el ritme com a min:s per km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Introdueix el teu ritme per planificar el temps de cursa i desar aquesta cursa.',
'tours.run.timeEstimated': 'Temps de cursa (previst)',
'tours.run.timeEstimatedShort': 'Temps de cursa',
'tours.run.timeEstimatedInfoLabel': 'Com es planifica el temps de cursa',
'tours.run.timeEstimatedInfo':
'Distància × el teu ritme i res més: el desnivell i la dificultat no hi compten. Una planificació, no un temps enregistrat.',
'tours.run.timeSum': '{distance} a {pace} min/km són {time}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturisme',
'tours.bike.profile.road': 'Carretera',
+20
View File
@@ -403,6 +403,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Túra na sněžnicích',
'tours.winter.labelOnly':
'Označeno cestovateli. Trasa a čas se plánují jako u každé túry, bez kontroly zimních podmínek.',
'tours.planner.tourTypeHint.run': 'Pěšky vlastním tempem, po dobře běhatelných cestách',
'tours.planner.namePlaceholder.run': 'Běh bez názvu',
'tours.planner.unreachable.run':
'K bodu {point} nevede žádná cesta vhodná k běhu: nejbližší, kterou router zná, je {distance} daleko. Přesuňte bod na cestu, nebo úsek k němu nakreslete ručně.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Běh',
'tours.run.profileHint.running':
'Dává přednost dobře běhatelným, pokud možno zpevněným cestám ve městě, v parcích a podél řek.',
'tours.run.pace': 'Plánované tempo',
'tours.run.paceInfoLabel': 'Jak se počítá čas běhu',
'tours.run.paceInfo':
'Vaše vlastní tempo na kilometr. Čas běhu je vzdálenost × tempo; převýšení a technická obtížnost se nezapočítávají.',
'tours.run.paceInvalid': 'Zadejte tempo jako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Zadejte své tempo, aby se naplánoval čas běhu a běh šel uložit.',
'tours.run.timeEstimated': 'Čas běhu (plánovaný)',
'tours.run.timeEstimatedShort': 'Čas běhu',
'tours.run.timeEstimatedInfoLabel': 'Jak se plánuje čas běhu',
'tours.run.timeEstimatedInfo':
'Vzdálenost × vaše tempo a nic dalšího: převýšení a obtížnost se nezapočítávají. Plán, ne zaznamenaný čas.',
'tours.run.timeSum': '{distance} při {pace} min/km dává {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Treking',
'tours.bike.profile.road': 'Silnice',
+20
View File
@@ -413,6 +413,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Schneeschuhwandern',
'tours.winter.labelOnly':
'Von den Reisenden gekennzeichnet. Route und Zeit sind wie bei jeder Wanderung geplant, nicht für den Winter geprüft.',
'tours.planner.tourTypeHint.run': 'Zu Fuß in deiner Pace, auf gut laufbaren Wegen',
'tours.planner.namePlaceholder.run': 'Unbenannter Lauf',
'tours.planner.unreachable.run':
'Kein laufbarer Weg erreicht Punkt {point}: der nächste, den der Router kennt, liegt {distance} entfernt. Setz den Punkt auf einen Weg oder zeichne den Abschnitt dorthin von Hand.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Laufen',
'tours.run.profileHint.running':
'Bevorzugt gut laufbare, möglichst befestigte Wege in der Stadt, in Parks und an Flüssen.',
'tours.run.pace': 'Geplante Pace',
'tours.run.paceInfoLabel': 'Wie die Laufzeit berechnet wird',
'tours.run.paceInfo':
'Deine eigene Pace pro Kilometer. Die Laufzeit ist Distanz × Pace; Höhenmeter und technische Schwierigkeit gehen nicht ein.',
'tours.run.paceInvalid': 'Gib die Pace als Min:Sek pro km ein, von {fastest} bis {slowest}.',
'tours.run.paceNeeded': 'Gib deine Pace ein, um die Laufzeit zu planen und diesen Lauf zu speichern.',
'tours.run.timeEstimated': 'Laufzeit (geplant)',
'tours.run.timeEstimatedShort': 'Laufzeit',
'tours.run.timeEstimatedInfoLabel': 'Wie die Laufzeit geplant wird',
'tours.run.timeEstimatedInfo':
'Distanz × deine Pace und sonst nichts: Höhenmeter und Schwierigkeit gehen nicht ein. Eine Planung, keine aufgezeichnete Zeit.',
'tours.run.timeSum': '{distance} bei {pace} min/km ergibt {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Rennrad',
+19
View File
@@ -403,6 +403,25 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Snowshoe hike',
'tours.winter.labelOnly':
'Marked by the travellers. Route and time are planned as for any hike, not checked for winter.',
'tours.planner.tourTypeHint.run': 'On foot at your own pace, on runnable ways',
'tours.planner.namePlaceholder.run': 'Untitled run',
'tours.planner.unreachable.run':
'No runnable way reaches point {point}: the nearest the router knows is {distance} away. Move the point onto a way, or draw the leg to it by hand.',
'tours.run.profile': 'Profile',
'tours.run.profile.running': 'Running',
'tours.run.profileHint.running': 'Prefers runnable, mostly paved ways in town, in parks and along rivers.',
'tours.run.pace': 'Planned pace',
'tours.run.paceInfoLabel': 'How the running time is worked out',
'tours.run.paceInfo':
'Your own pace per kilometre. The running time is distance × pace; climbs, descents and how technical the way is are not included.',
'tours.run.paceInvalid': 'Enter the pace as min:sec per km, from {fastest} to {slowest}.',
'tours.run.paceNeeded': 'Enter your pace to plan the running time and save this run.',
'tours.run.timeEstimated': 'Running time (planned)',
'tours.run.timeEstimatedShort': 'Running time',
'tours.run.timeEstimatedInfoLabel': 'How the running time is planned',
'tours.run.timeEstimatedInfo':
'Distance × your pace and nothing else: climbs, descents and difficulty are not included. A plan, not a recorded time.',
'tours.run.timeSum': '{distance} at {pace} min/km is {time}',
'tours.bike.profile': 'Profile',
'tours.bike.profile.touring': 'Touring',
'tours.bike.profile.road': 'Road',
+20
View File
@@ -410,6 +410,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Ruta con raquetas de nieve',
'tours.winter.labelOnly':
'Marcado por los viajeros. Ruta y tiempo se planifican como en cualquier ruta, sin comprobación invernal.',
'tours.planner.tourTypeHint.run': 'A pie a tu ritmo, por caminos buenos para correr',
'tours.planner.namePlaceholder.run': 'Carrera sin título',
'tours.planner.unreachable.run':
'Ningún camino apto para correr llega al punto {point}: el más cercano que conoce el enrutador está a {distance}. Mueve el punto a un camino o dibuja el tramo hasta él a mano.',
'tours.run.profile': 'Perfil',
'tours.run.profile.running': 'Correr',
'tours.run.profileHint.running':
'Prefiere caminos buenos para correr, asfaltados en lo posible, en la ciudad, en parques y junto a ríos.',
'tours.run.pace': 'Ritmo previsto',
'tours.run.paceInfoLabel': 'Cómo se calcula el tiempo de carrera',
'tours.run.paceInfo':
'Tu propio ritmo por kilómetro. El tiempo de carrera es distancia × ritmo; el desnivel y la dificultad técnica no se tienen en cuenta.',
'tours.run.paceInvalid': 'Introduce el ritmo como min:s por km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Introduce tu ritmo para planificar el tiempo de carrera y guardar esta carrera.',
'tours.run.timeEstimated': 'Tiempo de carrera (previsto)',
'tours.run.timeEstimatedShort': 'Tiempo de carrera',
'tours.run.timeEstimatedInfoLabel': 'Cómo se planifica el tiempo de carrera',
'tours.run.timeEstimatedInfo':
'Distancia × tu ritmo y nada más: el desnivel y la dificultad no se tienen en cuenta. Una planificación, no un tiempo registrado.',
'tours.run.timeSum': '{distance} a {pace} min/km son {time}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Carretera',
+20
View File
@@ -405,6 +405,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Räätsamatk',
'tours.winter.labelOnly':
'Reisijate märgitud. Marsruut ja aeg on kavandatud nagu igal matkal, talve jaoks kontrollimata.',
'tours.planner.tourTypeHint.run': 'Jalgsi omas tempos, hästi joostavatel teedel',
'tours.planner.namePlaceholder.run': 'Nimetu jooks',
'tours.planner.unreachable.run':
'Ükski joostav tee ei jõua punktini {point}: lähim, mida marsruutija teab, on {distance} kaugusel. Liiguta punkt teele või joonista lõik sinna käsitsi.',
'tours.run.profile': 'Profiil',
'tours.run.profile.running': 'Jooksmine',
'tours.run.profileHint.running':
'Eelistab hästi joostavaid, võimalusel kõvakattega teid linnas, parkides ja jõgede ääres.',
'tours.run.pace': 'Planeeritud tempo',
'tours.run.paceInfoLabel': 'Kuidas jooksuaeg arvutatakse',
'tours.run.paceInfo':
'Sinu enda tempo kilomeetri kohta. Jooksuaeg on vahemaa × tempo; tõusud, langused ja tehniline raskus ei lähe arvesse.',
'tours.run.paceInvalid': 'Sisesta tempo kujul min:s kilomeetri kohta, vahemikus {fastest} kuni {slowest}.',
'tours.run.paceNeeded': 'Sisesta oma tempo, et jooksuaeg planeerida ja see jooks salvestada.',
'tours.run.timeEstimated': 'Jooksuaeg (planeeritud)',
'tours.run.timeEstimatedShort': 'Jooksuaeg',
'tours.run.timeEstimatedInfoLabel': 'Kuidas jooksuaeg planeeritakse',
'tours.run.timeEstimatedInfo':
'Vahemaa × sinu tempo ja ei midagi muud: tõusud, langused ja raskus ei lähe arvesse. Plaan, mitte salvestatud aeg.',
'tours.run.timeSum': '{distance} tempos {pace} min/km teeb {time}',
'tours.bike.profile': 'Profiil',
'tours.bike.profile.touring': 'Matk',
'tours.bike.profile.road': 'Maantee',
+20
View File
@@ -416,6 +416,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Randonnée en raquettes',
'tours.winter.labelOnly':
'Marqué par les voyageurs. Itinéraire et durée sont planifiés comme pour toute randonnée, sans vérification hivernale.',
'tours.planner.tourTypeHint.run': 'À pied, à ton allure, sur des chemins où l’on court bien',
'tours.planner.namePlaceholder.run': 'Course sans titre',
'tours.planner.unreachable.run':
"Aucun chemin praticable en courant n'atteint le point {point} : le plus proche que connaît le routeur est à {distance}. Déplace le point sur un chemin ou dessine le tronçon à la main.",
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Course à pied',
'tours.run.profileHint.running':
'Privilégie des chemins où l’on court bien, revêtus autant que possible, en ville, dans les parcs et le long des rivières.',
'tours.run.pace': 'Allure prévue',
'tours.run.paceInfoLabel': 'Comment le temps de course est calculé',
'tours.run.paceInfo':
'Ton allure à toi, par kilomètre. Le temps de course est distance × allure ; dénivelé et difficulté technique ne sont pas pris en compte.',
'tours.run.paceInvalid': 'Saisis l’allure en min:s par km, de {fastest} à {slowest}.',
'tours.run.paceNeeded': 'Saisis ton allure pour planifier le temps de course et enregistrer cette course.',
'tours.run.timeEstimated': 'Temps de course (prévu)',
'tours.run.timeEstimatedShort': 'Temps de course',
'tours.run.timeEstimatedInfoLabel': 'Comment le temps de course est planifié',
'tours.run.timeEstimatedInfo':
'Distance × ton allure, rien d’autre : dénivelé et difficulté ne sont pas pris en compte. Une prévision, pas un temps enregistré.',
'tours.run.timeSum': '{distance} à {pace} min/km font {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Voyage',
'tours.bike.profile.road': 'Route',
+21
View File
@@ -418,6 +418,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Πεζοπορία με χιονοπέδιλα',
'tours.winter.labelOnly':
'Σημειώθηκε από τους ταξιδιώτες. Η διαδρομή και ο χρόνος σχεδιάζονται όπως σε κάθε πεζοπορία, χωρίς έλεγχο για τον χειμώνα.',
'tours.planner.tourTypeHint.run': 'Με τα πόδια, με τον δικό σου ρυθμό, σε δρόμους κατάλληλους για τρέξιμο',
'tours.planner.namePlaceholder.run': 'Τρέξιμο χωρίς τίτλο',
'tours.planner.unreachable.run':
'Κανένας δρόμος κατάλληλος για τρέξιμο δεν φτάνει στο σημείο {point}: ο πλησιέστερος που γνωρίζει ο δρομολογητής απέχει {distance}. Μετακίνησε το σημείο σε δρόμο ή σχεδίασε το τμήμα ως εκεί με το χέρι.',
'tours.run.profile': 'Προφίλ',
'tours.run.profile.running': 'Τρέξιμο',
'tours.run.profileHint.running':
'Προτιμά δρόμους κατάλληλους για τρέξιμο, κατά το δυνατόν ασφαλτοστρωμένους, στην πόλη, σε πάρκα και δίπλα σε ποτάμια.',
'tours.run.pace': 'Προγραμματισμένος ρυθμός',
'tours.run.paceInfoLabel': 'Πώς υπολογίζεται ο χρόνος τρεξίματος',
'tours.run.paceInfo':
'Ο δικός σου ρυθμός ανά χιλιόμετρο. Ο χρόνος τρεξίματος είναι απόσταση × ρυθμός· ανηφόρες, κατηφόρες και τεχνική δυσκολία δεν λαμβάνονται υπόψη.',
'tours.run.paceInvalid': 'Δώσε τον ρυθμό ως λεπ:δευτ ανά km, από {fastest} έως {slowest}.',
'tours.run.paceNeeded':
'Δώσε τον ρυθμό σου για να προγραμματιστεί ο χρόνος τρεξίματος και να αποθηκευτεί αυτό το τρέξιμο.',
'tours.run.timeEstimated': 'Χρόνος τρεξίματος (προγραμματισμένος)',
'tours.run.timeEstimatedShort': 'Χρόνος τρεξίματος',
'tours.run.timeEstimatedInfoLabel': 'Πώς προγραμματίζεται ο χρόνος τρεξίματος',
'tours.run.timeEstimatedInfo':
'Απόσταση × ο ρυθμός σου και τίποτα άλλο: ανηφόρες, κατηφόρες και δυσκολία δεν λαμβάνονται υπόψη. Πρόγραμμα, όχι καταγεγραμμένος χρόνος.',
'tours.run.timeSum': '{distance} με {pace} λεπ/km κάνει {time}',
'tours.bike.profile': 'Προφίλ',
'tours.bike.profile.touring': 'Περιήγηση',
'tours.bike.profile.road': 'Δρόμου',
+20
View File
@@ -410,6 +410,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Hótalpas túra',
'tours.winter.labelOnly':
'Az utazók jelölték meg. Az útvonal és az idő úgy van tervezve, mint bármely túránál, téli szempontból nincs ellenőrizve.',
'tours.planner.tourTypeHint.run': 'Gyalog a saját tempódban, jól futható utakon',
'tours.planner.namePlaceholder.run': 'Névtelen futás',
'tours.planner.unreachable.run':
'A(z) {point}. pontot nem éri el futásra alkalmas út: a legközelebbi, amit a tervező ismer, {distance} távolságra van. Tedd a pontot egy útra, vagy rajzold meg kézzel a szakaszt odáig.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Futás',
'tours.run.profileHint.running':
'Jól futható, lehetőleg burkolt utakat választ a városban, parkokban és folyók mentén.',
'tours.run.pace': 'Tervezett tempó',
'tours.run.paceInfoLabel': 'Hogyan számoljuk a futásidőt',
'tours.run.paceInfo':
'A saját tempód kilométerenként. A futásidő távolság × tempó; a szintemelkedés és a technikai nehézség nem számít bele.',
'tours.run.paceInvalid': 'Add meg a tempót perc:mp/km formában, {fastest} és {slowest} között.',
'tours.run.paceNeeded': 'Add meg a tempódat a futásidő tervezéséhez és a futás mentéséhez.',
'tours.run.timeEstimated': 'Futásidő (tervezett)',
'tours.run.timeEstimatedShort': 'Futásidő',
'tours.run.timeEstimatedInfoLabel': 'Hogyan tervezzük a futásidőt',
'tours.run.timeEstimatedInfo':
'Távolság × a tempód, semmi más: a szintemelkedés és a nehézség nem számít bele. Terv, nem rögzített idő.',
'tours.run.timeSum': '{distance} {pace} perc/km tempóval: {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Túra',
'tours.bike.profile.road': 'Országúti',
+20
View File
@@ -405,6 +405,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Pendakian dengan sepatu salju',
'tours.winter.labelOnly':
'Ditandai oleh para pelancong. Rute dan waktu direncanakan seperti pendakian lain, tidak diperiksa untuk musim dingin.',
'tours.planner.tourTypeHint.run': 'Berjalan kaki dengan tempomu sendiri, di jalan yang enak untuk lari',
'tours.planner.namePlaceholder.run': 'Lari tanpa judul',
'tours.planner.unreachable.run':
'Tidak ada jalan yang bisa dilalui dengan berlari menuju titik {point}: yang terdekat yang dikenal router berjarak {distance}. Pindahkan titik ke sebuah jalan, atau gambar ruas menuju titik itu secara manual.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Lari',
'tours.run.profileHint.running':
'Mengutamakan jalan yang enak untuk lari, sebisa mungkin beraspal, di kota, di taman, dan di tepi sungai.',
'tours.run.pace': 'Pace rencana',
'tours.run.paceInfoLabel': 'Cara waktu lari dihitung',
'tours.run.paceInfo':
'Pace milikmu sendiri per kilometer. Waktu lari adalah jarak × pace; tanjakan, turunan, dan tingkat kesulitan teknis tidak dihitung.',
'tours.run.paceInvalid': 'Masukkan pace sebagai mnt:dtk per km, dari {fastest} sampai {slowest}.',
'tours.run.paceNeeded': 'Masukkan pace-mu untuk merencanakan waktu lari dan menyimpan lari ini.',
'tours.run.timeEstimated': 'Waktu lari (rencana)',
'tours.run.timeEstimatedShort': 'Waktu lari',
'tours.run.timeEstimatedInfoLabel': 'Cara waktu lari direncanakan',
'tours.run.timeEstimatedInfo':
'Jarak × pace-mu, tidak ada yang lain: tanjakan, turunan, dan kesulitan tidak dihitung. Sebuah rencana, bukan waktu yang direkam.',
'tours.run.timeSum': '{distance} dengan pace {pace} mnt/km menjadi {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Road', // same-as-en
+20
View File
@@ -413,6 +413,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Ciaspolata',
'tours.winter.labelOnly':
'Contrassegnato dai viaggiatori. Percorso e tempi sono pianificati come per ogni escursione, non verificati per l’inverno.',
'tours.planner.tourTypeHint.run': 'A piedi al tuo passo, su percorsi adatti alla corsa',
'tours.planner.namePlaceholder.run': 'Corsa senza titolo',
'tours.planner.unreachable.run':
'Nessun percorso adatto alla corsa raggiunge il punto {point}: il più vicino che il router conosce è a {distance}. Sposta il punto su un percorso o disegna a mano il tratto fin lì.',
'tours.run.profile': 'Profilo',
'tours.run.profile.running': 'Corsa',
'tours.run.profileHint.running':
'Preferisce percorsi adatti alla corsa, asfaltati dove possibile, in città, nei parchi e lungo i fiumi.',
'tours.run.pace': 'Passo previsto',
'tours.run.paceInfoLabel': 'Come si calcola il tempo di corsa',
'tours.run.paceInfo':
'Il tuo passo al chilometro. Il tempo di corsa è distanza × passo; dislivello e difficoltà tecnica non sono considerati.',
'tours.run.paceInvalid': 'Inserisci il passo come min:s al km, da {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Inserisci il tuo passo per pianificare il tempo di corsa e salvare questa corsa.',
'tours.run.timeEstimated': 'Tempo di corsa (previsto)',
'tours.run.timeEstimatedShort': 'Tempo di corsa',
'tours.run.timeEstimatedInfoLabel': 'Come si pianifica il tempo di corsa',
'tours.run.timeEstimatedInfo':
'Distanza × il tuo passo e nient’altro: dislivello e difficoltà non sono considerati. Una pianificazione, non un tempo registrato.',
'tours.run.timeSum': '{distance} a {pace} min/km fanno {time}',
'tours.bike.profile': 'Profilo',
'tours.bike.profile.touring': 'Turismo',
'tours.bike.profile.road': 'Strada',
+17
View File
@@ -395,6 +395,23 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'スノーシューハイキング',
'tours.winter.labelOnly':
'旅行者が付けたラベルです。ルートと時間は通常のハイキングと同じく計画され、冬向けには確認されていません。',
'tours.planner.tourTypeHint.run': '自分のペースで、走りやすい道を',
'tours.planner.namePlaceholder.run': '無題のラン',
'tours.planner.unreachable.run':
'ポイント {point} に届く走れる道がありません。ルーターが知る最も近い道は {distance} 先です。ポイントを道の上に移すか、そこまでの区間を手で描いてください。',
'tours.run.profile': 'プロファイル',
'tours.run.profile.running': 'ランニング',
'tours.run.profileHint.running': '街中や公園、川沿いの走りやすい、なるべく舗装された道を優先します。',
'tours.run.pace': '予定ペース',
'tours.run.paceInfoLabel': '走行時間の計算方法',
'tours.run.paceInfo': '1 kmあたりのあなた自身のペースです。走行時間は距離 × ペースで、登り、下り、技術的な難しさは含みません。',
'tours.run.paceInvalid': 'ペースは km あたり 分:秒 で、{fastest} から {slowest} の間で入力してください。',
'tours.run.paceNeeded': '走行時間を計画してこのランを保存するには、ペースを入力してください。',
'tours.run.timeEstimated': '走行時間 (予定)',
'tours.run.timeEstimatedShort': '走行時間',
'tours.run.timeEstimatedInfoLabel': '走行時間の計画方法',
'tours.run.timeEstimatedInfo': '距離 × あなたのペースのみで、登り、下り、難しさは含みません。記録された時間ではなく計画です。',
'tours.run.timeSum': '{distance} を {pace} 分/km で {time}',
'tours.bike.profile': 'プロファイル',
'tours.bike.profile.touring': 'ツーリング',
'tours.bike.profile.road': 'ロード',
+17
View File
@@ -393,6 +393,23 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': '스노슈 하이킹',
'tours.winter.labelOnly':
'여행자가 표시했습니다. 경로와 시간은 다른 하이킹과 같이 계획되며 겨울 조건은 확인하지 않았습니다.',
'tours.planner.tourTypeHint.run': '내 페이스로 걸어서, 달리기 좋은 길로',
'tours.planner.namePlaceholder.run': '이름 없는 달리기',
'tours.planner.unreachable.run':
'달릴 수 있는 길이 {point}번 지점에 닿지 않습니다. 라우터가 아는 가장 가까운 길은 {distance} 떨어져 있습니다. 지점을 길 위로 옮기거나 그곳까지의 구간을 직접 그리세요.',
'tours.run.profile': '프로필',
'tours.run.profile.running': '달리기',
'tours.run.profileHint.running': '시내, 공원, 강변의 달리기 좋은, 되도록 포장된 길을 우선합니다.',
'tours.run.pace': '계획 페이스',
'tours.run.paceInfoLabel': '달리기 시간 계산 방법',
'tours.run.paceInfo': '킬로미터당 나만의 페이스입니다. 달리기 시간은 거리 × 페이스이며, 오르막, 내리막, 기술적 난이도는 포함되지 않습니다.',
'tours.run.paceInvalid': '페이스를 km당 분:초로, {fastest}에서 {slowest} 사이로 입력하세요.',
'tours.run.paceNeeded': '달리기 시간을 계획하고 이 달리기를 저장하려면 페이스를 입력하세요.',
'tours.run.timeEstimated': '달리기 시간 (계획)',
'tours.run.timeEstimatedShort': '달리기 시간',
'tours.run.timeEstimatedInfoLabel': '달리기 시간 계획 방법',
'tours.run.timeEstimatedInfo': '거리 × 나의 페이스뿐이며, 오르막, 내리막, 난이도는 포함되지 않습니다. 기록된 시간이 아니라 계획입니다.',
'tours.run.timeSum': '{distance}, {pace}분/km 페이스로 {time}',
'tours.bike.profile': '프로필',
'tours.bike.profile.touring': '투어링',
'tours.bike.profile.road': '로드',
+20
View File
@@ -412,6 +412,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Sneeuwschoenwandeling',
'tours.winter.labelOnly':
'Gemarkeerd door de reizigers. Route en tijd worden gepland als bij elke wandeling, niet gecontroleerd op de winter.',
'tours.planner.tourTypeHint.run': 'Te voet in je eigen tempo, over goed beloopbare wegen',
'tours.planner.namePlaceholder.run': 'Naamloze looptocht',
'tours.planner.unreachable.run':
'Geen hardloopbare weg bereikt punt {point}: de dichtstbijzijnde die de router kent ligt {distance} verderop. Zet het punt op een weg of teken het stuk ernaartoe met de hand.',
'tours.run.profile': 'Profiel',
'tours.run.profile.running': 'Hardlopen',
'tours.run.profileHint.running':
'Kiest goed beloopbare, zo veel mogelijk verharde wegen in de stad, in parken en langs rivieren.',
'tours.run.pace': 'Gepland tempo',
'tours.run.paceInfoLabel': 'Hoe de looptijd wordt berekend',
'tours.run.paceInfo':
'Je eigen tempo per kilometer. De looptijd is afstand × tempo; hoogtemeters en technische moeilijkheid tellen niet mee.',
'tours.run.paceInvalid': 'Vul het tempo in als min:sec per km, van {fastest} tot {slowest}.',
'tours.run.paceNeeded': 'Vul je tempo in om de looptijd te plannen en deze looptocht op te slaan.',
'tours.run.timeEstimated': 'Looptijd (gepland)',
'tours.run.timeEstimatedShort': 'Looptijd',
'tours.run.timeEstimatedInfoLabel': 'Hoe de looptijd wordt gepland',
'tours.run.timeEstimatedInfo':
'Afstand × je tempo en verder niets: hoogtemeters en moeilijkheid tellen niet mee. Een planning, geen opgenomen tijd.',
'tours.run.timeSum': '{distance} bij {pace} min/km is {time}',
'tours.bike.profile': 'Profiel',
'tours.bike.profile.touring': 'Toer',
'tours.bike.profile.road': 'Racefiets',
+20
View File
@@ -419,6 +419,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Wędrówka na rakietach śnieżnych',
'tours.winter.labelOnly':
'Oznaczone przez podróżnych. Trasa i czas są planowane jak przy każdej wędrówce, bez sprawdzania pod kątem zimy.',
'tours.planner.tourTypeHint.run': 'Pieszo we własnym tempie, po drogach dobrych do biegania',
'tours.planner.namePlaceholder.run': 'Bieg bez nazwy',
'tours.planner.unreachable.run':
'Do punktu {point} nie prowadzi żadna droga dobra do biegania: najbliższa, którą zna router, jest {distance} dalej. Przesuń punkt na drogę albo narysuj odcinek do niego ręcznie.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Bieganie',
'tours.run.profileHint.running':
'Wybiera drogi dobre do biegania, w miarę możliwości utwardzone, w mieście, w parkach i wzdłuż rzek.',
'tours.run.pace': 'Planowane tempo',
'tours.run.paceInfoLabel': 'Jak liczony jest czas biegu',
'tours.run.paceInfo':
'Twoje własne tempo na kilometr. Czas biegu to dystans × tempo; przewyższenia i trudność techniczna nie są uwzględniane.',
'tours.run.paceInvalid': 'Wpisz tempo jako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Wpisz swoje tempo, aby zaplanować czas biegu i zapisać ten bieg.',
'tours.run.timeEstimated': 'Czas biegu (planowany)',
'tours.run.timeEstimatedShort': 'Czas biegu',
'tours.run.timeEstimatedInfoLabel': 'Jak planowany jest czas biegu',
'tours.run.timeEstimatedInfo':
'Dystans × Twoje tempo i nic więcej: przewyższenia i trudność nie są uwzględniane. Plan, a nie zarejestrowany czas.',
'tours.run.timeSum': '{distance} przy {pace} min/km to {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turystyka',
'tours.bike.profile.road': 'Szosa',
+20
View File
@@ -423,6 +423,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Поход на снегоступах',
'tours.winter.labelOnly':
'Отмечено путешественниками. Маршрут и время рассчитаны как для любого похода, без проверки на зимние условия.',
'tours.planner.tourTypeHint.run': 'Пешком в своём темпе, по удобным для бега дорогам',
'tours.planner.namePlaceholder.run': 'Пробежка без названия',
'tours.planner.unreachable.run':
'К точке {point} не ведёт ни одна дорога для бега: ближайшая, которую знает маршрутизатор, в {distance}. Перенесите точку на дорогу или нарисуйте участок к ней вручную.',
'tours.run.profile': 'Профиль',
'tours.run.profile.running': 'Бег',
'tours.run.profileHint.running':
'Предпочитает удобные для бега, по возможности с покрытием, дороги в городе, в парках и вдоль рек.',
'tours.run.pace': 'Плановый темп',
'tours.run.paceInfoLabel': 'Как считается время бега',
'tours.run.paceInfo':
'Ваш собственный темп на километр. Время бега равно расстоянию × темп; набор высоты и техническая сложность не учитываются.',
'tours.run.paceInvalid': 'Введите темп как мин:с на км, от {fastest} до {slowest}.',
'tours.run.paceNeeded': 'Введите свой темп, чтобы рассчитать время бега и сохранить эту пробежку.',
'tours.run.timeEstimated': 'Время бега (план)',
'tours.run.timeEstimatedShort': 'Время бега',
'tours.run.timeEstimatedInfoLabel': 'Как планируется время бега',
'tours.run.timeEstimatedInfo':
'Расстояние × ваш темп и ничего больше: набор высоты и сложность не учитываются. Это план, а не записанное время.',
'tours.run.timeSum': '{distance} в темпе {pace} мин/км: {time}',
'tours.bike.profile': 'Профиль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шоссе',
+20
View File
@@ -404,6 +404,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Túra na snežniciach',
'tours.winter.labelOnly':
'Označené cestovateľmi. Trasa a čas sa plánujú ako pri každej túre, bez kontroly zimných podmienok.',
'tours.planner.tourTypeHint.run': 'Pešo vlastným tempom, po dobre bežateľných cestách',
'tours.planner.namePlaceholder.run': 'Beh bez názvu',
'tours.planner.unreachable.run':
'K bodu {point} nevedie žiadna cesta vhodná na beh: najbližšia, ktorú router pozná, je {distance} ďaleko. Presuňte bod na cestu alebo úsek k nemu nakreslite ručne.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Beh',
'tours.run.profileHint.running':
'Uprednostňuje dobre bežateľné, podľa možnosti spevnené cesty v meste, v parkoch a pozdĺž riek.',
'tours.run.pace': 'Plánované tempo',
'tours.run.paceInfoLabel': 'Ako sa počíta čas behu',
'tours.run.paceInfo':
'Vaše vlastné tempo na kilometer. Čas behu je vzdialenosť × tempo; prevýšenie a technická náročnosť sa nezapočítavajú.',
'tours.run.paceInvalid': 'Zadajte tempo ako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Zadajte svoje tempo, aby sa naplánoval čas behu a beh sa dal uložiť.',
'tours.run.timeEstimated': 'Čas behu (plánovaný)',
'tours.run.timeEstimatedShort': 'Čas behu',
'tours.run.timeEstimatedInfoLabel': 'Ako sa plánuje čas behu',
'tours.run.timeEstimatedInfo':
'Vzdialenosť × vaše tempo a nič iné: prevýšenie a náročnosť sa nezapočítavajú. Plán, nie zaznamenaný čas.',
'tours.run.timeSum': '{distance} pri {pace} min/km dáva {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Trekking',
'tours.bike.profile.road': 'Cestný',
+19
View File
@@ -402,6 +402,25 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Snöskovandring',
'tours.winter.labelOnly':
'Märkt av resenärerna. Rutt och tid planeras som för alla vandringar, inte kontrollerade för vinter.',
'tours.planner.tourTypeHint.run': 'Till fots i din egen takt, på bra löpvägar',
'tours.planner.namePlaceholder.run': 'Namnlös löptur',
'tours.planner.unreachable.run':
'Ingen väg som går att springa på når punkt {point}: den närmaste som routern känner till ligger {distance} bort. Flytta punkten till en väg eller rita sträckan dit för hand.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Löpning',
'tours.run.profileHint.running': 'Föredrar bra löpvägar, belagda där det går, i stan, i parker och längs vattendrag.',
'tours.run.pace': 'Planerat tempo',
'tours.run.paceInfoLabel': 'Så räknas löptiden ut',
'tours.run.paceInfo':
'Ditt eget tempo per kilometer. Löptiden är sträcka × tempo; stigningar och teknisk svårighet räknas inte in.',
'tours.run.paceInvalid': 'Ange tempot som min:sek per km, från {fastest} till {slowest}.',
'tours.run.paceNeeded': 'Ange ditt tempo för att planera löptiden och spara löpturen.',
'tours.run.timeEstimated': 'Löptid (planerad)',
'tours.run.timeEstimatedShort': 'Löptid',
'tours.run.timeEstimatedInfoLabel': 'Så planeras löptiden',
'tours.run.timeEstimatedInfo':
'Sträcka × ditt tempo och inget annat: stigningar och svårighet räknas inte in. En planering, ingen inspelad tid.',
'tours.run.timeSum': '{distance} i {pace} min/km blir {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Landsväg',
+18
View File
@@ -384,6 +384,24 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'เดินป่าด้วยรองเท้าลุยหิมะ',
'tours.winter.labelOnly':
'ผู้เดินทางเป็นผู้ทำเครื่องหมาย เส้นทางและเวลาวางแผนเหมือนการเดินป่าทั่วไป ไม่ได้ตรวจสอบสำหรับฤดูหนาว',
'tours.planner.tourTypeHint.run': 'เดินเท้าตามจังหวะของคุณ บนทางที่วิ่งได้สะดวก',
'tours.planner.namePlaceholder.run': 'การวิ่งที่ไม่มีชื่อ',
'tours.planner.unreachable.run':
'ไม่มีทางที่วิ่งได้ไปถึงจุด {point}: ทางที่ใกล้ที่สุดที่เราเตอร์รู้จักอยู่ห่างออกไป {distance} ย้ายจุดไปไว้บนทาง หรือวาดช่วงไปยังจุดนั้นด้วยมือ',
'tours.run.profile': 'โปรไฟล์',
'tours.run.profile.running': 'วิ่ง',
'tours.run.profileHint.running': 'เลือกทางที่วิ่งได้สะดวก ลาดยางเท่าที่ทำได้ ในเมือง ในสวนสาธารณะ และริมแม่น้ำ',
'tours.run.pace': 'เพซที่วางแผนไว้',
'tours.run.paceInfoLabel': 'วิธีคำนวณเวลาวิ่ง',
'tours.run.paceInfo': 'เพซของคุณเองต่อกิโลเมตร เวลาวิ่งคือระยะทาง × เพซ ไม่รวมทางขึ้น ทางลง และความยากทางเทคนิค',
'tours.run.paceInvalid': 'ใส่เพซในรูปแบบ นาที:วินาที ต่อ km ตั้งแต่ {fastest} ถึง {slowest}',
'tours.run.paceNeeded': 'ใส่เพซของคุณเพื่อวางแผนเวลาวิ่งและบันทึกการวิ่งนี้',
'tours.run.timeEstimated': 'เวลาวิ่ง (ตามแผน)',
'tours.run.timeEstimatedShort': 'เวลาวิ่ง',
'tours.run.timeEstimatedInfoLabel': 'วิธีวางแผนเวลาวิ่ง',
'tours.run.timeEstimatedInfo':
'ระยะทาง × เพซของคุณเท่านั้น ไม่รวมทางขึ้น ทางลง และความยาก เป็นแผน ไม่ใช่เวลาที่บันทึกไว้',
'tours.run.timeSum': '{distance} ที่เพซ {pace} นาที/km เท่ากับ {time}',
'tours.bike.profile': 'โปรไฟล์',
'tours.bike.profile.touring': 'ทัวริ่ง',
'tours.bike.profile.road': 'เสือหมอบ',
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 832;
const GENERAL_KEY_COUNT = 848;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+20
View File
@@ -404,6 +404,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Kar ayakkabısıyla yürüyüş',
'tours.winter.labelOnly':
'Gezginler tarafından işaretlendi. Rota ve süre her yürüyüşteki gibi planlanır, kış koşullarına göre denetlenmez.',
'tours.planner.tourTypeHint.run': 'Yürüyerek kendi temponda, koşmaya uygun yollarda',
'tours.planner.namePlaceholder.run': 'Adsız koşu',
'tours.planner.unreachable.run':
'{point}. noktaya koşmaya uygun hiçbir yol ulaşmıyor: yönlendiricinin bildiği en yakın yol {distance} uzakta. Noktayı bir yola taşı ya da oraya giden bölümü elle çiz.',
'tours.run.profile': 'Profil',
'tours.run.profile.running': 'Koşu',
'tours.run.profileHint.running':
'Şehirde, parklarda ve nehir kenarlarında koşmaya uygun, mümkün olduğunca asfalt yolları tercih eder.',
'tours.run.pace': 'Planlanan tempo',
'tours.run.paceInfoLabel': 'Koşu süresi nasıl hesaplanır',
'tours.run.paceInfo':
'Kilometre başına kendi tempon. Koşu süresi mesafe × tempodur; tırmanışlar, inişler ve teknik zorluk hesaba katılmaz.',
'tours.run.paceInvalid': 'Tempoyu km başına dk:sn olarak, {fastest} ile {slowest} arasında gir.',
'tours.run.paceNeeded': 'Koşu süresini planlamak ve bu koşuyu kaydetmek için temponu gir.',
'tours.run.timeEstimated': 'Koşu süresi (planlanan)',
'tours.run.timeEstimatedShort': 'Koşu süresi',
'tours.run.timeEstimatedInfoLabel': 'Koşu süresi nasıl planlanır',
'tours.run.timeEstimatedInfo':
'Mesafe × tempon, başka hiçbir şey değil: tırmanışlar, inişler ve zorluk hesaba katılmaz. Kayıtlı bir süre değil, bir plan.',
'tours.run.timeSum': '{distance}, {pace} dk/km tempoyla {time}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Yol',
+20
View File
@@ -418,6 +418,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Похід на снігоступах',
'tours.winter.labelOnly':
'Позначено мандрівниками. Маршрут і час сплановано як для будь-якого походу, без перевірки на зимові умови.',
'tours.planner.tourTypeHint.run': 'Пішки у власному темпі, дорогами, зручними для бігу',
'tours.planner.namePlaceholder.run': 'Пробіжка без назви',
'tours.planner.unreachable.run':
'До точки {point} не веде жодна дорога для бігу: найближча, яку знає маршрутизатор, за {distance}. Перенесіть точку на дорогу або намалюйте ділянку до неї вручну.',
'tours.run.profile': 'Профіль',
'tours.run.profile.running': 'Біг',
'tours.run.profileHint.running':
'Віддає перевагу зручним для бігу, по змозі з покриттям, дорогам у місті, у парках і вздовж річок.',
'tours.run.pace': 'Плановий темп',
'tours.run.paceInfoLabel': 'Як рахується час бігу',
'tours.run.paceInfo':
'Ваш власний темп на кілометр. Час бігу дорівнює відстані × темп; набір висоти й технічна складність не враховуються.',
'tours.run.paceInvalid': 'Введіть темп як хв:с на км, від {fastest} до {slowest}.',
'tours.run.paceNeeded': 'Введіть свій темп, щоб розрахувати час бігу й зберегти цю пробіжку.',
'tours.run.timeEstimated': 'Час бігу (план)',
'tours.run.timeEstimatedShort': 'Час бігу',
'tours.run.timeEstimatedInfoLabel': 'Як планується час бігу',
'tours.run.timeEstimatedInfo':
'Відстань × ваш темп і нічого більше: набір висоти й складність не враховуються. Це план, а не записаний час.',
'tours.run.timeSum': '{distance} у темпі {pace} хв/км: {time}',
'tours.bike.profile': 'Профіль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шосе',
+20
View File
@@ -402,6 +402,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Đi bộ bằng giày tuyết',
'tours.winter.labelOnly':
'Do du khách đánh dấu. Tuyến đường và thời gian được lập như mọi chuyến đi bộ khác, không kiểm tra cho mùa đông.',
'tours.planner.tourTypeHint.run': 'Đi bộ theo nhịp của bạn, trên những con đường dễ chạy',
'tours.planner.namePlaceholder.run': 'Buổi chạy chưa đặt tên',
'tours.planner.unreachable.run':
'Không có đường chạy được nào tới điểm {point}: đường gần nhất mà bộ định tuyến biết cách {distance}. Hãy dời điểm lên một con đường, hoặc tự vẽ đoạn tới đó.',
'tours.run.profile': 'Hồ sơ',
'tours.run.profile.running': 'Chạy bộ',
'tours.run.profileHint.running':
'Ưu tiên đường dễ chạy, trải nhựa khi có thể, trong thành phố, trong công viên và dọc bờ sông.',
'tours.run.pace': 'Nhịp chạy dự kiến',
'tours.run.paceInfoLabel': 'Cách tính thời gian chạy',
'tours.run.paceInfo':
'Nhịp chạy của riêng bạn trên mỗi kilômét. Thời gian chạy là quãng đường × nhịp chạy; độ dốc lên xuống và độ khó kỹ thuật không được tính.',
'tours.run.paceInvalid': 'Nhập nhịp chạy dạng phút:giây mỗi km, từ {fastest} đến {slowest}.',
'tours.run.paceNeeded': 'Nhập nhịp chạy của bạn để lên kế hoạch thời gian chạy và lưu buổi chạy này.',
'tours.run.timeEstimated': 'Thời gian chạy (dự kiến)',
'tours.run.timeEstimatedShort': 'Thời gian chạy',
'tours.run.timeEstimatedInfoLabel': 'Cách lên kế hoạch thời gian chạy',
'tours.run.timeEstimatedInfo':
'Quãng đường × nhịp chạy của bạn, không gì khác: độ dốc lên xuống và độ khó không được tính. Đây là kế hoạch, không phải thời gian đã ghi.',
'tours.run.timeSum': '{distance} với nhịp {pace} phút/km là {time}',
'tours.bike.profile': 'Hồ sơ',
'tours.bike.profile.touring': 'Du lịch',
'tours.bike.profile.road': 'Đường trường',
+16
View File
@@ -367,6 +367,22 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': '冬季健行',
'tours.winter.activity.snowshoe': '雪鞋健行',
'tours.winter.labelOnly': '由旅行者標記。路線與時間按一般健行規劃,未針對冬季查核。',
'tours.planner.tourTypeHint.run': '依你自己的配速步行,走適合跑步的道路',
'tours.planner.namePlaceholder.run': '未命名的跑步',
'tours.planner.unreachable.run': '沒有適合跑步的道路能到達第 {point} 個點:路由器所知最近的道路在 {distance} 之外。請把該點移到道路上,或手動繪製到那裡的路段。',
'tours.run.profile': '類型',
'tours.run.profile.running': '跑步',
'tours.run.profileHint.running': '優先選擇市區、公園與河岸適合跑步、盡量鋪面的道路。',
'tours.run.pace': '計畫配速',
'tours.run.paceInfoLabel': '跑步時間如何計算',
'tours.run.paceInfo': '你自己的每公里配速。跑步時間等於距離 × 配速;爬升、下降與技術難度不計入。',
'tours.run.paceInvalid': '請以每公里 分:秒 輸入配速,範圍 {fastest} 至 {slowest}。',
'tours.run.paceNeeded': '輸入你的配速,以規劃跑步時間並儲存這次跑步。',
'tours.run.timeEstimated': '跑步時間(計畫)',
'tours.run.timeEstimatedShort': '跑步時間',
'tours.run.timeEstimatedInfoLabel': '跑步時間如何規劃',
'tours.run.timeEstimatedInfo': '僅為距離 × 你的配速:爬升、下降與難度皆不計入。這是計畫,不是記錄的時間。',
'tours.run.timeSum': '{distance},配速 {pace} 分/公里,用時 {time}',
'tours.bike.profile': '類型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+16
View File
@@ -368,6 +368,22 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': '冬季徒步',
'tours.winter.activity.snowshoe': '雪鞋徒步',
'tours.winter.labelOnly': '由旅行者标记。路线和时间按普通徒步规划,未针对冬季核查。',
'tours.planner.tourTypeHint.run': '按你自己的配速步行,走适合跑步的道路',
'tours.planner.namePlaceholder.run': '未命名的跑步',
'tours.planner.unreachable.run': '没有适合跑步的道路能到达第 {point} 个点:路由器知道的最近道路在 {distance} 之外。请把该点移到道路上,或手动绘制到那里的路段。',
'tours.run.profile': '类型',
'tours.run.profile.running': '跑步',
'tours.run.profileHint.running': '优先选择城市、公园和河边适合跑步、尽量铺装的道路。',
'tours.run.pace': '计划配速',
'tours.run.paceInfoLabel': '跑步时间如何计算',
'tours.run.paceInfo': '你自己的每公里配速。跑步时间等于距离 × 配速;爬升、下降和技术难度不计入。',
'tours.run.paceInvalid': '请按每公里 分:秒 输入配速,范围 {fastest} 至 {slowest}。',
'tours.run.paceNeeded': '输入你的配速,以计划跑步时间并保存这次跑步。',
'tours.run.timeEstimated': '跑步时间(计划)',
'tours.run.timeEstimatedShort': '跑步时间',
'tours.run.timeEstimatedInfoLabel': '跑步时间如何计划',
'tours.run.timeEstimatedInfo': '仅为距离 × 你的配速:爬升、下降和难度均不计入。这是计划,不是记录的时间。',
'tours.run.timeSum': '{distance},配速 {pace} 分/公里,用时 {time}',
'tours.bike.profile': '类型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+1
View File
@@ -55,6 +55,7 @@ export * from './place/place-website';
export * from './roadtrip/roadtrip.schema';
export * from './place/track-colors';
export * from './tours/tours.schema';
export * from './tours/run-pace';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-library.schema';
export * from './tours/tour-poi-search.schema';
+26
View File
@@ -0,0 +1,26 @@
import {
MAX_RUN_PACE_SECONDS_PER_KM,
MIN_RUN_PACE_SECONDS_PER_KM,
runMinutesAtPace,
runPaceSecondsPerKmSchema,
} from './run-pace';
import { describe, expect, it } from 'vitest';
describe('run pace (RUN-01)', () => {
it('plans the distance at the pace: 8 km at 6:00 min/km is 48 minutes', () => {
expect(runMinutesAtPace(8, 360)).toBe(48);
expect(runMinutesAtPace(10, 330)).toBe(55);
// Rounded to the minute, never cut short: 5.03 km at 5:00 is 25 min 9 s.
expect(runMinutesAtPace(5.03, 300)).toBe(25);
expect(runMinutesAtPace(0, 360)).toBe(0);
});
it('takes whole seconds between 2:00 and 20:00 min/km only', () => {
expect([MIN_RUN_PACE_SECONDS_PER_KM, MAX_RUN_PACE_SECONDS_PER_KM]).toEqual([120, 1200]);
expect(runPaceSecondsPerKmSchema.safeParse(360).success).toBe(true);
for (const pace of [0, -1, 119, 1201, 300.5, Number.POSITIVE_INFINITY]) {
expect(runPaceSecondsPerKmSchema.safeParse(pace).success).toBe(false);
}
});
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
/**
* RUN-01: a run is planned with the runner's own pace, in seconds per
* kilometre, and its time is the distance at that pace. Nothing else goes in:
* no climb, no descent, no surface and no difficulty, and TREK suggests no
* pace of its own. A run without a pace has no time; it stays unknown.
*
* The bounds keep a typo out, not a runner: 2:00 min/km is faster than any
* road record, 20:00 min/km slower than walking.
*/
export const MIN_RUN_PACE_SECONDS_PER_KM = 2 * 60;
export const MAX_RUN_PACE_SECONDS_PER_KM = 20 * 60;
export const runPaceSecondsPerKmSchema = z
.number()
.int()
.min(MIN_RUN_PACE_SECONDS_PER_KM)
.max(MAX_RUN_PACE_SECONDS_PER_KM);
/** The planned running time in whole minutes: the distance at the pace, 8 km at 6:00 min/km is 48 minutes. */
export function runMinutesAtPace(distanceKm: number, paceSecondsPerKm: number): number {
return Math.round((distanceKm * paceSecondsPerKm) / 60);
}
+9 -5
View File
@@ -3,6 +3,7 @@ import {
paddleBoatTypeSchema,
paddleWaterContextSchema,
plannableTourTypeSchema,
runProfileSchema,
} from './tours.schema';
import { z } from 'zod';
@@ -38,20 +39,23 @@ export type TourGpxPreviewResponse = z.infer<typeof tourGpxPreviewResponseSchema
/**
* The fields of the multipart import beside the file, as strings the way a
* form sends them. Left out, the import is a hike, as it always was; a paddle
* import names its boat and water, and a bike import its profile, like a
* planned tour of that type does.
* import names its boat and water, and a bike import and a run import (RUN-01)
* their profile, like a planned tour of that type does. A run import has no
* pace: its time stays unknown until a run is planned from it.
*/
export const tourGpxImportFieldsSchema = z
.object({
tour_type: plannableTourTypeSchema.default('hike'),
bike_profile: bikeProfileSchema.optional(),
run_profile: runProfileSchema.optional(),
boat_type: paddleBoatTypeSchema.optional(),
water_context: paddleWaterContextSchema.optional(),
})
.superRefine((value, ctx) => {
const owners = { bike_profile: 'bike', boat_type: 'kayak', water_context: 'kayak' } as const;
for (const [field, owner] of Object.entries(owners) as Array<[keyof typeof owners, string]>) {
const name = owner === 'bike' ? 'a bike import' : 'a paddle import';
const owners = { bike_profile: 'bike', run_profile: 'run', boat_type: 'kayak', water_context: 'kayak' } as const;
const names = { bike: 'a bike import', run: 'a run import', kayak: 'a paddle import' } as const;
for (const [field, owner] of Object.entries(owners) as Array<[keyof typeof owners, keyof typeof names]>) {
const name = names[owner];
if (value.tour_type === owner && value[field] === undefined)
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `${name} needs ${field}` });
if (value.tour_type !== owner && value[field] !== undefined)
@@ -40,7 +40,7 @@ describe('tour POI search contracts (PLN-02)', () => {
],
}).success,
).toBe(false);
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'run' }).success).toBe(false);
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'food' }).success).toBe(false);
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, add: true }).success).toBe(false);
});
@@ -26,6 +26,7 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly Tou
bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint', 'toilets', 'shelter', 'picnic', 'campsite', 'sights'],
kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'],
walk: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'restaurant', 'market', 'toilets', 'drinking_water'],
run: ['drinking_water', 'toilets', 'park', 'viewpoint', 'cafe', 'food', 'shelter', 'sights'],
};
/**
+84 -1
View File
@@ -8,6 +8,7 @@ import {
MAX_STATION_STAY_MINUTES,
recommendedPaddleSpeedKmh,
plannedTourDurationMinutesSchema,
RUN_PROFILES,
stationKeySchema,
tourCopyRequestSchema,
tourCreateRequestSchema,
@@ -284,7 +285,7 @@ describe('Tours paddle type', () => {
it('refuses boat fields on a hike, and a type nobody can plan yet', () => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, boat_type: 'sup' })).toThrow(/paddle tour/);
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, paddle_speed_kmh: 4 })).toThrow(/paddle tour/);
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, tour_type: 'run' })).toThrow();
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, tour_type: 'food' })).toThrow();
});
it('suggests 4 km/h for kayak, canoe and SUP, and nothing for any other boat', () => {
@@ -590,3 +591,85 @@ describe('winter activity of a hike (HIK-01)', () => {
expect(tourListItemSchema.safeParse({ ...listedHike, winter_activity: 'ski_tour' }).success).toBe(false);
});
});
describe('runs (RUN-01)', () => {
const run = { ...baseRequest, tour_type: 'run' as const, run_profile: 'running' as const };
const ferrata = {
kind: 'ferrata' as const,
ref: 'way/1',
name: null,
geometry: [
[48, 11],
[48.01, 11.02],
],
};
it('saves a run with its profile and pace, and nothing of a hike, a bike tour or a paddle tour', () => {
expect(RUN_PROFILES).toEqual(['running']);
expect(tourCreateRequestSchema.parse({ ...run, run_pace_seconds_per_km: 360 })).toMatchObject({
tour_type: 'run',
run_profile: 'running',
run_pace_seconds_per_km: 360,
max_hiking_difficulty: null,
});
// Without a pace the run has no time: unknown, never a pace TREK made up.
expect(tourCreateRequestSchema.parse(run).run_pace_seconds_per_km).toBeUndefined();
expect(tourCreateRequestSchema.parse({ ...run, run_pace_seconds_per_km: null }).run_pace_seconds_per_km).toBeNull();
});
it('refuses a pace that is no whole number of seconds, not positive or out of bounds', () => {
for (const pace of [0, -300, 119, 1201, 360.5, Number.NaN]) {
expect(tourCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: pace }).success).toBe(false);
}
expect(tourCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: 120 }).success).toBe(true);
expect(tourCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: 1200 }).success).toBe(true);
});
it('refuses a run without a profile, with a profile it does not know, or with fields of another type', () => {
expect(() => tourCreateRequestSchema.parse({ ...run, run_profile: null })).toThrow(/a run needs a run profile/);
expect(tourCreateRequestSchema.safeParse({ ...run, run_profile: 'sprint' }).success).toBe(false);
expect(() => tourCreateRequestSchema.parse({ ...run, max_hiking_difficulty: 2 })).toThrow(/a run has no hiking/);
expect(() => tourCreateRequestSchema.parse({ ...run, winter_activity: 'snowshoe' })).toThrow(/a run has no winter/);
expect(() => tourCreateRequestSchema.parse({ ...run, bike_profile: 'road' })).toThrow(/bike tour/);
expect(() => tourCreateRequestSchema.parse({ ...run, boat_type: 'sup' })).toThrow(/paddle tour/);
const overFerrata = { ...run, waypoints: [{ ...run.waypoints[0], fixed_leg: ferrata }, run.waypoints[1]] };
expect(() => tourCreateRequestSchema.parse(overFerrata)).toThrow(/a run has no via ferrata/);
});
it('keeps a profile and a pace to a run', () => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, run_profile: 'running' })).toThrow(/belongs to a run/);
const bike = { ...baseRequest, tour_type: 'bike', bike_profile: 'road', run_pace_seconds_per_km: 300 };
expect(() => tourCreateRequestSchema.parse(bike)).toThrow(/run_pace_seconds_per_km belongs to a run/);
});
it('imports a GPX file as a run only with its profile, and a run profile only for one', () => {
expect(tourGpxImportFieldsSchema.parse({ tour_type: 'run', run_profile: 'running' })).toMatchObject({
tour_type: 'run',
run_profile: 'running',
});
expect(tourGpxImportFieldsSchema.safeParse({ tour_type: 'run' }).success).toBe(false);
const bike = { tour_type: 'bike', bike_profile: 'road', run_profile: 'running' };
expect(tourGpxImportFieldsSchema.safeParse(bike).success).toBe(false);
});
it('lists the profile and pace, and reads a list cached before they existed', () => {
const listed = {
place_id: 1,
tour_type: 'run',
distance: 8,
elevation_gain: 40,
elevation_loss: 40,
duration: 48,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
name: 'River loop',
planned: false,
caution: false,
};
const planned = { ...listed, run_profile: 'running', run_pace_seconds_per_km: 360 };
expect(tourListItemSchema.parse(planned)).toMatchObject({ run_profile: 'running', run_pace_seconds_per_km: 360 });
expect(tourListItemSchema.parse(listed).run_profile).toBeUndefined();
});
});
+52 -6
View File
@@ -5,6 +5,7 @@ import {
portageSpeedKmhSchema,
trackMarksSchema,
} from './paddle-plan.schema';
import { runPaceSecondsPerKmSchema } from './run-pace';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
@@ -30,9 +31,10 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
* are offered. `kayak` is the key the paddle tour has had in `tour_types` from
* the start; the app calls it a paddle tour, and the boat is a field of its
* own. A bike tour (BIK-01) names its profile the same way. `walk` is the city
* walk (CW-01): stations joined on foot, without a hiking limit.
* walk (CW-01): stations joined on foot, without a hiking limit. `run` is a
* run (RUN-01), planned on foot with the runner's own pace.
*/
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'bike', 'kayak']);
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak']);
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
/**
@@ -46,6 +48,16 @@ export const BIKE_PROFILES = ['touring', 'road', 'gravel', 'mtb'] as const;
export const bikeProfileSchema = z.enum(BIKE_PROFILES);
export type BikeProfile = z.infer<typeof bikeProfileSchema>;
/**
* RUN-01: what a run is planned as, the one profile it is routed for. Running
* keeps to runnable, mostly paved ways in towns, parks and along rivers;
* trail running follows with RUN-02. A profile asks the router for ways, it
* says nothing about whether one can be run.
*/
export const RUN_PROFILES = ['running'] as const;
export const runProfileSchema = z.enum(RUN_PROFILES);
export type RunProfile = z.infer<typeof runProfileSchema>;
/** PAD-01: the one boat a paddle tour is planned for. */
export const paddleBoatTypeSchema = z.enum(['kayak', 'canoe', 'sup', 'other']);
export type PaddleBoatType = z.infer<typeof paddleBoatTypeSchema>;
@@ -177,6 +189,10 @@ export const tourSchema = z.object({
bike_profile: bikeProfileSchema.nullable().optional(),
/** HIK-01: a hike's winter label; null for none and for every other type. Absent in a list cached before it existed. */
winter_activity: winterActivitySchema.nullable().optional(),
/** RUN-01: a run's profile; null for every other type. Absent in a list cached before it existed. */
run_profile: runProfileSchema.nullable().optional(),
/** RUN-01: the pace a run is planned at, seconds per km; null while none is entered and for every other type. */
run_pace_seconds_per_km: z.number().nullable().optional(),
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
@@ -415,6 +431,10 @@ export const tourCreateRequestSchema = z
bike_profile: bikeProfileSchema.nullable().optional(),
/** HIK-01, a hike's only: its winter label, null to take it off; left out, an edit keeps the one it has. */
winter_activity: winterActivitySchema.nullable().optional(),
/** RUN-01, a run's only, and its one requirement. */
run_profile: runProfileSchema.nullable().optional(),
/** RUN-01, a run's only: the pace it is planned at, seconds per km; null while none is entered. */
run_pace_seconds_per_km: runPaceSecondsPerKmSchema.nullable().optional(),
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
@@ -489,6 +509,15 @@ export type TourCreateInput = z.input<typeof tourCreateRequestSchema>;
type Issue = { path: Array<string | number>; message: string };
/** How a refusal names each type. */
const TYPE_NAMED: Record<PlannableTourType, string> = {
kayak: 'a paddle tour',
bike: 'a bike tour',
walk: 'a city walk',
run: 'a run',
hike: 'a hike',
};
const PADDLE_FIELDS = [
'boat_type',
'water_context',
@@ -499,8 +528,9 @@ const PADDLE_FIELDS = [
/**
* What belongs to which type: a hike carries a routing limit and no boat; a
* bike tour exactly one profile and nothing of a hike's or a paddler's; a
* paddle tour exactly one boat and its water, and no hiking limit.
* bike tour and a run exactly one profile each and nothing of a hike's or a
* paddler's, a run also its pace; a paddle tour exactly one boat and its
* water, and no hiking limit.
*/
function tourTypeIssues(value: {
tour_type: PlannableTourType;
@@ -508,6 +538,8 @@ function tourTypeIssues(value: {
hiking_preferences?: HikingPreferences | null;
bike_profile?: BikeProfile | null;
winter_activity?: WinterActivity | null;
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
@@ -523,7 +555,7 @@ function tourTypeIssues(value: {
}): Issue[] {
const issues: Issue[] = [];
const type = value.tour_type;
const named = { kayak: 'a paddle tour', bike: 'a bike tour', walk: 'a city walk', hike: 'a hike' }[type];
const named = TYPE_NAMED[type];
if (type === 'kayak') {
if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' });
if (value.water_context == null)
@@ -548,13 +580,14 @@ function tourTypeIssues(value: {
issues.push({ path: ['bike_profile'], message: 'a bike tour needs a bike profile' });
if (type !== 'bike' && value.bike_profile != null)
issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' });
issues.push(...runIssues(value));
// A portage and a lock are a paddler's; a via ferrata is climbed, never ridden.
(value.waypoints ?? []).forEach((point, index) => {
if (type !== 'kayak' && point.fixed_leg?.kind === 'portage')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' });
if (type !== 'kayak' && point.lock != null)
issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' });
if ((type === 'bike' || type === 'walk') && point.fixed_leg?.kind === 'ferrata')
if ((type === 'bike' || type === 'walk' || type === 'run') && point.fixed_leg?.kind === 'ferrata')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` });
// A stay, an importance and a protection are a city walk's stations'.
if (type !== 'walk' && (point.stay_minutes != null || point.importance != null || point.protected === true))
@@ -563,6 +596,19 @@ function tourTypeIssues(value: {
return issues;
}
/** RUN-01: a run names its profile; its profile and pace belong to no other type. */
function runIssues(value: {
tour_type: PlannableTourType;
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
}): Issue[] {
if (value.tour_type === 'run')
return value.run_profile == null ? [{ path: ['run_profile'], message: 'a run needs a run profile' }] : [];
return (['run_profile', 'run_pace_seconds_per_km'] as const)
.filter((field) => value[field] != null)
.map((field) => ({ path: [field], message: `${field} belongs to a run` }));
}
export const tourCreateResponseSchema = z.object({
tour: tourListItemSchema,
waypoints: z.array(tourWaypointSchema),