feat(tours): plan a multi-day tour as one route split into stages

A hut-to-hut hike or a paddle trip over several days is now planned in the
Tours planner as one tour: the whole route is planned as before, and stops
along it are marked as stage ends (MDT-01). The stages are read off that one
route in its order, never kept as copies, so a change inside a stage changes
the whole tour and every figure on it.

- A Day tour / Multi-day tour switch beside the tour type; a stop's menu
  ends a stage there or lets it go on. Back to a day tour removes every stage
  end after a confirmation, as one step undo brings back.
- The list of points is headed per stage with its distance and moving time;
  a stop that ends a stage carries a moon at its number, on the map too.
- The overview lists the stages from stop to stop with distance, climb,
  descent and time (hiking rule per stage, paddle estimate per stage, the
  router's time shared by distance otherwise), marks where each ends across
  the elevation profile, and a stage of more than a day on the move warns.
- A stage end a change cannot keep (its stop removed, moved to an end or
  made a shaping point) is dropped and said so, with undo, never moved
  to another point.
- A multi-day tour may take a day a stage, so its time limit grows with its
  stages; the tour keeps its one type, portages included.
- The list and the MCP tools show the stage count and where stages end.

Stage ends are a flag on the waypoint (migration 046000); a tour without one
is a day tour, as every tour saved before.
This commit is contained in:
Maurice
2026-10-20 12:39:08 +02:00
parent acc294dd8e
commit 2449616ee9
76 changed files with 2669 additions and 55 deletions
@@ -2,7 +2,7 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
@@ -20,7 +20,7 @@ function plannerWaypointIcon(
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>${plannerPinBadge(point)}`,
});
}
@@ -46,10 +46,10 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const { label, shaping } = point;
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping, stageEnd]
);
const eventHandlers = useMemo(
() => ({
@@ -118,6 +118,8 @@ export interface MapViewProps {
detail?: string;
label?: string;
shaping?: boolean;
/** MDT-01: a stage of a multi-day tour ends here; the pin carries a moon at its corner. */
stageEnd?: boolean;
}>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
@@ -1,6 +1,7 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
// FE-PLANNER-PIN-002: the stop where a stage of a multi-day tour ends carries the moon at its corner (MDT-01).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
@@ -9,4 +10,13 @@ describe('plannerPinShape', () => {
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
it('FE-PLANNER-PIN-002: marks a stage end with the moon at the corner, and nothing else', () => {
const badge = plannerPinBadge({ stageEnd: true, label: '3' });
expect(badge).toContain('data-stage-end="true"');
expect(badge).toContain('<svg');
expect(badge).toContain('inset-inline-end');
expect(plannerPinBadge({ label: '3' })).toBe('');
expect(plannerPinBadge({ stageEnd: true, shaping: true })).toBe('');
});
});
@@ -1,8 +1,14 @@
import { Moon } from 'lucide-react';
import { createElement } from 'react';
import { renderIconMarkup } from '../../utils/iconMarkup';
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
/** MDT-01: a stage of a multi-day tour ends at this stop. */
stageEnd?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
@@ -14,3 +20,15 @@ export function plannerPinShape(
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
const MOON = renderIconMarkup(createElement(Moon, { size: 9, strokeWidth: 2.6, 'aria-hidden': true }));
/**
* MDT-01: the moon at the corner of a pin where a stage ends, the night
* between two days of the tour, as the planner's list marks the stop's number.
* Laid over the pin, so it needs no room of its own and moves with it.
*/
export function plannerPinBadge(point: PlannerPinPoint): string {
if (!point.stageEnd || point.shaping) return '';
return `<span data-stage-end="true" aria-hidden="true" style="position:absolute;bottom:-4px;inset-inline-end:-4px;display:grid;place-items:center;width:15px;height:15px;border-radius:9999px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);pointer-events:none">${MOON}</span>`;
}
@@ -0,0 +1,35 @@
// FE-PLANNER-PIN-GL-001: the GL map's planner pins carry the moon where a stage of a multi-day tour ends (MDT-01).
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL';
describe('usePlannerWaypointMarkersGL', () => {
it('FE-PLANNER-PIN-GL-001: puts the moon on the pin of a stage end only, numbered as the list numbers it', () => {
const container = document.createElement('div');
const map = { getContainer: () => container, unproject: () => ({ lat: 0, lng: 0 }) };
const attached: HTMLElement[] = [];
renderHook(() =>
usePlannerWaypointMarkersGL({
mapRef: { current: map },
mapReady: true,
pinsRef: { current: [] },
attachPin: (el) => {
attached.push(el);
return { remove: vi.fn(), setLngLat: vi.fn() };
},
pinCss: () => 'display:flex',
plannerWaypoints: [
{ id: 'a', lat: 47, lng: 11, label: '1' },
{ id: 'b', lat: 47.1, lng: 11, label: '2', stageEnd: true },
],
selectedPlannerWaypointId: null,
onPlannerWaypointClick: vi.fn(),
onPlannerWaypointMove: undefined,
})
);
expect(attached).toHaveLength(2);
expect(attached[0].querySelector('[data-stage-end]')).toBeNull();
expect(attached[1].querySelector('[data-stage-end]')).not.toBeNull();
expect(attached[1].textContent).toBe('2');
});
});
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -79,6 +79,9 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
// MDT-01: the moon where a stage ends; the pin is positioned, so the badge sits at its corner.
const badge = plannerPinBadge(point);
if (badge) el.insertAdjacentHTML('beforeend', badge);
const swallow = (event: Event) => event.stopPropagation();
const select = (event: Event) => {
event.stopPropagation();
@@ -7,6 +7,7 @@ import { TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGradeTile } fro
import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
import { TourStagesBadge } from './stages/StageParts'
import { useTourDays } from './tourDays'
import { Tooltip } from '../shared/Tooltip'
@@ -77,6 +78,7 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
>
<TourCautionBadge tour={tour} />
<TourActivityBadge tour={tour} />
<TourStagesBadge tour={tour} />
<TourDayRowPills tour={tour} compact />
<TourDaysBadge tourDays={tourDays} />
</div>
@@ -31,6 +31,10 @@ import { SkiTrackCard } from '../ski/SkiTrackCard'
import type { SkiClassification } from '../ski/useSkiClassification'
import { TourTypeFields } from './TourTypeFields'
import TourWaypointList from './TourWaypointList'
import { TourStageLossNotice } from '../stages/TourStageLossNotice'
import { TourStageModeField } from '../stages/TourStageModeField'
import { TourStagesSection } from '../stages/TourStagesSection'
import { stageBoundariesM } from '../stages/tourStages'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -462,10 +466,14 @@ export function TourPlannerRail({ planner,
<TourTypeFields planner={planner} locked={locked} />
<TourStageModeField planner={planner} locked={locked} />
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
<UnreachableNotice planner={planner} />
<TourStageLossNotice planner={planner} />
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} tourPlaceIds={tourPlaceIds} />
<BikeWaysCard planner={planner} />
@@ -603,6 +611,7 @@ function ElevationSection({ id, samples, gradientId, planner }: {
onFocusChange={planner.setRouteProfileFocus}
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
bands={crossings}
marks={stageBoundariesM(planner.stages.stages)}
/>}
</div>
</TourSection>
@@ -784,6 +793,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<div className="flex flex-col gap-3">
<FerrataCrossingBadges />
<RailFacts facts={tourMetricFacts(routeAnalysis, distanceUnit, t, tourTimeFields)} />
<TourStagesSection planner={planner} />
{routeAnalysis && !planner.isPaddle && (
<ElevationSection
id="tour-planner-editable-elevation-profile"
@@ -1,7 +1,7 @@
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-015: shaping points, the water router's answers
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-016: shaping points, the water router's answers
// and a city walk's stops in the list, with their arrivals and opening hours on a day of the walk,
// other ways for a hike's leg and round hikes out of a stop (P2A-03, P2A-05, PAD-11, CW-01, CW-02,
// CW-04, RTE-02, RTE-03).
// other ways for a hike's leg and round hikes out of a stop, and the stages of a multi-day tour
// (P2A-03, P2A-05, PAD-11, CW-01, CW-02, CW-04, RTE-02, RTE-03, MDT-01).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -392,4 +392,47 @@ describe('TourWaypointList', () => {
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
});
});
it('FE-TOUR-WAYPOINT-LIST-016: heads each stage where it begins, marks the stop that ends one, and ends a stage from the menu', async () => {
const stage = (number: number, from: number, to: number, fromId: string, toId: string) => ({
number,
from,
to,
fromId,
toId,
distanceKm: 4.2,
gain: null,
loss: null,
minutes: 75,
fromM: null,
toM: null,
overDay: false,
});
const stages = {
multiDay: true,
stages: [stage(1, 0, 2, 'a', 'b'), stage(2, 2, 3, 'b', 'c')],
endCount: 1,
lost: 0,
setMultiDay: vi.fn(),
toggleStageEnd: vi.fn(),
canEnd: vi.fn((id: string) => id === 'b'),
};
const controller = planner({
waypoints: [START, SHAPE, { ...STOP, stageEnd: true }, END],
selectedWaypointId: 'b',
stages,
} as Partial<TourPlannerController>);
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const heads = screen.getAllByTestId('tour-stage-divider');
expect(heads.map((head) => head.getAttribute('data-stage'))).toEqual(['1', '2']);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(at(rows, 2).querySelector('[data-waypoint-stage-end]')).not.toBeNull();
expect(within(at(rows, 2)).getByLabelText('Waypoint 2, End of stage 1')).toBeInTheDocument();
expect(at(rows, 0).querySelector('[data-waypoint-stage-end]')).toBeNull();
// The head of the second stage follows the stop that ends the first.
expect(at(rows, 2).compareDocumentPosition(at(heads, 1)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Remove stage end' }));
expect(stages.toggleStageEnd).toHaveBeenCalledWith('b');
});
});
@@ -39,6 +39,7 @@ import { PaddleRolePill } from '../paddle/PaddleWaypointParts';
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageRouting } from '../paddle/usePortageRouting';
import { StageDivider, StageEndCorner, stageMenuItems } from '../stages/StageParts';
import { RailCard } from '../tourParts';
import { StationScheduleRow } from '../walk/StationSchedule';
import { usePlannerWalkSchedule } from '../walk/useWalkSchedule';
@@ -180,10 +181,11 @@ function numberNote(
ferrata: TourWaypointFerrata | null,
poiCategory: TourPoiCategory | null,
station: StationMarks,
t: Translate
t: Translate,
stageNote?: string
): { note: string | null; poi: TourPoiCategory | null } {
const marks = station ? stationMarkNote(station, t) : null;
const join = (first: string | null) => [first, marks].filter(Boolean).join(', ') || null;
const join = (first: string | null) => [first, marks, stageNote].filter(Boolean).join(', ') || null;
if (ferrata) return { note: join(t('tours.ferrata.waypointAtEnd', { name: ferrata.name })), poi: null };
if (poiCategory) return { note: join(t(TOUR_POI_LOOKS[poiCategory].labelKey)), poi: poiCategory };
return { note: join(null), poi: null };
@@ -205,6 +207,8 @@ function WaypointNumber({
station = null,
loopEnd = false,
shaping = false,
stageNote,
corner,
}: {
/** The stop's number; shaping points do not count, as on the map. */
stop: number;
@@ -218,6 +222,10 @@ function WaypointNumber({
loopEnd?: boolean;
/** P2A-03: a shaping point, a small dot as on the map. */
shaping?: boolean;
/** MDT-01: the stage of a multi-day tour that ends at this stop, said with the number. */
stageNote?: string;
/** MDT-01: a mark at the number's corner, the moon where a stage ends. */
corner?: ReactNode;
}) {
const { t } = useTranslation();
if (shaping) {
@@ -245,7 +253,7 @@ function WaypointNumber({
);
}
const label = t('tours.planner.waypointLabel', { n: stop });
const { note, poi } = numberNote(ferrata, poiCategory, station, t);
const { note, poi } = numberNote(ferrata, poiCategory, station, t, stageNote);
const number = (
<span
aria-label={[label, note].filter(Boolean).join(', ')}
@@ -258,6 +266,7 @@ function WaypointNumber({
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
{poi && <PoiCornerIcon category={poi} />}
{station?.protected && <StationShield />}
{corner}
</span>
);
if (!ferrata) return note ? <Tooltip label={note}>{number}</Tooltip> : number;
@@ -494,6 +503,7 @@ function WaypointRowActions({
},
...routeMenuItems(planner, point, index, lastIndex, t),
...roundMenuItems(planner, point, t),
...stageMenuItems(planner.stages, point, t),
...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []),
...(onUnlink
? [{ divider: true } as const, { label: t('tours.placePicker.unlink'), icon: Unlink, onClick: onUnlink }]
@@ -702,6 +712,15 @@ export default function TourWaypointList({
// PAD-11C: where a leg lacks the water data of its region, TREK fetches it and plans the leg again.
const regionLoad = usePaddleRegionLoad(routedLegs?.failures, planner.retry);
const stopNumbers = stopNumbersOf(planner.waypoints);
// MDT-01: a multi-day tour's stages, each headed where it begins; the stop that ends one carries its number.
const stageStarts = new Map(planner.stages.stages.map((stage) => [stage.from, stage]));
const stageEnds = new Map(planner.stages.stages.slice(0, -1).map((stage) => [stage.toId, stage.number]));
const stageHead = (index: number) => {
const stage = stageStarts.get(index);
return stage ? <StageDivider stage={stage} /> : null;
};
const stageMarkOf = (stage: number | undefined) =>
stage ? { stageNote: t('tours.stages.endOf', { number: stage }), corner: <StageEndCorner /> } : {};
const endDrag = () => {
setDraggedWaypointId(null);
setDropTarget(null);
@@ -753,6 +772,7 @@ export default function TourWaypointList({
const stop = stopNumbers.get(point.id) ?? index + 1;
return (
<Fragment key={point.id}>
{index === 0 && stageHead(0)}
<li
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
@@ -814,6 +834,7 @@ export default function TourWaypointList({
station={point}
loopEnd={loopEnd}
shaping={!!point.shaping}
{...stageMarkOf(stageEnds.get(point.id))}
/>
<button
type="button"
@@ -845,6 +866,7 @@ export default function TourWaypointList({
</div>
<StationScheduleRow schedule={walk.schedule} point={point} placeName={placeName} number={stop} />
</li>
{index > 0 && stageHead(index)}
<WaypointLegRows
planner={planner}
point={point}
@@ -88,7 +88,8 @@ function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
Math.abs(point.lat) <= 90 &&
Number.isFinite(point.lng) &&
Math.abs(point.lng) <= 180 &&
(point.lock == null || paddleLockSchema.safeParse(point.lock).success)
(point.lock == null || paddleLockSchema.safeParse(point.lock).success) &&
(point.stageEnd === undefined || typeof point.stageEnd === 'boolean')
);
}
@@ -1,5 +1,6 @@
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
maxTourMinutesFor,
plannableTourTypeSchema,
runMinutesAtPace,
tourWebsiteSchema,
@@ -72,6 +73,8 @@ export interface TourPlannerFactsInput {
stays?: Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'> | null;
/** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */
durationSeconds: number | null;
/** MDT-01: the stages the tour is split into, one for a day tour; a day each is what the times may take. */
stageCount?: number;
}
function invalidMinutes(value: number | null): boolean {
@@ -191,7 +194,7 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
plannedTotalInvalid:
plannedDurationMinutes === null &&
automaticPlannedTotalMinutes !== null &&
automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
automaticPlannedTotalMinutes > maxTourMinutesFor(input.stageCount ?? 1),
// 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, a run its pace, a ride none.
@@ -224,6 +224,15 @@ describe('tourPlannerFacts', () => {
).toBe(true);
expect(tourPlannerFacts(hike({ durationSeconds: 600 })).stationStays).toBeNull();
});
it('MDT-01: a multi-day tour may take a day a stage on the move, a day tour one day', () => {
// 30 hours on the move: too long for a day, room enough for two stages.
const long = hike({ tourType: 'bike', durationSeconds: 30 * 3600 });
expect(tourPlannerFacts(long).plannedTotalInvalid).toBe(true);
expect(tourPlannerFacts({ ...long, stageCount: 2 }).plannedTotalInvalid).toBe(false);
const withBreaks = { ...long, stageCount: 2, breakAdditionalMinutes: 19 * 60 };
expect(tourPlannerFacts(withBreaks).plannedTotalInvalid).toBe(true);
});
});
describe('usePlannerHistory', () => {
@@ -1,5 +1,6 @@
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-012: the editor's waypoint model, shaping points, drawn legs
// and a city walk's stations with the keys a save answers (P2A-03, PAD-11, CW-01 to CW-04, RTE-02).
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-013: the editor's waypoint model, shaping points, drawn legs,
// a city walk's stations with the keys a save answers and the stage ends of a multi-day tour
// (P2A-03, PAD-11, CW-01 to CW-04, RTE-02, MDT-01).
import type { TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
@@ -255,4 +256,23 @@ describe('tourPlannerWaypoints', () => {
['c', 2],
]);
});
it('FE-TOUR-WAYPOINTS-013: a stage end is saved and read with its stop, plans no route, and holds only between start and end', () => {
const saved = savedWaypointsOf([A, { ...B, stageEnd: true }, C]);
expect(saved[1]).toEqual({ lat: 49.01, lng: 12.01, role: 'via', sequence: 1, stage_end: true });
expect(saved[0]).not.toHaveProperty('stage_end');
let next = 0;
const read = plannerWaypointsOf(saved, () => `p${(next += 1)}`);
expect(read[1]).toMatchObject({ id: 'p2', stageEnd: true });
expect(read[0]).not.toHaveProperty('stageEnd');
// The route does not depend on it; the history does.
expect(routeInputsKey([A, { ...B, stageEnd: true }, C])).toBe(routeInputsKey([A, B, C]));
expect(sameWaypointSnapshot([A, { ...B, stageEnd: true }, C], [A, B, C])).toBe(false);
// Moved to an end, or made a shaping point, a stop ends no stage.
const moved = normalizePlannerWaypoints([{ ...B, stageEnd: true }, A, C]);
expect(moved[0]).not.toHaveProperty('stageEnd');
const shaped = normalizePlannerWaypoints([A, { ...B, stageEnd: true, shaping: true }, C]);
expect(shaped[1]).not.toHaveProperty('stageEnd');
expect(normalizePlannerWaypoints([A, { ...B, stageEnd: true }, C])[1].stageEnd).toBe(true);
});
});
@@ -44,6 +44,8 @@ export interface TourPlannerWaypoint {
openingHours?: string | null;
/** PLN-03: the kind of place the point was taken in from along the tour, whose icon its row carries. */
poiCategory?: TourPoiCategory | null;
/** MDT-01: a stop where a stage of a multi-day tour ends and the next begins. */
stageEnd?: boolean;
}
/** Stops are numbered on their own, as on the map: a shaping point (P2A-03) takes no number. */
@@ -71,6 +73,7 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
...(point.protected ? { protected: true } : {}),
...(point.opening_hours ? { openingHours: point.opening_hours } : {}),
...(point.poi_category ? { poiCategory: point.poi_category } : {}),
...(point.stage_end ? { stageEnd: true } : {}),
}));
}
@@ -98,6 +101,7 @@ export function savedWaypointsOf(points: readonly TourPlannerWaypoint[], tourTyp
...(walk && point.protected ? { protected: true } : {}),
...(point.openingHours ? { opening_hours: point.openingHours } : {}),
...(point.poiCategory ? { poi_category: point.poiCategory } : {}),
...(point.stageEnd ? { stage_end: true } : {}),
}));
}
@@ -119,6 +123,10 @@ export function ridablePoints(points: TourPlannerWaypoint[]): TourPlannerWaypoin
* Roles from the order; free legs follow their points, and a paddle tour keeps
* its portages and locks. A ride (HOR-01) has every leg on a horse route or
* drawn by hand; any other type gives a horse route section back to the router.
* A stage end (MDT-01) stays only where one can be: at a stop between start and
* end. A mark that ends up elsewhere after a change (its point moved to an end,
* made a shaping point) is dropped here, and the planner says so rather than
* move it to another point.
*/
export function normalizePlannerWaypoints(
points: TourPlannerWaypoint[],
@@ -126,10 +134,12 @@ export function normalizePlannerWaypoints(
): TourPlannerWaypoint[] {
const joined = keepJoinedFixedLegs(alignFreeLegs(points, tourType === 'kayak'));
const typed = tourType === 'horse' ? rideLegs(joined) : withoutHorseRoutes(joined);
return typed.map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}));
return typed.map((point, index) => {
const role = index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via';
if (!point.stageEnd || (role === 'via' && !point.shaping)) return { ...point, role };
const { stageEnd: _dropped, ...rest } = point;
return { ...rest, role };
});
}
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
@@ -170,8 +180,9 @@ export function routeExtrasKey(points: TourPlannerWaypoint[]): string {
/**
* CW-02: everything a route is planned from: every point with all it holds but
* a station's own fields (its name, stay, importance, protection, opening
* hours and the kind of place it was found as) and the key a save gave it. A change to those alone leaves the route
* as it is; any other change plans it again.
* hours and the kind of place it was found as), the key a save gave it and
* (MDT-01) whether a stage ends there. A change to those alone leaves the
* route as it is; any other change plans it again.
*/
export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
return JSON.stringify(
@@ -184,6 +195,7 @@ export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
openingHours: _hours,
poiCategory: _kind,
key: _key,
stageEnd: _stage,
...routed
}) => routed
)
@@ -204,12 +216,18 @@ function stationFieldsKey(points: TourPlannerWaypoint[]): string {
);
}
/** The same step of the history: the line, what was planned along it, and every station's own fields. */
/** MDT-01: where the stages end, which the route does not depend on either. */
function stageEndsKey(points: TourPlannerWaypoint[]): string {
return points.map((point) => (point.stageEnd ? '1' : '0')).join('');
}
/** The same step of the history: the line, what was planned along it, every station's own fields and the stage ends. */
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
return (
tourWaypointKey(a) === tourWaypointKey(b) &&
routeExtrasKey(a) === routeExtrasKey(b) &&
stationFieldsKey(a) === stationFieldsKey(b)
stationFieldsKey(a) === stationFieldsKey(b) &&
stageEndsKey(a) === stageEndsKey(b)
);
}
@@ -0,0 +1,232 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
routeWalkingTour: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
canEdit,
canAssign,
}),
}));
import { validDraft } from './tourPlannerDraft';
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-008 (MDT-01)
const POINTS: Array<[number, number]> = [
[48, 11],
[48.01, 11.02],
[48.02, 11.04],
[48.03, 11.06],
];
const routed = {
coordinates: POINTS,
parts: [{ coordinates: POINTS, free: false }],
distanceMeters: 6000,
durationSeconds: 5400,
};
const enriched = POINTS.map(([lat, lng], index): [number, number, number] => [lat, lng, [500, 560, 520, 600][index]]);
const savedHike = {
place_id: 81,
name: 'Hut to hut',
tour_type: 'hike' as const,
distance: 6,
elevation_gain: 140,
elevation_loss: 40,
duration: 90,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
revision: 2,
stage_count: 2,
};
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
/** A planner with the four points set and their route planned. */
async function plannedHike(tripId: number) {
const hook = renderHook(() => useTourPlanner({ tripId }));
act(() => {
for (const [lat, lng] of POINTS) hook.result.current.addWaypoint(lat, lng);
});
await settle();
return hook;
}
describe('useTourPlanner, multi-day tours (MDT-01)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset();
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-STAGES-001: a stage end splits the one route into stages, measured from its line, without planning it again', async () => {
const { result } = await plannedHike(61);
expect(result.current.stages.multiDay).toBe(false);
expect(result.current.stages.stages).toEqual([]);
const stop = result.current.waypoints[1].id;
routeWalkingTour.mockClear();
act(() => result.current.stages.toggleStageEnd(stop));
await settle();
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(result.current.waypoints[1].stageEnd).toBe(true);
expect(result.current.stages.multiDay).toBe(true);
expect(result.current.stages.stages.map((stage) => [stage.number, stage.gain, stage.loss])).toEqual([
[1, 60, 0],
[2, 80, 40],
]);
expect(result.current.stages.stages[0].toId).toBe(stop);
expect(result.current.stages.stages[1].fromId).toBe(stop);
expect(result.current.hasUnsavedChanges).toBe(true);
// One step of the history: undo takes the stage end back.
act(() => result.current.undo());
expect(result.current.waypoints[1].stageEnd).toBeUndefined();
expect(result.current.stages.multiDay).toBe(false);
});
it('FE-TOUR-STAGES-002: a stage end whose stop is removed is said to be lost, and undo brings it back', async () => {
const { result } = await plannedHike(62);
const stop = result.current.waypoints[2].id;
act(() => result.current.stages.toggleStageEnd(stop));
act(() => result.current.removeWaypoint(stop));
expect(result.current.stages.lost).toBe(1);
expect(result.current.stages.endCount).toBe(0);
act(() => result.current.undo());
expect(result.current.stages.lost).toBe(0);
expect(result.current.waypoints[2].stageEnd).toBe(true);
});
it('FE-TOUR-STAGES-003: a stop made a shaping point or moved to the end cannot end a stage, and says so', async () => {
const { result } = await plannedHike(63);
const stop = result.current.waypoints[1].id;
act(() => result.current.stages.toggleStageEnd(stop));
act(() => result.current.setWaypointShaping(stop, true));
expect(result.current.waypoints[1].stageEnd).toBeUndefined();
expect(result.current.stages.lost).toBe(1);
act(() => result.current.undo());
act(() => result.current.reorderWaypoint(stop, result.current.waypoints[3].id, 'after'));
expect(result.current.waypoints[3].id).toBe(stop);
expect(result.current.waypoints[3].stageEnd).toBeUndefined();
expect(result.current.stages.lost).toBe(1);
// A later change that loses nothing clears the notice.
act(() => result.current.setWaypointPosition(result.current.waypoints[1].id, 48.011, 11.021));
expect(result.current.stages.lost).toBe(0);
});
it('FE-TOUR-STAGES-004: a stage end taken away on purpose is no loss, and the order of the stages follows the route', async () => {
const { result } = await plannedHike(64);
const [, first, second] = result.current.waypoints.map((point) => point.id);
act(() => result.current.stages.toggleStageEnd(second));
act(() => result.current.stages.toggleStageEnd(first));
expect(result.current.stages.stages.map((stage) => stage.toId)).toEqual([
first,
second,
result.current.waypoints[3].id,
]);
act(() => result.current.stages.toggleStageEnd(first));
expect(result.current.stages.lost).toBe(0);
expect(result.current.stages.endCount).toBe(1);
// Reversed, the stages run the other way and keep their stops.
act(() => result.current.reverse());
expect(result.current.waypoints[1].id).toBe(second);
expect(result.current.waypoints[1].stageEnd).toBe(true);
// The tour keeps its one type for all its stages; another type keeps the stages.
act(() => result.current.setTourType('kayak'));
expect(result.current.tourType).toBe('kayak');
expect(result.current.stages.endCount).toBe(1);
});
it('FE-TOUR-STAGES-005: choosing a multi-day tour changes nothing yet; back to a day tour removes every stage end as one step', async () => {
const { result } = await plannedHike(65);
act(() => result.current.stages.setMultiDay(true));
expect(result.current.stages.multiDay).toBe(true);
expect(result.current.stages.stages).toHaveLength(1);
expect(result.current.waypoints.some((point) => point.stageEnd)).toBe(false);
act(() => {
result.current.stages.toggleStageEnd(result.current.waypoints[1].id);
result.current.stages.toggleStageEnd(result.current.waypoints[2].id);
});
expect(result.current.stages.stages).toHaveLength(3);
act(() => result.current.stages.setMultiDay(false));
expect(result.current.stages.multiDay).toBe(false);
expect(result.current.waypoints.some((point) => point.stageEnd)).toBe(false);
expect(result.current.stages.lost).toBe(0);
act(() => result.current.undo());
expect(result.current.stages.endCount).toBe(2);
// A fresh draft forgets the choice of a multi-day tour.
act(() => result.current.startNewTour(true));
act(() => result.current.stages.setMultiDay(true));
expect(result.current.stages.multiDay).toBe(true);
act(() => result.current.startNewTour(true));
expect(result.current.stages.multiDay).toBe(false);
});
it('FE-TOUR-STAGES-006: saves the stage ends with their stops and opens a saved tour with its stages', async () => {
detailTour.mockResolvedValue({
tour: savedHike,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'via', sequence: 1, stage_end: true },
{ lat: 48.02, lng: 11.04, role: 'via', sequence: 2 },
{ lat: 48.03, lng: 11.06, role: 'end', sequence: 3 },
],
});
updateTour.mockResolvedValue({ tour: { ...savedHike, revision: 3 }, waypoints: [] });
const { result } = renderHook(() => useTourPlanner({ tripId: 66 }));
await act(async () => {
await result.current.openTour({ place_id: 81 });
});
expect(result.current.stages.multiDay).toBe(true);
expect(result.current.waypoints[1].stageEnd).toBe(true);
act(() => result.current.setName('Hut to hut, three days'));
act(() => result.current.stages.toggleStageEnd(result.current.waypoints[2].id));
await settle();
await act(async () => {
await result.current.save();
});
const payload = updateTour.mock.calls[0]?.[2] as { waypoints: Array<{ stage_end?: boolean }> };
expect(payload.waypoints.map((point) => point.stage_end ?? false)).toEqual([false, true, true, false]);
});
it('FE-TOUR-STAGES-007: a viewer who may not edit sets no stage end', () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 68, canEdit: false, canAssign: false }));
act(() => result.current.stages.setMultiDay(true));
expect(result.current.stages.multiDay).toBe(false);
act(() => result.current.stages.toggleStageEnd('anything'));
expect(result.current.stages.endCount).toBe(0);
});
it('FE-TOUR-STAGES-008: the draft kept between visits holds the stage ends, and one with a broken mark is dropped', async () => {
const { result } = await plannedHike(69);
act(() => result.current.stages.toggleStageEnd(result.current.waypoints[1].id));
const draft = JSON.parse(localStorage.getItem('tour-draft-69') || 'null');
expect(draft.waypoints[1].stageEnd).toBe(true);
expect(validDraft(draft)).toBe(true);
draft.waypoints[1].stageEnd = 'yes';
expect(validDraft(draft)).toBe(false);
});
});
@@ -23,6 +23,8 @@ 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'
import { lostStageEnds } from '../stages/tourStages'
import { useTourStages } from '../stages/useTourStages'
/** What a tour planned from an import starts with. */
export interface DerivedTourDraft {
@@ -160,6 +162,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
const [mapFocusKey, setMapFocusKey] = useState(0)
// MDT-01: stage ends a change of the points could not keep, said while those points are the planner's.
const [stageLoss, setStageLoss] = useState<{ count: number; after: TourPlannerWaypoint[] } | null>(null)
const draftRevisionRef = useRef(0)
const saveRequestIdRef = useRef(0)
const saveInFlightRef = useRef(false)
@@ -263,6 +267,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
recordStep(snapshot())
waypointsRef.current = next
setWaypoints(next)
const lost = lostStageEnds(current, changed, next)
setStageLoss(lost > 0 ? { count: lost, after: next } : null)
// CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was.
if (routeInputsKey(current) === routeInputsKey(next)) {
markEdited()
@@ -277,6 +283,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setError(null)
}, [recordStep, invalidateDetailRequest, interruptRouting, markEdited, snapshot])
/** Whether the points may change now: the viewer may edit and no save is on its way. */
const mayEditPoints = useCallback(() => editPermissionRef.current && !saveInFlightRef.current, [])
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
@@ -534,14 +543,25 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
// 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
// MDT-01: the stages, cut from the route while it is the one planned for these points.
const stagePaddle = useMemo(() => isPaddle
? { effectiveSpeedKmh, portageSpeedKmh, speedInvalid: paddleSpeedInvalid, legLengths: routedLegLengths }
: null, [effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths])
const stages = useTourStages({
waypoints, line: routeCurrent ? (enrichedGeometry ?? route) : null, tourType, routerSeconds: durationSeconds,
paddle: stagePaddle, commit: commitWaypoints, mayEdit: mayEditPoints,
draftKey: `${editingPlaceId ?? 'new'}:${newDraftGeneration}`,
lost: stageLoss?.after === waypoints ? stageLoss.count : 0,
})
const stageCount = Math.max(1, stages.stages.length)
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm, website,
plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stays, tourType, waterContext, website,
winterActivity])
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stageCount, 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,
@@ -794,21 +814,20 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [interruptRouting])
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
const mayEditRoute = useCallback(() => editPermissionRef.current && !saveInFlightRef.current, [])
const shaping = useRouteShaping({
commit: commitWaypoints, waypointsRef, retry, mayEdit: mayEditRoute,
commit: commitWaypoints, waypointsRef, retry, mayEdit: mayEditPoints,
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
})
// RTE-02: other ways for one routed section of a hike, compared and taken on purpose.
const legVariants = useLegVariants({
waypoints, route, enrichedGeometry, routeCurrent, tourType, maxHikingDifficulty, hikingPreferences, undo, mayEdit: mayEditRoute,
waypoints, route, enrichedGeometry, routeCurrent, tourType, maxHikingDifficulty, hikingPreferences, undo, mayEdit: mayEditPoints,
insertShapingPoints: shaping.insertShapingPoints,
})
// RTE-03: round hikes of about a wished length out of a stop, put in place of the plan on purpose.
const roundHikes = useRoundHikes({
waypoints, tourType, maxHikingDifficulty, hikingPreferences, replaceWaypoints: commitWaypoints, undo, mayEdit: mayEditRoute,
waypoints, tourType, maxHikingDifficulty, hikingPreferences, replaceWaypoints: commitWaypoints, undo, mayEdit: mayEditPoints,
})
const readOnlyGpxAnalysis = useMemo(
@@ -941,7 +960,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
...shaping, legVariants, roundHikes, paddleRoute, routeCurrent,
...shaping, legVariants, roundHikes, paddleRoute, routeCurrent, stages,
undo, redo, canUndo: history.canUndo, canRedo: history.canRedo,
status, error, retry, isSaving,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, unreachable,
@@ -0,0 +1,143 @@
import type { TourListItem } from '@trek/shared';
import { ArrowDown, ArrowUp, Clock, Moon, Ruler, Sunrise, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import type { useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { TourStage } from './tourStages';
import type { TourStagesController } from './useTourStages';
type Translate = ReturnType<typeof useTranslation>['t'];
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
/**
* MDT-01: the moon at the corner of a stop's number where a stage ends, the
* night between two days of the tour; the map pin carries it at the same corner.
*/
export function StageEndCorner() {
return (
<span
aria-hidden="true"
data-waypoint-stage-end="true"
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-accent text-accent-text ring-2 ring-[color:var(--bg-card)]"
>
<Moon size={9} strokeWidth={2.6} />
</span>
);
}
/**
* A stage by its number, in the accent: the one strong mark of a stage
* wherever it is named. Its sunrise opens the day, as the moon at its last
* stop closes it.
*/
function StagePill({ number }: { number: number }) {
const { t } = useTranslation();
return (
<span
data-stage-pill={number}
className="inline-flex h-[22px] flex-none items-center gap-1 whitespace-nowrap rounded-full bg-accent pe-2.5 ps-2 font-semibold text-accent-text"
style={fs(11, 'body')}
>
<Sunrise size={11} strokeWidth={2.4} aria-hidden="true" />
{t('tours.stages.stage', { number })}
</span>
);
}
/**
* What a stage measures, as the plan's white badges: its distance, climb and
* descent where the route has heights, and its moving time. A time over a day
* turns to the warning tone and says why. Nothing the route does not tell is
* shown: an unknown value leaves its badge out rather than guess.
*/
export function StageMetricBadges({
stage,
compact = false,
align = 'start',
}: {
stage: TourStage;
/** Distance and time only, for the narrow head of a stage in the list of points. */
compact?: boolean;
align?: 'start' | 'end';
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const icon = (Icon: LucideIcon) => <Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />;
return (
<span className={`flex min-w-0 flex-wrap items-center gap-1 ${align === 'end' ? 'justify-end' : 'justify-start'}`}>
{stage.distanceKm !== null && (
<WhiteBadge icon={icon(Ruler)}>{formatDistance(stage.distanceKm, unit)}</WhiteBadge>
)}
{!compact && stage.gain !== null && (
<WhiteBadge icon={icon(ArrowUp)}>{formatElevation(stage.gain, unit)}</WhiteBadge>
)}
{!compact && stage.loss !== null && (
<WhiteBadge icon={icon(ArrowDown)}>{formatElevation(stage.loss, unit)}</WhiteBadge>
)}
{stage.minutes !== null && (
<Tooltip label={t(stage.overDay ? 'tours.stages.overDay' : 'tours.stages.timeInfo')}>
<span data-stage-over-day={stage.overDay || undefined} className="inline-flex">
{stage.overDay ? (
<SoftPill tone="warning" icon={<Clock size={10} strokeWidth={2.2} className="flex-none" />}>
{formatPlannedTourDuration(stage.minutes)}
</SoftPill>
) : (
<WhiteBadge icon={icon(Clock)}>{formatPlannedTourDuration(stage.minutes)}</WhiteBadge>
)}
</span>
</Tooltip>
)}
</span>
);
}
/**
* The head of a stage in the list of points, where the stage begins: its pill,
* a hairline, and what it measures. The rows of its points follow; the stop
* that ends it carries the moon on its number.
*/
export function StageDivider({ stage }: { stage: TourStage }) {
return (
<li
data-testid="tour-stage-divider"
data-stage={stage.number}
className="flex min-w-0 items-center gap-2 px-1.5 pb-1 pt-2.5 first:pt-0.5"
>
<StagePill number={stage.number} />
<span aria-hidden="true" className="h-px min-w-3 flex-1 bg-edge-faint" />
<StageMetricBadges stage={stage} compact align="end" />
</li>
);
}
/** The stage tool of a stop's menu: end a stage here, or let the stage go on; none where no stage can end. */
export function stageMenuItems(stages: TourStagesController, point: { id: string; stageEnd?: boolean }, t: Translate) {
if (!stages.canEnd(point.id)) return [] as MenuItems;
const items: MenuItems = [
{ divider: true },
{
label: t(point.stageEnd ? 'tours.stages.removeEnd' : 'tours.stages.endHere'),
icon: Moon,
onClick: () => stages.toggleStageEnd(point.id),
},
];
return items;
}
/** MDT-01: a multi-day tour in a list says how many stages it has, with the moon of its nights. */
export function TourStagesBadge({ tour }: { tour: Pick<TourListItem, 'stage_count'> }) {
const { t } = useTranslation();
if (!tour.stage_count) return null;
return (
<span data-testid="tour-stages-badge" className="inline-flex">
<WhiteBadge icon={<Moon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t('tours.stages.count', { count: tour.stage_count })}
</WhiteBadge>
</span>
);
}
@@ -0,0 +1,32 @@
import { AlertTriangle, Undo2 } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, TourNotice } from '../tourParts';
type LossPlanner = Pick<TourPlannerController, 'stages' | 'undo' | 'canUndo'>;
/**
* MDT-01: a change that could not keep a stage end (its stop was removed, or
* moved to where no stage can end) says so at once, with the one step back,
* rather than letting the stages it joined pass unnoticed. It goes with the
* next change of the points.
*/
export function TourStageLossNotice({ planner }: { planner: LossPlanner }) {
const { t } = useTranslation();
const { lost } = planner.stages;
if (lost === 0) return null;
return (
<div data-testid="tour-stage-loss">
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-col items-start gap-2">
<span>{t('tours.stages.lost', { count: lost })}</span>
{planner.canUndo && (
<CardAction icon={<Undo2 size={12} strokeWidth={2} />} onClick={planner.undo}>
{t('undo.button')}
</CardAction>
)}
</span>
</TourNotice>
</div>
);
}
@@ -0,0 +1,64 @@
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { LABEL, Segmented } from '../../shared/dialogParts';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourNotice } from '../tourParts';
type StageMode = 'single' | 'multi';
/**
* MDT-01: a day tour or a multi-day tour, beside the tour's type. Choosing a
* multi-day tour changes nothing yet: it says where the first stage end is
* set. Back to a day tour every stage end goes, after a confirmation, as one
* step undo brings back; the route stays as it is.
*/
export function TourStageModeField({ planner, locked }: { planner: TourPlannerController; locked: boolean }) {
const { t } = useTranslation();
const { stages } = planner;
const [confirming, setConfirming] = useState(false);
const choose = (mode: StageMode) => {
if (mode === 'multi') stages.setMultiDay(true);
else if (stages.endCount > 0) setConfirming(true);
else stages.setMultiDay(false);
};
return (
<div data-testid="tour-stage-mode" className="flex min-w-0 flex-col gap-2">
<fieldset disabled={locked} className="m-0 min-w-0 border-0 p-0">
<span className={LABEL}>{t('tours.stages.mode')}</span>
<Segmented<StageMode>
value={stages.multiDay ? 'multi' : 'single'}
onChange={choose}
label={t('tours.stages.mode')}
fill
options={[
{
value: 'single',
label: t('tours.stages.mode.single'),
icon: <Sun size={13} strokeWidth={2.2} aria-hidden="true" />,
},
{
value: 'multi',
label: t('tours.stages.mode.multi'),
icon: <Moon size={13} strokeWidth={2.2} aria-hidden="true" />,
},
]}
/>
</fieldset>
{stages.multiDay && stages.endCount === 0 && (
<TourNotice role="status" icon={<Moon size={13} strokeWidth={2} />}>
{t('tours.stages.emptyHint')}
</TourNotice>
)}
<ConfirmDialog
isOpen={confirming && !locked}
onClose={() => setConfirming(false)}
onConfirm={() => stages.setMultiDay(false)}
title={t('tours.stages.toDayTitle')}
message={t('tours.stages.toDayBody', { count: stages.endCount })}
confirmLabel={t('tours.stages.toDayConfirm')}
/>
</div>
);
}
@@ -0,0 +1,91 @@
import { ArrowRight } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { focusRouteProfileAtDistance } from '../../../utils/routeGeometry';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { InfoDot, TourSection } from '../tourParts';
import { StageEndCorner, StageMetricBadges } from './StageParts';
import type { TourStage } from './tourStages';
type StagesPlanner = Pick<
TourPlannerController,
'stages' | 'waypoints' | 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'
>;
/** A stage's number as the list numbers a stop, with the moon at its corner where a night follows. */
function StageNumber({ number, night }: { number: number; night: boolean }) {
return (
<span
className="relative grid h-7 w-7 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={fs(11.5)}
>
{number}
{night && <StageEndCorner />}
</span>
);
}
/**
* MDT-01: the stages of a multi-day tour under the whole tour's facts, the
* second view of the same route: each stage from the stop it starts at to the
* one it ends at, with what it measures. A stage shows where it ends, on the
* map, in the profile and in the list of points.
*/
export function TourStagesSection({ planner }: { planner: StagesPlanner }) {
const { t } = useTranslation();
const placeNames = useWaypointPlaceNames(planner.waypoints);
const { stages } = planner.stages;
if (stages.length < 2) return null;
const nameOf = (id: string) => {
const index = planner.waypoints.findIndex((point) => point.id === id);
// Stops are numbered as the list numbers them: a shaping point takes no number.
const stop = planner.waypoints.slice(0, index + 1).filter((point) => !point.shaping).length;
return planner.waypoints[index]?.name ?? placeNames[id] ?? t('tours.planner.waypointLabel', { n: stop });
};
const show = (stage: TourStage) => {
planner.setSelectedWaypointId(stage.toId);
const samples = planner.routeAnalysis?.distanceIndexedProfileSamples ?? [];
planner.setRouteProfileFocus(stage.toM === null ? null : focusRouteProfileAtDistance(samples, stage.toM));
};
return (
<TourSection
label={t('tours.stages.title')}
action={<InfoDot label={t('tours.stages.title')} description={t('tours.stages.timeInfo')} />}
>
<ol data-testid="tour-stages" className="-mx-1.5 m-0 flex list-none flex-col gap-px p-0">
{stages.map((stage) => (
<li key={stage.number}>
<Tooltip label={t('tours.stages.show')}>
<button
type="button"
data-testid="tour-stage-row"
onClick={() => show(stage)}
className="flex w-full min-w-0 items-center gap-2.5 rounded-[10px] px-1.5 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<StageNumber number={stage.number} night={stage.number < stages.length} />
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span
className="flex w-full min-w-0 items-center gap-1 font-semibold leading-tight text-content"
style={fs(12.5, 'body')}
>
<span className="min-w-0 truncate">{nameOf(stage.fromId)}</span>
<ArrowRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-content-faint rtl:rotate-180"
/>
<span className="min-w-0 truncate">{nameOf(stage.toId)}</span>
</span>
<StageMetricBadges stage={stage} />
</span>
</button>
</Tooltip>
</li>
))}
</ol>
</TourSection>
);
}
@@ -0,0 +1,178 @@
// FE-TOUR-STAGE-VIEW-001 to FE-TOUR-STAGE-VIEW-008: the stages of a multi-day tour in the planner,
// the list and the overview (MDT-01).
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { StageDivider, stageMenuItems, TourStagesBadge } from './StageParts';
import { TourStageLossNotice } from './TourStageLossNotice';
import { TourStageModeField } from './TourStageModeField';
import { TourStagesSection } from './TourStagesSection';
import type { TourStage } from './tourStages';
import type { TourStagesController } from './useTourStages';
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({ b: 'Upper hut' }) }));
const STAGE: TourStage = {
number: 2,
from: 1,
to: 3,
fromId: 'b',
toId: 'd',
distanceKm: 12.4,
gain: 820,
loss: 310,
minutes: 330,
fromM: 9000,
toM: 21400,
overDay: false,
};
function stagesOf(overrides: Partial<TourStagesController> = {}): TourStagesController {
return {
multiDay: false,
stages: [],
endCount: 0,
lost: 0,
setMultiDay: vi.fn(),
toggleStageEnd: vi.fn(),
canEnd: vi.fn(() => true),
...overrides,
};
}
function plannerOf(stages: TourStagesController, extra: Record<string, unknown> = {}) {
return {
stages,
waypoints: [
{ id: 'a', lat: 47, lng: 11, role: 'start', name: 'Valley' },
{ id: 'b', lat: 47.1, lng: 11, role: 'via', stageEnd: true },
{ id: 'd', lat: 47.2, lng: 11, role: 'end', name: 'Pass' },
],
routeAnalysis: null,
setSelectedWaypointId: vi.fn(),
setRouteProfileFocus: vi.fn(),
undo: vi.fn(),
canUndo: true,
...extra,
} as unknown as TourPlannerController;
}
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
});
describe('stage views', () => {
it('FE-TOUR-STAGE-VIEW-001: a stage head names the stage and what it measures, distance and time only', () => {
render(
<ol>
<StageDivider stage={STAGE} />
</ol>
);
const head = screen.getByTestId('tour-stage-divider');
expect(head).toHaveAttribute('data-stage', '2');
expect(within(head).getByText('Stage 2')).toBeInTheDocument();
expect(within(head).getByText('12.4 km')).toBeInTheDocument();
expect(within(head).queryByText(/820/)).toBeNull();
expect(head.querySelector('[data-stage-over-day]')).toBeNull();
});
it('FE-TOUR-STAGE-VIEW-002: a stage of more than a day on the move warns, and an unknown value leaves its badge out', () => {
render(
<ol>
<StageDivider stage={{ ...STAGE, minutes: 1500, overDay: true, distanceKm: null }} />
</ol>
);
const head = screen.getByTestId('tour-stage-divider');
expect(head.querySelector('[data-stage-over-day="true"]')).not.toBeNull();
expect(within(head).queryByText(/km/)).toBeNull();
});
it('FE-TOUR-STAGE-VIEW-003: a multi-day tour in a list says how many stages it has; a day tour says nothing', () => {
const { rerender } = render(<TourStagesBadge tour={{ stage_count: 3 }} />);
expect(screen.getByTestId('tour-stages-badge')).toHaveTextContent('3 stages');
rerender(<TourStagesBadge tour={{ stage_count: null }} />);
expect(screen.queryByTestId('tour-stages-badge')).toBeNull();
});
it('FE-TOUR-STAGE-VIEW-004: a stop menu ends a stage or lets it go on, and offers nothing where no stage can end', () => {
const t = (key: string) => key;
const stages = stagesOf();
const [divider, end] = stageMenuItems(stages, { id: 'b' }, t);
expect(divider).toEqual({ divider: true });
expect(end).toMatchObject({ label: 'tours.stages.endHere' });
(end as { onClick: () => void }).onClick();
expect(stages.toggleStageEnd).toHaveBeenCalledWith('b');
expect(stageMenuItems(stages, { id: 'b', stageEnd: true }, t)[1]).toMatchObject({
label: 'tours.stages.removeEnd',
});
expect(stageMenuItems(stagesOf({ canEnd: vi.fn(() => false) }), { id: 'a' }, t)).toEqual([]);
});
it('FE-TOUR-STAGE-VIEW-005: choosing a multi-day tour shows where its first stage end is set', async () => {
const stages = stagesOf();
const { rerender } = render(<TourStageModeField planner={plannerOf(stages)} locked={false} />);
await userEvent.click(screen.getByRole('button', { name: /Multi-day tour/ }));
expect(stages.setMultiDay).toHaveBeenCalledWith(true);
rerender(<TourStageModeField planner={plannerOf(stagesOf({ multiDay: true }))} locked={false} />);
expect(screen.getByText('In the menu of a stop, choose End stage here to split the route.')).toBeInTheDocument();
});
it('FE-TOUR-STAGE-VIEW-006: back to a day tour asks first when stage ends would go, and not when none would', async () => {
const withEnds = stagesOf({ multiDay: true, endCount: 2 });
const { rerender } = render(<TourStageModeField planner={plannerOf(withEnds)} locked={false} />);
await userEvent.click(screen.getByRole('button', { name: /Day tour/ }));
expect(withEnds.setMultiDay).not.toHaveBeenCalled();
expect(
screen.getByText('The 2 stage ends are removed. The route stays as it is, and undo brings them back.')
).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Remove stage ends' }));
expect(withEnds.setMultiDay).toHaveBeenCalledWith(false);
const chosenOnly = stagesOf({ multiDay: true });
rerender(<TourStageModeField planner={plannerOf(chosenOnly)} locked={false} />);
await userEvent.click(screen.getByRole('button', { name: /Day tour/ }));
expect(chosenOnly.setMultiDay).toHaveBeenCalledWith(false);
});
it('FE-TOUR-STAGE-VIEW-007: a lost stage end is said with the way back, and nothing is said while none is lost', async () => {
const planner = plannerOf(stagesOf({ lost: 1 }));
const { rerender } = render(<TourStageLossNotice planner={planner} />);
expect(screen.getByTestId('tour-stage-loss')).toHaveTextContent(
'1 stage end was dropped with its stop; its two stages are joined now.'
);
await userEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(planner.undo).toHaveBeenCalled();
rerender(<TourStageLossNotice planner={plannerOf(stagesOf({ lost: 1 }), { canUndo: false })} />);
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
rerender(<TourStageLossNotice planner={plannerOf(stagesOf())} />);
expect(screen.queryByTestId('tour-stage-loss')).toBeNull();
});
it('FE-TOUR-STAGE-VIEW-008: the overview lists the stages by their stops and shows where one ends', async () => {
const first: TourStage = { ...STAGE, number: 1, from: 0, to: 1, fromId: 'a', toId: 'b', fromM: 0, toM: 9000 };
const last: TourStage = { ...STAGE, toM: null };
const planner = plannerOf(stagesOf({ multiDay: true, stages: [first, last] }));
render(<TourStagesSection planner={planner} />);
const rows = screen.getAllByTestId('tour-stage-row');
expect(rows[0]).toHaveTextContent('Valley');
expect(rows[0]).toHaveTextContent('Upper hut');
expect(rows[1]).toHaveTextContent('Pass');
expect(rows[0].querySelector('[data-waypoint-stage-end]')).not.toBeNull();
expect(rows[1].querySelector('[data-waypoint-stage-end]')).toBeNull();
await userEvent.click(rows[0]);
expect(planner.setSelectedWaypointId).toHaveBeenCalledWith('b');
expect(planner.setRouteProfileFocus).toHaveBeenCalledWith(null);
await userEvent.click(rows[1]);
expect(planner.setSelectedWaypointId).toHaveBeenLastCalledWith('d');
expect(planner.setRouteProfileFocus).toHaveBeenLastCalledWith(null);
});
it('FE-TOUR-STAGE-VIEW-009: a day tour has no stage list', () => {
render(<TourStagesSection planner={plannerOf(stagesOf())} />);
expect(screen.queryByTestId('tour-stages')).toBeNull();
});
});
@@ -0,0 +1,157 @@
// FE-TOUR-STAGE-MODEL-001 to FE-TOUR-STAGE-MODEL-010: the stages of a multi-day tour, cut from its one route (MDT-01).
import { MAX_TOUR_STAGES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { hikingTimeMinutes } from '../hikingTime';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import {
canEndStage,
lostStageEnds,
stageBoundariesM,
stageEndCount,
stageSpansOf,
tourStagesOf,
vertexIndicesOf,
withoutStageEnds,
withStageEnd,
type TourStageInputs,
} from './tourStages';
const point = (id: string, lat: number, extra: Partial<TourPlannerWaypoint> = {}): TourPlannerWaypoint => ({
id,
lat,
lng: 11,
role: 'via',
...extra,
});
/** Five stops due north, 0.01 degrees apart. */
const FIVE = [point('a', 48), point('b', 48.01), point('c', 48.02), point('d', 48.03), point('e', 48.04)];
const withEnds = (...ids: string[]) => FIVE.map((p) => (ids.includes(p.id) ? { ...p, stageEnd: true } : p));
const LINE = FIVE.map((p, index): [number, number, number] => [p.lat, p.lng, [500, 600, 550, 650, 600][index]]);
function input(points: TourPlannerWaypoint[], extra: Partial<TourStageInputs> = {}): TourStageInputs {
return { points, line: LINE, tourType: 'hike', routerSeconds: 4 * 3600, paddle: null, ...extra };
}
describe('tourStages', () => {
it('FE-TOUR-STAGE-MODEL-001: splits the points at their stage ends, in route order', () => {
expect(stageSpansOf([])).toEqual([]);
expect(stageSpansOf(FIVE)).toEqual([{ number: 1, from: 0, to: 4 }]);
expect(stageSpansOf(withEnds('b', 'd'))).toEqual([
{ number: 1, from: 0, to: 1 },
{ number: 2, from: 1, to: 3 },
{ number: 3, from: 3, to: 4 },
]);
// A mark on an end of the tour ends nothing.
expect(stageSpansOf(withEnds('a', 'e'))).toHaveLength(1);
expect(stageEndCount(withEnds('b', 'd'))).toBe(2);
});
it('FE-TOUR-STAGE-MODEL-002: a stage ends only at a stop between start and end, while the tour has room', () => {
expect(canEndStage(FIVE, 'a')).toBe(false);
expect(canEndStage(FIVE, 'e')).toBe(false);
expect(canEndStage(FIVE, 'missing')).toBe(false);
expect(canEndStage([FIVE[0], { ...FIVE[1], shaping: true }, FIVE[4]], 'b')).toBe(false);
expect(canEndStage(FIVE, 'c')).toBe(true);
const full = Array.from({ length: MAX_TOUR_STAGES + 2 }, (_, index) =>
point(`p${index}`, 48 + index / 100, index > 0 && index < MAX_TOUR_STAGES ? { stageEnd: true } : {})
);
expect(stageEndCount(full)).toBe(MAX_TOUR_STAGES - 1);
expect(canEndStage(full, `p${MAX_TOUR_STAGES}`)).toBe(false);
// One that ends a stage can always stop doing so.
expect(canEndStage(full, 'p1')).toBe(true);
});
it('FE-TOUR-STAGE-MODEL-003: sets and takes a stage end, the same points where nothing changes', () => {
const set = withStageEnd(FIVE, 'c', true);
expect(set[2].stageEnd).toBe(true);
expect(withStageEnd(set, 'c', true)).toBe(set);
expect(withStageEnd(FIVE, 'a', true)).toBe(FIVE);
expect(withStageEnd(FIVE, 'missing', true)).toBe(FIVE);
const taken = withStageEnd(set, 'c', false);
expect(taken[2]).not.toHaveProperty('stageEnd');
expect(withoutStageEnds(FIVE)).toBe(FIVE);
expect(withoutStageEnds(withEnds('b', 'd')).some((p) => 'stageEnd' in p)).toBe(false);
});
it('FE-TOUR-STAGE-MODEL-004: counts the stage ends a change lost, never one taken away on purpose', () => {
const current = withEnds('b', 'd');
const removed = current.filter((p) => p.id !== 'b');
expect(lostStageEnds(current, removed, removed)).toBe(1);
const shaped = current.map((p) => (p.id === 'd' ? { ...p, shaping: true } : p));
const dropped = shaped.map((p) => (p.id === 'd' ? { ...p, stageEnd: undefined } : p));
expect(lostStageEnds(current, shaped, dropped)).toBe(1);
const cleared = withStageEnd(current, 'b', false);
expect(lostStageEnds(current, cleared, cleared)).toBe(0);
expect(lostStageEnds(current, current, current)).toBe(0);
});
it('FE-TOUR-STAGE-MODEL-005: places every point on the line in order, at the first pass of a route that comes by twice', () => {
expect(vertexIndicesOf(FIVE, LINE)).toEqual([0, 1, 2, 3, 4]);
const outAndBack: Array<[number, number]> = [
[48, 11],
[48.01, 11],
[48.02, 11],
[48.01, 11],
[48, 11],
];
const there = [point('s', 48), point('m', 48.01), point('t', 48.02), point('r', 48)];
expect(vertexIndicesOf(there, outAndBack)).toEqual([0, 1, 2, 4]);
});
it('FE-TOUR-STAGE-MODEL-006: a hike stage is timed by the signposts over its own climb and descent', () => {
const stages = tourStagesOf(input(withEnds('c')));
expect(stages.map((stage) => [stage.fromId, stage.toId, stage.gain, stage.loss])).toEqual([
['a', 'c', 100, 50],
['c', 'e', 100, 50],
]);
const first = stages[0];
expect(first.minutes).toBe(hikingTimeMinutes(first.distanceKm ?? 0, 100, 50));
expect(first.fromM).toBe(0);
expect(stages[1].fromM).toBe(first.toM);
expect(stageBoundariesM(stages)).toEqual([first.toM]);
});
it('FE-TOUR-STAGE-MODEL-007: a ride or a walk shares the router time out by distance', () => {
const stages = tourStagesOf(input(withEnds('b'), { tourType: 'bike' }));
expect(stages.map((stage) => stage.minutes)).toEqual([60, 180]);
expect(stages.every((stage) => !stage.overDay)).toBe(true);
const unknown = tourStagesOf(input(withEnds('b'), { tourType: 'walk', routerSeconds: null }));
expect(unknown.map((stage) => stage.minutes)).toEqual([null, null]);
});
it('FE-TOUR-STAGE-MODEL-008: a paddle stage has no heights and is timed at the paddler speed, its lock passed once', () => {
const lock = { wait_minutes: 10, transit_minutes: 5 };
const points = withEnds('c').map((p) => (p.id === 'c' ? { ...p, lock } : p));
const legLengths = [1000, 1000, 1000, 1000];
const paddle = { effectiveSpeedKmh: 4, portageSpeedKmh: null, speedInvalid: false, legLengths };
const stages = tourStagesOf(input(points, { tourType: 'kayak', paddle }));
expect(stages.map((stage) => [stage.gain, stage.loss])).toEqual([
[null, null],
[null, null],
]);
// 2 km at 4 km/h is half an hour; the lock at the stage end counts on the first day only.
expect(stages.map((stage) => stage.minutes)).toEqual([45, 30]);
const unknownSpeed = { effectiveSpeedKmh: null, portageSpeedKmh: null, speedInvalid: true };
const stopped = tourStagesOf(input(points, { tourType: 'kayak', paddle: unknownSpeed }));
expect(stopped.map((stage) => stage.minutes)).toEqual([null, null]);
});
it('FE-TOUR-STAGE-MODEL-009: a stage of more than a day on the move says so', () => {
const stages = tourStagesOf(input(withEnds('b'), { tourType: 'bike', routerSeconds: 4 * 24 * 3600 }));
expect(stages.map((stage) => stage.overDay)).toEqual([false, true]);
});
it('FE-TOUR-STAGE-MODEL-010: without a route for these points every stage is there, unmeasured', () => {
const stages = tourStagesOf(input(withEnds('b'), { line: null }));
expect(stages).toHaveLength(2);
expect(stages.every((stage) => stage.distanceKm === null && stage.minutes === null && stage.toM === null)).toBe(
true
);
expect(stageBoundariesM(stages)).toEqual([]);
// Two stops at the same spot of the line measure nothing between them.
const together = tourStagesOf(input([FIVE[0], { ...FIVE[0], id: 'twin', stageEnd: true }, FIVE[4]]));
expect(together[0].distanceKm).toBe(0);
expect(together[0].gain).toBe(0);
});
});
@@ -0,0 +1,274 @@
import { MAX_PLANNED_TOUR_DURATION_MINUTES, MAX_TOUR_STAGES, type PlannableTourType } from '@trek/shared';
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
import { hikingTimeMinutes } from '../hikingTime';
import { paddleEstimateOf } from '../planner/paddlePlanOf';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* MDT-01: the stages of a multi-day tour, read off its points in route order.
* A stage runs from the start, or from the stop that ended the stage before,
* to the next stop marked as a stage end, or to the end of the tour. The
* stages are a view of the one route, never a copy of it: what they measure is
* cut from the line the whole tour was planned along.
*/
type StagePoint = Pick<TourPlannerWaypoint, 'id' | 'stageEnd' | 'shaping'>;
/** A stage as the points make it: its number from 1 and the index of its first and its last point. */
interface StageSpan {
number: number;
from: number;
to: number;
}
/** How many stops end a stage. */
export function stageEndCount(points: readonly Pick<TourPlannerWaypoint, 'stageEnd'>[]): number {
return points.filter((point) => point.stageEnd).length;
}
/** The stages in route order; none before the tour has two points, one for a day tour. */
export function stageSpansOf(points: readonly Pick<TourPlannerWaypoint, 'stageEnd'>[]): StageSpan[] {
if (points.length < 2) return [];
const last = points.length - 1;
const spans: StageSpan[] = [];
let from = 0;
points.forEach((point, index) => {
if (!point.stageEnd || index === 0 || index === last) return;
spans.push({ number: spans.length + 1, from, to: index });
from = index;
});
spans.push({ number: spans.length + 1, from, to: last });
return spans;
}
/**
* Whether a stage can end at this point: a stop between start and end, while
* the tour has room for another stage. A point that ends one already can
* always stop doing so.
*/
export function canEndStage(points: readonly StagePoint[], id: string): boolean {
const index = points.findIndex((point) => point.id === id);
const point = points[index];
if (!point || index === 0 || index === points.length - 1 || point.shaping) return false;
return point.stageEnd === true || stageEndCount(points) < MAX_TOUR_STAGES - 1;
}
/** The point without its stage end. */
function withoutMark<T extends StagePoint>(point: T): T {
const { stageEnd: _taken, ...rest } = point;
return rest as T;
}
/** The points with the stage end at this one set or taken away; the same array when nothing changes. */
export function withStageEnd<T extends StagePoint>(points: T[], id: string, on: boolean): T[] {
const point = points.find((item) => item.id === id);
if (!point || !!point.stageEnd === on || (on && !canEndStage(points, id))) return points;
return points.map((item) => {
if (item.id !== id) return item;
return on ? { ...item, stageEnd: true } : withoutMark(item);
});
}
/** Every stage end taken away: the tour a day tour again, its route as it was. */
export function withoutStageEnds<T extends StagePoint>(points: T[]): T[] {
if (stageEndCount(points) === 0) return points;
return points.map((point) => (point.stageEnd ? withoutMark(point) : point));
}
/**
* How many stage ends a change of the points could not keep: their point was
* removed, or the change left it where no stage can end (at the start or the
* end, as a shaping point) and the planner had to drop the mark. A stage end
* the change took away on purpose is no loss.
*/
export function lostStageEnds(
current: readonly StagePoint[],
changed: readonly StagePoint[],
next: readonly StagePoint[]
): number {
const kept = new Set(next.filter((point) => point.stageEnd).map((point) => point.id));
const asChanged = new Map(changed.map((point) => [point.id, point]));
return current.filter(
(point) => point.stageEnd && !kept.has(point.id) && (asChanged.get(point.id)?.stageEnd ?? true)
).length;
}
/** What a stage measures; null where the route does not tell (no heights on the water, a route not planned yet). */
interface StageMetrics {
distanceKm: number | null;
gain: number | null;
loss: number | null;
/** Moving time, breaks left out. */
minutes: number | null;
/** Where the stage starts and ends along the whole route, in metres, as the elevation profile counts them. */
fromM: number | null;
toM: number | null;
}
export interface TourStage extends StageSpan, StageMetrics {
fromId: string;
toId: string;
/** The moving time alone is more than a day. */
overDay: boolean;
}
/** What a paddle tour's stage times are worked out from, as the planner works out the whole tour's. */
export interface PaddleStageInputs {
effectiveSpeedKmh: number | null;
portageSpeedKmh: number | null;
speedInvalid: boolean;
/** The routed length of each leg, by the index of the point it starts at. */
legLengths?: ReadonlyArray<number | null>;
}
export interface TourStageInputs {
points: readonly TourPlannerWaypoint[];
/** The route as planned for exactly these points, with heights where it has them; null while it is not. */
line: ReadonlyArray<readonly number[]> | null;
tourType: PlannableTourType;
/** The router's time for the whole tour, seconds. */
routerSeconds: number | null;
/** A paddle tour's speeds; null for any other type. */
paddle: PaddleStageInputs | null;
}
const METRES_PER_DEGREE = 111_320;
/**
* How much farther than the nearest spot of the line a point may lie and
* still be placed at an earlier pass: a route that comes by a point twice (out
* and back) places it where it gets there first.
*/
const PASS_TOLERANCE_M = 30;
function metresFrom(vertex: readonly number[], lat: number, lng: number): number {
const x = ((vertex[1] ?? 0) - lng) * Math.cos((lat * Math.PI) / 180);
const y = (vertex[0] ?? 0) - lat;
return Math.hypot(x, y) * METRES_PER_DEGREE;
}
/** The vertex of the line each point lies at, searching on from where the one before lay. */
export function vertexIndicesOf(
points: ReadonlyArray<{ lat: number; lng: number }>,
line: ReadonlyArray<readonly number[]>
): number[] {
const indices: number[] = [];
let from = 0;
for (const point of points) {
let nearest = Infinity;
for (let i = from; i < line.length; i++) nearest = Math.min(nearest, metresFrom(line[i], point.lat, point.lng));
let index = from;
for (let i = from; i < line.length; i++) {
if (metresFrom(line[i], point.lat, point.lng) <= nearest + PASS_TOLERANCE_M) {
index = i;
break;
}
}
indices.push(index);
from = index;
}
return indices;
}
interface Stretch {
distanceKm: number;
gain: number | null;
loss: number | null;
}
/** Distance and heights of one stretch of the line; a stretch of a single vertex measures nothing. */
function stretchOf(line: ReadonlyArray<readonly number[]>, from: number, to: number, heights: boolean): Stretch {
const analysis = to > from ? analyzeRouteGeometry(JSON.stringify(line.slice(from, to + 1))) : null;
return {
distanceKm: analysis?.distanceKm ?? 0,
gain: heights ? (analysis?.gain ?? 0) : null,
loss: heights ? (analysis?.loss ?? 0) : null,
};
}
/**
* A paddle stage's time: its points as their own small tour. A lock at the
* stop that ends a stage is passed on the day that ends there, not again the
* next morning.
*/
function paddleStageMinutes(
points: readonly TourPlannerWaypoint[],
span: StageSpan,
paddle: PaddleStageInputs
): number | null {
const own = points
.slice(span.from, span.to + 1)
.map((point, index) => (index === 0 && span.from > 0 && point.lock ? { ...point, lock: null } : point));
const estimate = paddleEstimateOf(own, paddle, paddle.speedInvalid, paddle.legLengths?.slice(span.from, span.to));
return estimate.waterMinutes === null ? null : estimate.estimateMinutes;
}
/**
* A stage's moving time by the tour's own rule: a paddle stage at the
* paddler's speed, a hike by the signposts' rule over its distance, climb and
* descent, and otherwise the router's time for the whole tour shared out by
* distance, which is how the router times a ride or a walk on the flat.
*/
function stageMinutes(input: TourStageInputs, span: StageSpan, stretch: Stretch, totalKm: number): number | null {
if (input.paddle) return paddleStageMinutes(input.points, span, input.paddle);
if (input.tourType === 'hike' && stretch.gain !== null && stretch.loss !== null) {
return hikingTimeMinutes(stretch.distanceKm, stretch.gain, stretch.loss);
}
if (input.routerSeconds === null || totalKm <= 0) return null;
return Math.round((input.routerSeconds / 60) * (stretch.distanceKm / totalKm));
}
function unmeasured(span: StageSpan, points: readonly TourPlannerWaypoint[]): TourStage {
return {
...span,
fromId: points[span.from].id,
toId: points[span.to].id,
distanceKm: null,
gain: null,
loss: null,
minutes: null,
fromM: null,
toM: null,
overDay: false,
};
}
/** Whether every vertex of the line carries a height. */
function hasHeights(line: ReadonlyArray<readonly number[]>): boolean {
return line.every((vertex) => vertex.length >= 3 && Number.isFinite(vertex[2]));
}
/**
* The stages of the tour with what each measures. While the route is not
* planned for these points every stage is there, unmeasured. A day tour has
* one stage, the whole tour, which the planner's overview already measures.
*/
export function tourStagesOf(input: TourStageInputs): TourStage[] {
const spans = stageSpansOf(input.points);
const { line, points } = input;
if (!line || line.length < 2 || spans.length < 2) return spans.map((span) => unmeasured(span, points));
const at = vertexIndicesOf(points, line);
// A paddle tour's heights come off the banks, not the water: they stay unknown.
const heights = input.tourType !== 'kayak' && hasHeights(line);
const stretches = spans.map((span) => stretchOf(line, at[span.from], at[span.to], heights));
const totalKm = stretches.reduce((sum, stretch) => sum + stretch.distanceKm, 0);
let travelledM = 0;
return spans.map((span, index) => {
const stretch = stretches[index];
const minutes = stageMinutes(input, span, stretch, totalKm);
const fromM = travelledM;
travelledM += stretch.distanceKm * 1000;
return {
...unmeasured(span, points),
...stretch,
minutes,
fromM,
toM: travelledM,
overDay: minutes !== null && minutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
};
});
}
/** Where along the route every stage but the last ends, in metres, for the marks across the elevation profile. */
export function stageBoundariesM(stages: readonly TourStage[]): number[] {
return stages.slice(0, -1).flatMap((stage) => (stage.toM === null ? [] : [stage.toM]));
}
@@ -0,0 +1,79 @@
import type { PlannableTourType } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import {
canEndStage,
stageEndCount,
tourStagesOf,
withoutStageEnds,
withStageEnd,
type PaddleStageInputs,
} from './tourStages';
interface TourStagesOptions {
waypoints: TourPlannerWaypoint[];
/** The route as planned for exactly these points; null while it is not. */
line: ReadonlyArray<readonly number[]> | null;
tourType: PlannableTourType;
routerSeconds: number | null;
paddle: PaddleStageInputs | null;
/** The planner's own change of the points: one step of the history, the draft unsaved again. */
commit: (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
/** Whether the points may change now: the viewer may edit and no save is on its way. */
mayEdit: () => boolean;
/** The draft the planner holds; a choice of a multi-day tour without a stage end yet belongs to it alone. */
draftKey: string;
/** How many stage ends the last change of the points could not keep. */
lost: number;
}
/**
* MDT-01: the stages of the tour in the planner, and how they are set. A tour
* is a multi-day tour once one of its stops ends a stage; before that the
* traveller can choose it and is shown where to set the first stage end. Back
* to a day tour every stage end goes, as one step that undo brings back. The
* route is never planned again for a stage change: the line stays the one
* line, and the stages are cut from it.
*/
export function useTourStages({
waypoints,
line,
tourType,
routerSeconds,
paddle,
commit,
mayEdit,
draftKey,
lost,
}: TourStagesOptions) {
const [chosenFor, setChosenFor] = useState<string | null>(null);
const endCount = stageEndCount(waypoints);
const multiDay = endCount > 0 || chosenFor === draftKey;
const stages = useMemo(
() => (multiDay ? tourStagesOf({ points: waypoints, line, tourType, routerSeconds, paddle }) : []),
[line, multiDay, paddle, routerSeconds, tourType, waypoints]
);
const setMultiDay = useCallback(
(on: boolean) => {
if (!mayEdit()) return;
setChosenFor(on ? draftKey : null);
if (!on) commit(withoutStageEnds);
},
[commit, draftKey, mayEdit]
);
const toggleStageEnd = useCallback(
(id: string) => {
if (!mayEdit()) return;
commit((current) => withStageEnd(current, id, !current.find((point) => point.id === id)?.stageEnd));
},
[commit, mayEdit]
);
const canEnd = useCallback((id: string) => canEndStage(waypoints, id), [waypoints]);
return { multiDay, stages, endCount, lost, setMultiDay, toggleStageEnd, canEnd };
}
export type TourStagesController = ReturnType<typeof useTourStages>;
@@ -13,10 +13,12 @@ interface ElevationProfileProps {
formatFocus?: (focus: RouteProfileFocus) => string
/** Stretches to mark behind the line, in metres along the route (the via ferratas a tour climbs). */
bands?: ReadonlyArray<{ fromM: number; toM: number }>
/** Spots to mark across the profile, in metres along the route (where the stages of a multi-day tour end). */
marks?: readonly number[]
}
/** A route profile whose horizontal axis represents travelled distance. */
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands }: ElevationProfileProps) {
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks }: ElevationProfileProps) {
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
const pointerFrameRef = useRef<number | null>(null)
const pendingPointerDistanceRef = useRef<number | null>(null)
@@ -124,6 +126,10 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
width={Math.max(1, ((band.toM - band.fromM) / chart.maxDistance) * width)} height={height}
fill="var(--map-ferrata)" fillOpacity={0.16} />
))}
{marks?.map(atM => (
<line key={atM} data-profile-mark="true" x1={(atM / chart.maxDistance) * width} x2={(atM / chart.maxDistance) * width}
y1={0} y2={height} stroke="var(--accent)" strokeOpacity={0.55} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
))}
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
{focusX != null && (
@@ -0,0 +1,56 @@
// FE-TOUR-MAP-POINTS-001: the planner map names where a stage of a multi-day tour ends (MDT-01).
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { useTourMapPoints } from './useTourMapPoints';
vi.mock('../../components/Tours/planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
/** No card is open over the map: every press goes through as it is. */
const guard = ((handler: unknown) => handler) as unknown as Parameters<typeof useTourMapPoints>[0]['guard'];
describe('useTourMapPoints', () => {
it('FE-TOUR-MAP-POINTS-001: a stop that ends a stage carries the mark and says which stage it ends', () => {
const tourPlanner = {
mode: { type: 'new-draft' },
isSaving: false,
waypoints: [
{ id: 'a', lat: 47, lng: 11, role: 'start' },
{ id: 's', lat: 47.05, lng: 11, role: 'via', shaping: true },
{ id: 'b', lat: 47.1, lng: 11, role: 'via', stageEnd: true },
{ id: 'c', lat: 47.2, lng: 11, role: 'via', stageEnd: true },
{ id: 'd', lat: 47.3, lng: 11, role: 'end' },
],
selectedWaypointId: null,
setSelectedWaypointId: vi.fn(),
setWaypointPosition: vi.fn(),
addWaypoint: vi.fn(),
undo: vi.fn(),
maxHikingDifficulty: 2,
isPaddle: false,
} as unknown as TourPlannerController;
const { result } = renderHook(
() => useTourMapPoints({ tourPlanner, canPlaceEdit: true, mode: '2d', cardOpen: false, guard }),
{ wrapper }
);
const points = result.current.waypoints.points as Array<{
id: string;
label?: string;
stageEnd?: boolean;
detail?: string;
}>;
expect(points.map((point) => [point.id, point.label, point.stageEnd ?? false])).toEqual([
['a', '1', false],
['s', undefined, false],
['b', '2', true],
['c', '3', true],
['d', '4', false],
]);
expect(points[2].detail).toBe('Via, End of stage 1');
expect(points[3].detail).toBe('Via, End of stage 2');
expect(points[4].detail).toBe('End');
});
});
@@ -78,6 +78,8 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, gu
const roles = isPaddle ? paddleRolesById(waypoints) : null;
// Stops are numbered on their own: a shaping point (P2A-03) only steers and takes no number.
let stop = 0;
// MDT-01: the stages of a multi-day tour, counted as their ends come along the route.
let stage = 0;
return shown.map((point) => {
if (point.shaping) {
return {
@@ -90,11 +92,13 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, gu
const role = roles?.get(point.id);
const detail = role ? t(`tours.paddle.role.${role}`) : roles ? '' : t(ROLE_KEY[point.role]);
const lock = point.lock ? t('tours.paddle.lock') : '';
if (point.stageEnd) stage += 1;
const stageEnd = point.stageEnd ? t('tours.stages.endOf', { number: stage }) : '';
return {
...point,
label: String(stop),
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: stop }),
detail: [detail, lock].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]),
detail: [detail, lock, stageEnd].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]),
};
});
}
+10
View File
@@ -67,6 +67,16 @@ export function hikePlannerDefaults() {
editWaypoints: vi.fn(),
// CW-07: a draft planned in TREK, not read from a Google Maps walk.
draftSource: null,
// MDT-01: a day tour, no stage set and none that may be.
stages: {
multiDay: false,
stages: [],
endCount: 0,
lost: 0,
setMultiDay: vi.fn(),
toggleStageEnd: vi.fn(),
canEnd: vi.fn(() => false),
},
// P2C-03: a tour opened at its first revision, with nothing known about others.
revision: {
base: null,
@@ -24,6 +24,7 @@ export class TourWaypoints {
protected: number & Opt = 0;
opening_hours?: string | null;
poi_category?: string | null;
stage_end: number & Opt = 0;
}
export const TourWaypointsImportance = {
@@ -51,6 +52,7 @@ export const TourWaypointsSchema = defineEntity({
expression: 'stay_minutes IS NULL OR (stay_minutes >= 0 AND stay_minutes <= 1440)',
},
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
{ name: 'tour_waypoints_stage_end_check', expression: 'stage_end IN (0, 1)' },
],
properties: {
id: p.integer().primary(),
@@ -72,5 +74,6 @@ export const TourWaypointsSchema = defineEntity({
protected: p.integer().default(0),
opening_hours: p.text().nullable(),
poi_category: p.text().nullable(),
stage_end: p.integer().default(0),
},
});
@@ -20,4 +20,5 @@ export interface TourWaypointsTable {
protected: InsertOptional<number>;
opening_hours: InsertOptional<string | null>;
poi_category: InsertOptional<string | null>;
stage_end: InsertOptional<number>;
}
@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
/**
* MDT-01: a stop of a tour can end a stage, so one route planned whole splits
* into the days of a multi-day tour, each stage running from one such stop to
* the next. 0 or 1; every waypoint saved before ends none, and a tour without
* one stays a day tour, as it was.
*/
export class Migration20200101046000_a_tour_waypoint_can_end_a_stage extends Migration {
override name = 'Migration20200101046000_a_tour_waypoint_can_end_a_stage';
override up(): void {
this.addSql(
`ALTER TABLE tour_waypoints ADD COLUMN stage_end INTEGER NOT NULL DEFAULT 0 CHECK (stage_end IN (0, 1))`,
);
}
}
@@ -33,6 +33,8 @@ export interface TourWaypointRow {
opening_hours?: string | null;
/** PLN-03: the kind of place the point was taken in from along the tour; absent on a point set by hand. */
poi_category?: TourPoiCategory | null;
/** MDT-01: a stop where a stage of a multi-day tour ends; absent where the route goes on. */
stage_end?: boolean;
}
/** The row as stored, the fixed leg still JSON text. */
@@ -52,6 +54,7 @@ interface StoredWaypoint {
protected: number;
opening_hours: string | null;
poi_category: string | null;
stage_end: number;
}
/** The columns a waypoint is read with, as the stored row names them. */
@@ -71,6 +74,7 @@ const WAYPOINT_COLUMNS = [
'protected',
'opening_hours',
'poi_category',
'stage_end',
] as const;
/** A stored kind the contract knows; one it does not (dropped since) reads as none. */
@@ -100,6 +104,7 @@ function readWaypoint(row: StoredWaypoint): TourWaypointRow {
...(row.protected ? { protected: true } : {}),
...(opening_hours ? { opening_hours } : {}),
...(poi_category ? { poi_category } : {}),
...(row.stage_end ? { stage_end: true } : {}),
};
}
@@ -196,6 +201,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
protected: w.protected ? 1 : 0,
opening_hours: w.opening_hours ?? null,
poi_category: w.poi_category ?? null,
stage_end: w.stage_end ? 1 : 0,
})),
);
}
+12 -1
View File
@@ -66,6 +66,8 @@ export interface TourListRow {
/** ACT-01: the first and last time on an imported track's points; NULL without times. */
track_started_at: string | null;
track_ended_at: string | null;
/** MDT-01: how many of its stops end a stage; 0 for a day tour. */
stage_ends: number;
}
/** LIB-02: a tour of another trip as the take-over list reads it, with the trip it is in. */
@@ -314,6 +316,12 @@ export class ToursRepository extends TrekRepository<Tours> {
eb
.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id'))
.as('has_waypoints'),
eb
.selectFrom('tour_waypoints as ts')
.select((count) => count.fn.countAll<number>().as('n'))
.whereRef('ts.place_id', '=', 'p.id')
.where('ts.stage_end', '=', 1)
.as('stage_ends'),
]);
}
@@ -879,11 +887,12 @@ function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_wa
}
function toListRow<
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing'> & {
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing' | 'stage_ends'> & {
planned: unknown;
has_waypoints: unknown;
has_recorded_track: unknown;
route_missing: unknown;
stage_ends: unknown;
},
>(r: R): TourListRow {
return {
@@ -892,5 +901,7 @@ function toListRow<
has_waypoints: Number(r.has_waypoints),
has_recorded_track: Number(r.has_recorded_track),
route_missing: Number(r.route_missing),
// Postgres counts in a bigint, which its driver hands back as text.
stage_ends: Number(r.stage_ends),
};
}
+2 -2
View File
@@ -101,7 +101,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), 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, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), 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, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -116,7 +116,7 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
+22 -4
View File
@@ -43,7 +43,8 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
hikingPreferencesOf,
MAX_PLANNED_TOUR_DURATION_MINUTES,
maxTourMinutesFor,
plannedTimeLimitMessage,
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type BikeProfile,
@@ -64,6 +65,8 @@ import {
type TourProvenance,
type TourProvenanceSource,
type TourWaypoint,
tourStageCountOf,
tourStageIssues,
type WandererDifficulty,
type WinterActivity,
walkStationFacts,
@@ -219,6 +222,8 @@ export class ToursService {
track_started_at: utcSuffix(r.track_started_at),
track_ended_at: utcSuffix(r.track_ended_at),
...activityFacts(r.place_id, activities),
// MDT-01: a multi-day tour's stages, one more than the stops that end one.
stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null,
};
}
@@ -286,16 +291,27 @@ export class ToursService {
throw badRequest('planned Tour times must be whole minutes from 0 to 1440');
}
}
// MDT-01: a multi-day tour is held to a day a stage, a day tour to one day.
const stages = tourStageCountOf(input.waypoints);
if (
input.planned_duration_minutes == null &&
input.duration_seconds != null &&
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) >
MAX_PLANNED_TOUR_DURATION_MINUTES
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages)
) {
throw badRequest('walking time plus breaks must be 1440 minutes or less unless a total is overridden');
throw badRequest(plannedTimeLimitMessage(stages));
}
}
/**
* MDT-01: the stage ends sit at stops between start and end, as the contract
* holds a request to; checked here too for a write that did not come through
* it (an assistant's city walk), so no surface can store one out of place.
*/
private validateStageEnds(input: TourCreateCommand): void {
const [issue] = tourStageIssues(input.waypoints);
if (issue) throw badRequest(issue.message);
}
/** All tours (the facet + owning place) for a trip, newest first; a paddle tour with its check bench (PAD-09). */
async listTours(tripId: string): Promise<TourListItem[]> {
const tid = toRowId(tripId);
@@ -425,6 +441,7 @@ export class ToursService {
const tid = toRowId(tripId)!;
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validateStageEnds(input);
this.validatePlannedTimes(input);
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
if (!start || (input.route_geometry === null && input.waypoints.length < 2)) {
@@ -755,6 +772,7 @@ export class ToursService {
async routeWrite(input: TourCreateCommand): Promise<TourRouteWrite> {
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validateStageEnds(input);
this.validatePlannedTimes(input);
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
if (!start) throw badRequest('A tour needs at least two points');
+44
View File
@@ -1205,6 +1205,50 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
});
});
describe('multi-day tours (MDT-01)', () => {
/** Two days over the ridge: the middle stop ends the first stage. */
const twoDays = (stageAt: number) =>
tourBody({
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
].map((point) => (point.sequence === stageAt ? { ...point, stage_end: true } : point)),
});
it('saves one route with its stage ends, lists its stages, and an edit joins them again', async () => {
const placeId = await createViaApi(5, twoDays(1));
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
expect(detail.status).toBe(200);
expect(detail.body.tour).toMatchObject({ stage_count: 2, tour_type: 'hike' });
expect(detail.body.waypoints.map((point: { stage_end?: boolean }) => point.stage_end ?? false)).toEqual([
false,
true,
false,
]);
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
expect(list.body.tours[0]).toMatchObject({ place_id: placeId, stage_count: 2 });
const joined = await request(server)
.put(`/api/trips/5/tours/${placeId}`)
.set('Cookie', sessionCookie(OWNER))
.send(twoDays(-1));
expect(joined.status).toBe(200);
expect(joined.body.tour.stage_count).toBeNull();
});
it('400 for a stage end at the start or the end of the tour, and nothing is written', async () => {
const before = await countRows(orm, Tours, {});
const refused = await Promise.all(
[0, 2].map((at) =>
request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(twoDays(at)),
),
);
expect(refused.map((res) => res.status)).toEqual([400, 400]);
expect(await countRows(orm, Tours, {})).toBe(before);
});
});
describe('planning over an import (PR1-05)', () => {
/** An imported GPX tour on trip 5: no waypoints, the recorded line as it came. */
const importedTour = async (tripId = 5) => {
+3 -1
View File
@@ -21,7 +21,7 @@ export async function makeTour(orm: FactoryOrm, placeId: number, overrides: Enti
type WaypointRole = 'start' | 'via' | 'end';
/** A city walk station's columns (CW-01 to CW-04), a shaping point's flag and the place a point stands for. */
/** A city walk station's columns (CW-01 to CW-04), a shaping point's and a stage end's flags and the place a point stands for. */
interface StationColumns {
station_key?: string | null;
name?: string | null;
@@ -32,6 +32,8 @@ interface StationColumns {
opening_hours?: string | null;
/** 1 for a shaping point. */
shaping?: number;
/** MDT-01: 1 for a stop that ends a stage of a multi-day tour. */
stage_end?: number;
/** The id of the place of the trip the point stands for. */
linkedPlace?: number | null;
/** The leg to the next point as a fixed line, as its JSON column stores it. */
@@ -74,6 +74,7 @@ const UNNUMBERED = [
'Migration20200101045710_a_saved_tour_change_can_be_taken_back',
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
'Migration20200101046000_a_tour_waypoint_can_end_a_stage',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -221,6 +221,21 @@ describe('TourWaypointsRepository', () => {
expect((await repo.listForPlace(place.id))[1]).not.toHaveProperty('poi_category');
});
it('TWREPO-012: a stop that ends a stage is stored as such and read back; the others carry no flag (MDT-01)', async () => {
const { trip, place } = tourInTrip();
await repo.insertForPlace(place.id, [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1, stage_end: true },
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
]);
const points = await repo.listForPlace(place.id);
expect(points[1]).toEqual({ lat: 47.05, lng: 11.05, role: 'via', sequence: 1, stage_end: true });
expect(points[0]).not.toHaveProperty('stage_end');
expect(points[2]).not.toHaveProperty('stage_end');
// A trip copy reads the mark with the rest of the point.
expect((await repo.listForTrip(trip.id))[1]).toMatchObject({ place_id: place.id, stage_end: true });
});
it('TWREPO-010: listForPlaces reads the waypoints of the tours asked for, by place and sequence, and none for none (CW-02)', async () => {
const { trip, place } = tourInTrip();
const second = await makePlace(t, trip.id);
@@ -11,7 +11,7 @@ import type { ToursRepository } from '../../../../src/db/repositories/Tours.repo
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { makePlace } from '../../../helpers/factories/places';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
import { insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
import { makeDay } from '../../../helpers/factories/trips';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
@@ -112,6 +112,7 @@ describe('ToursRepository reads', () => {
ski_technique: null,
track_started_at: null,
track_ended_at: null,
stage_ends: 0,
},
{
place_id: older.id,
@@ -151,6 +152,7 @@ describe('ToursRepository reads', () => {
ski_technique: null,
track_started_at: null,
track_ended_at: null,
stage_ends: 0,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
@@ -195,6 +197,32 @@ describe('ToursRepository reads', () => {
expect((await repo.findInTrip(trip.id, drawn.id))?.route_missing).toBe(0);
});
it('TOURREPO-016: the list and the detail count the stops that end a stage, 0 for a day tour (MDT-01)', async () => {
const { trip } = fixture();
const hutToHut = await makePlace(t, trip.id, { name: 'Hut to hut' });
const dayHike = await makePlace(t, trip.id, { name: 'Day hike' });
createTour(testDb, hutToHut.id);
createTour(testDb, dayHike.id);
await insertRows(
t,
TourWaypoints,
[0, 1, 1, 0].map((stage_end, sequence) => ({
place: hutToHut.id,
lat: 47,
lng: 11,
role: 'via',
sequence,
stage_end,
})),
);
await addWaypoint(dayHike.id, 0);
const listed = new Map((await repo.listForTrip(trip.id)).map((row) => [row.place_id, row.stage_ends]));
expect(listed.get(hutToHut.id)).toBe(2);
expect(listed.get(dayHike.id)).toBe(0);
expect((await repo.findInTrip(trip.id, hutToHut.id))?.stage_ends).toBe(2);
});
it('TOURREPO-015: dayOfTrip reads a day of the trip with its date, and nothing for a day of another trip', async () => {
const { trip, other } = fixture();
const dated = await makeDay(t, trip.id, { date: '2026-07-12' });
+28
View File
@@ -337,6 +337,32 @@ describe('ToursMcp', () => {
]);
});
it('TOURS-MCP-015: list_tours counts the stages of a multi-day tour and get_tour_plan marks where each ends (MDT-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await addTourWaypoints(orm, ridge.id, [
{ lat: 47, lng: 11 },
{ lat: 47.05, lng: 11.05, stage_end: 1 },
{ lat: 47.1, lng: 11.1, stage_end: 1 },
{ lat: 47.15, lng: 11.15 },
]);
await orm.close();
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const stages = new Map(
tours.map((tour: { place_id: number; stage_count: number | null }) => [tour.place_id, tour.stage_count]),
);
expect(stages.get(ridge.id)).toBe(3);
expect(stages.get(gorge.id)).toBeNull();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(user.id)));
expect(plan.waypoints.map((point: { stage_end?: boolean }) => point.stage_end === true)).toEqual([
false,
true,
true,
false,
]);
});
it('TOURS-MCP-014: the tools name the station fields, and the deletes ask before a walk takes its protected stations along', () => {
const description = (ctor: unknown, method: string) => {
const entry = getEntry(ctor as ClassRef, method);
@@ -344,6 +370,8 @@ describe('ToursMcp', () => {
return entry.options.description ?? '';
};
expect(description(ToursMcp, 'listTours')).toMatch(/station_count.*route_pending/);
expect(description(ToursMcp, 'listTours')).toContain('stage_count');
expect(description(ToursMcp, 'getTourPlan')).toContain('stage_end');
const planFields = ['key', 'stay_minutes', 'importance', 'protected', 'opening_hours', 'visit_status', 'derived'];
for (const field of planFields) {
expect(description(ToursMcp, 'getTourPlan')).toContain(field);
@@ -682,3 +682,69 @@ describe('ToursService runs (RUN-01)', () => {
});
});
describe('ToursService multi-day tours (MDT-01)', () => {
/** Hut to hut over three days: two stops between start and end each end a stage. */
const hutToHut: TourCreateRequest = {
...request,
planned_duration_minutes: null,
break_additional_minutes: 60,
duration_seconds: 30 * 3600,
route_geometry: [
[48, 11, 600],
[48.01, 11.02, 650],
[48.02, 11.04, 630],
[48.03, 11.06, 700],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'via', sequence: 1, stage_end: true },
{ lat: 48.02, lng: 11.04, role: 'via', sequence: 2, stage_end: true },
{ lat: 48.03, lng: 11.06, role: 'end', sequence: 3 },
],
};
it('MDT-SVC-001: keeps the stage ends with their stops and lists the stages, a day tour none', async () => {
const created = await service.createTour(tripId, hutToHut);
expect(created.tour.stage_count).toBe(3);
expect(created.waypoints.map((point) => point.stage_end ?? false)).toEqual([false, true, true, false]);
const dayTour = await service.createTour(tripId, request);
expect(dayTour.tour.stage_count).toBeNull();
const listed = new Map((await service.listTours(tripId)).map((tour) => [tour.place_id, tour.stage_count]));
expect(listed.get(created.tour.place_id)).toBe(3);
expect(listed.get(dayTour.tour.place_id)).toBeNull();
});
it('MDT-SVC-002: an edit moves, joins and drops stages with the points it saves', async () => {
const created = await service.createTour(tripId, hutToHut);
const placeId = String(created.tour.place_id);
const joined = hutToHut.waypoints.map(({ stage_end: _end, ...point }, index) =>
index === 2 ? { ...point, stage_end: true } : point,
);
const edited = await service.updateTour(tripId, placeId, { ...hutToHut, waypoints: joined });
expect(edited.tour.stage_count).toBe(2);
expect(edited.waypoints.map((point) => point.stage_end ?? false)).toEqual([false, false, true, false]);
const oneDay = await service.updateTour(tripId, placeId, { ...request, waypoints: request.waypoints });
expect(oneDay.tour.stage_count).toBeNull();
expect(await countRows(await sharedTestOrm(testDb), TourWaypoints, { stage_end: 1 })).toBe(0);
});
it('MDT-SVC-003: holds a multi-day tour to a day a stage, and refuses a stage end out of place', async () => {
const tooLong = { ...hutToHut, duration_seconds: 72 * 3600 };
const refusal = await catchDomainError(() => service.createTour(tripId, tooLong));
expect(refusal instanceof DomainError && refusal.publicMessage).toBe(
'walking time plus breaks must be 4320 minutes or less, a day a stage, unless a total is overridden',
);
// A write that did not come through the contract is held to the same rule.
const atStart = {
...hutToHut,
waypoints: hutToHut.waypoints.map((point, index) => (index === 0 ? { ...point, stage_end: true } : point)),
};
const misplaced = await catchDomainError(() => service.createTour(tripId, atStart));
expect(misplaced instanceof DomainError && [misplaced.getStatus(), misplaced.publicMessage]).toEqual([
400,
'a stage ends at a stop between start and end',
]);
expect(await count(Tours)).toBe(0);
});
});
+34
View File
@@ -1292,5 +1292,39 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'تعذر تغيير الجولة',
'tours.stats.unavailable': 'تعذر تحميل الجولات المنجزة',
'tours.stats.openTrip': 'فتح الرحلة',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'الأيام',
'tours.stages.mode.single': 'رحلة يوم',
'tours.stages.mode.multi': 'عدة أيام',
'tours.stages.emptyHint': 'من قائمة التوقف اختر «إنهاء المرحلة هنا» لتقسيم المسار.',
'tours.stages.endHere': 'إنهاء المرحلة هنا',
'tours.stages.removeEnd': 'إزالة نهاية المرحلة',
'tours.stages.stage': 'المرحلة {number}',
'tours.stages.endOf': 'نهاية المرحلة {number}',
'tours.stages.title': 'المراحل',
'tours.stages.count': 'المراحل: {count}',
'tours.stages.count.zero': 'المراحل: {count}',
'tours.stages.count.one': 'المراحل: {count}',
'tours.stages.count.two': 'المراحل: {count}',
'tours.stages.count.few': 'المراحل: {count}',
'tours.stages.count.many': 'المراحل: {count}',
'tours.stages.overDay': 'أكثر من يوم في الحركة',
'tours.stages.timeInfo':
'وقت الحركة لكل مرحلة دون الاستراحات. في جولات الدراجة والجولات في المدينة يُوزَّع وقت مخطط المسار حسب المسافة.',
'tours.stages.lost': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.lost.zero': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.lost.one': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.lost.two': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.lost.few': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.lost.many': 'نهايات مراحل سقطت مع توقفاتها: {count}. صارت تلك المراحل مدمجة.',
'tours.stages.toDayTitle': 'تحويلها إلى رحلة يوم واحد؟',
'tours.stages.toDayBody': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayBody.zero': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayBody.one': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayBody.two': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayBody.few': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayBody.many': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.',
'tours.stages.toDayConfirm': 'إزالة نهايات المراحل',
'tours.stages.show': 'إظهار مكان نهاية هذه المرحلة',
};
export default tours;
+22
View File
@@ -1260,5 +1260,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Turu dəyişmək mümkün olmadı',
'tours.stats.unavailable': 'Tamamlanmış turları yükləmək mümkün olmadı',
'tours.stats.openTrip': 'Səyahəti aç',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Günlər',
'tours.stages.mode.single': 'Bir günlük',
'tours.stages.mode.multi': 'Çoxgünlük',
'tours.stages.emptyHint': 'Marşrutu bölmək üçün dayanacağın menyusunda «Mərhələni burada bitir» seçin.',
'tours.stages.endHere': 'Mərhələni burada bitir',
'tours.stages.removeEnd': 'Mərhələ sonunu sil',
'tours.stages.stage': 'Mərhələ {number}',
'tours.stages.endOf': 'Mərhələ {number} sonu',
'tours.stages.title': 'Mərhələlər',
'tours.stages.count': '{count} mərhələ',
'tours.stages.count.one': '{count} mərhələ',
'tours.stages.overDay': 'Bir gündən çox hərəkətdə',
'tours.stages.timeInfo':
'Hər mərhələ üçün hərəkət vaxtı, fasilələr daxil deyil. Velosiped turlarında və şəhər gəzintilərində marşrut planlayıcısının vaxtı məsafəyə görə bölünür.',
'tours.stages.lost': '{count} mərhələ sonu dayanacaqları ilə birlikdə itdi; həmin mərhələlər indi birləşib.',
'tours.stages.lost.one': '{count} mərhələ sonu dayanacağı ilə birlikdə itdi; onun iki mərhələsi indi birləşib.',
'tours.stages.toDayTitle': 'Bir günlük tura çevrilsin?',
'tours.stages.toDayBody': '{count} mərhələ sonu silinir. Marşrut olduğu kimi qalır, Geri al onları qaytarır.',
'tours.stages.toDayBody.one': '{count} mərhələ sonu silinir. Marşrut olduğu kimi qalır, Geri al onu qaytarır.',
'tours.stages.toDayConfirm': 'Mərhələ sonlarını sil',
'tours.stages.show': 'Bu mərhələnin harada bitdiyini göstər',
};
export default tours;
+23
View File
@@ -1272,5 +1272,28 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Não foi possível alterar o passeio',
'tours.stats.unavailable': 'Não foi possível carregar os passeios realizados',
'tours.stats.openTrip': 'Abrir viagem',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dias',
'tours.stages.mode.single': 'Um dia',
'tours.stages.mode.multi': 'Vários dias',
'tours.stages.emptyHint': 'No menu de uma parada, escolha “Encerrar etapa aqui” para dividir a rota.',
'tours.stages.endHere': 'Encerrar etapa aqui',
'tours.stages.removeEnd': 'Remover fim de etapa',
'tours.stages.stage': 'Etapa {number}',
'tours.stages.endOf': 'Fim da etapa {number}',
'tours.stages.title': 'Etapas',
'tours.stages.count': '{count} etapas',
'tours.stages.count.one': '{count} etapa',
'tours.stages.overDay': 'Mais de um dia em movimento',
'tours.stages.timeInfo':
'Tempo em movimento por etapa, sem pausas. Em passeios de bicicleta e caminhadas urbanas, o tempo do planejador de rotas é dividido pela distância.',
'tours.stages.lost': '{count} fins de etapa sumiram com suas paradas; essas etapas agora estão unidas.',
'tours.stages.lost.one': '{count} fim de etapa sumiu com sua parada; suas duas etapas agora estão unidas.',
'tours.stages.toDayTitle': 'Transformar em passeio de um dia?',
'tours.stages.toDayBody':
'Os {count} fins de etapa são removidos. A rota fica como está, e Desfazer os traz de volta.',
'tours.stages.toDayBody.one': '{count} fim de etapa é removido. A rota fica como está, e Desfazer o traz de volta.',
'tours.stages.toDayConfirm': 'Remover fins de etapa',
'tours.stages.show': 'Mostrar onde esta etapa termina',
};
export default tours;
+23
View File
@@ -1279,5 +1279,28 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'No s’ha pogut canviar l’excursió',
'tours.stats.unavailable': 'No s’han pogut carregar les excursions fetes',
'tours.stats.openTrip': 'Obre el viatge',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dies',
'tours.stages.mode.single': 'Un dia',
'tours.stages.mode.multi': 'Diversos dies',
'tours.stages.emptyHint': 'Al menú d’una parada, tria «Acaba l’etapa aquí» per dividir la ruta.',
'tours.stages.endHere': 'Acaba l’etapa aquí',
'tours.stages.removeEnd': 'Treu el final d’etapa',
'tours.stages.stage': 'Etapa {number}',
'tours.stages.endOf': 'Final de l’etapa {number}',
'tours.stages.title': 'Etapes',
'tours.stages.count': '{count} etapes',
'tours.stages.count.one': '{count} etapa',
'tours.stages.overDay': 'Més d’un dia en moviment',
'tours.stages.timeInfo':
'Temps en moviment per etapa, sense pauses. En bici i en passejades per la ciutat, el temps del planificador de rutes es reparteix per distància.',
'tours.stages.lost': '{count} finals d’etapa s’han perdut amb les seves parades; aquestes etapes ara estan unides.',
'tours.stages.lost.one':
'{count} final d’etapa s’ha perdut amb la seva parada; les seves dues etapes ara estan unides.',
'tours.stages.toDayTitle': 'Convertir-la en una ruta d’un dia?',
'tours.stages.toDayBody': 'Es treuen els {count} finals d’etapa. La ruta queda com està i Desfés els recupera.',
'tours.stages.toDayBody.one': 'Es treu {count} final d’etapa. La ruta queda com està i Desfés el recupera.',
'tours.stages.toDayConfirm': 'Treu els finals d’etapa',
'tours.stages.show': 'Mostra on acaba aquesta etapa',
};
export default tours;
+25
View File
@@ -1258,5 +1258,30 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Výlet se nepodařilo změnit',
'tours.stats.unavailable': 'Absolvované výlety se nepodařilo načíst',
'tours.stats.openTrip': 'Otevřít cestu',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dny',
'tours.stages.mode.single': 'Jednodenní',
'tours.stages.mode.multi': 'Vícedenní',
'tours.stages.emptyHint': 'V nabídce zastávky zvolte „Ukončit etapu zde“ a rozdělte trasu.',
'tours.stages.endHere': 'Ukončit etapu zde',
'tours.stages.removeEnd': 'Odebrat konec etapy',
'tours.stages.stage': 'Etapa {number}',
'tours.stages.endOf': 'Konec etapy {number}',
'tours.stages.title': 'Etapy',
'tours.stages.count': '{count} etap',
'tours.stages.count.one': '{count} etapa',
'tours.stages.count.few': '{count} etapy',
'tours.stages.overDay': 'Víc než den v pohybu',
'tours.stages.timeInfo':
'Čas v pohybu na etapu, bez přestávek. U cyklotras a městských procházek se čas plánovače tras dělí podle vzdálenosti.',
'tours.stages.lost': '{count} konců etap zmizelo se svými zastávkami; tyto etapy jsou teď spojené.',
'tours.stages.lost.one': '{count} konec etapy zmizel se svou zastávkou; jeho dvě etapy jsou teď spojené.',
'tours.stages.lost.few': '{count} konce etap zmizely se svými zastávkami; tyto etapy jsou teď spojené.',
'tours.stages.toDayTitle': 'Udělat z ní jednodenní túru?',
'tours.stages.toDayBody': 'Odebere se {count} konců etap. Trasa zůstane, jak je, a Zpět je vrátí.',
'tours.stages.toDayBody.one': 'Odebere se {count} konec etapy. Trasa zůstane, jak je, a Zpět ho vrátí.',
'tours.stages.toDayBody.few': 'Odeberou se {count} konce etap. Trasa zůstane, jak je, a Zpět je vrátí.',
'tours.stages.toDayConfirm': 'Odebrat konce etap',
'tours.stages.show': 'Ukázat, kde etapa končí',
};
export default tours;
+26
View File
@@ -1286,6 +1286,32 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Die Tour konnte nicht geändert werden',
'tours.stats.unavailable': 'Touraktivitäten konnten nicht geladen werden',
'tours.stats.openTrip': 'Reise öffnen',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Tage',
'tours.stages.mode.single': 'Tagestour',
'tours.stages.mode.multi': 'Mehrtagestour',
'tours.stages.emptyHint': 'Im Menü eines Stopps „Etappe hier beenden“ wählen, um die Route zu teilen.',
'tours.stages.endHere': 'Etappe hier beenden',
'tours.stages.removeEnd': 'Etappenende entfernen',
'tours.stages.stage': 'Etappe {number}',
'tours.stages.endOf': 'Ende von Etappe {number}',
'tours.stages.title': 'Etappen',
'tours.stages.count': '{count} Etappen',
'tours.stages.count.one': '{count} Etappe',
'tours.stages.overDay': 'Mehr als ein Tag unterwegs',
'tours.stages.timeInfo':
'Zeit in Bewegung je Etappe, ohne Pausen. Radtouren und Stadtspaziergänge teilen die Zeit des Routers nach Strecke auf.',
'tours.stages.lost':
'{count} Etappenenden sind mit ihren Stopps weggefallen; diese Etappen sind jetzt zusammengelegt.',
'tours.stages.lost.one':
'{count} Etappenende ist mit seinem Stopp weggefallen; seine zwei Etappen sind jetzt zusammengelegt.',
'tours.stages.toDayTitle': 'Zur Tagestour machen?',
'tours.stages.toDayBody':
'Die {count} Etappenenden werden entfernt. Die Route bleibt, wie sie ist, und Rückgängig holt sie zurück.',
'tours.stages.toDayBody.one':
'{count} Etappenende wird entfernt. Die Route bleibt, wie sie ist, und Rückgängig holt es zurück.',
'tours.stages.toDayConfirm': 'Etappenenden entfernen',
'tours.stages.show': 'Zeigen, wo diese Etappe endet',
};
export default tours;
+22
View File
@@ -1247,5 +1247,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'The tour could not be changed',
'tours.stats.unavailable': 'Tour activities could not be loaded',
'tours.stats.openTrip': 'Open trip',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Days',
'tours.stages.mode.single': 'Day tour',
'tours.stages.mode.multi': 'Multi-day tour',
'tours.stages.emptyHint': 'In the menu of a stop, choose End stage here to split the route.',
'tours.stages.endHere': 'End stage here',
'tours.stages.removeEnd': 'Remove stage end',
'tours.stages.stage': 'Stage {number}',
'tours.stages.endOf': 'End of stage {number}',
'tours.stages.title': 'Stages',
'tours.stages.count': '{count} stages',
'tours.stages.count.one': '{count} stage',
'tours.stages.overDay': 'More than a day on the move',
'tours.stages.timeInfo':
'Moving time of each stage, breaks not included. Bike tours and city walks share the router time by distance.',
'tours.stages.lost': '{count} stage ends were dropped with their stops; those stages are joined now.',
'tours.stages.lost.one': '{count} stage end was dropped with its stop; its two stages are joined now.',
'tours.stages.toDayTitle': 'Make it a day tour?',
'tours.stages.toDayBody': 'The {count} stage ends are removed. The route stays as it is, and undo brings them back.',
'tours.stages.toDayBody.one': '{count} stage end is removed. The route stays as it is, and undo brings it back.',
'tours.stages.toDayConfirm': 'Remove stage ends',
'tours.stages.show': 'Show where this stage ends',
};
export default tours;
+22
View File
@@ -1272,5 +1272,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'No se pudo cambiar la excursión',
'tours.stats.unavailable': 'No se pudieron cargar las excursiones realizadas',
'tours.stats.openTrip': 'Abrir viaje',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Días',
'tours.stages.mode.single': 'Un día',
'tours.stages.mode.multi': 'Varios días',
'tours.stages.emptyHint': 'En el menú de una parada, elige «Terminar etapa aquí» para dividir la ruta.',
'tours.stages.endHere': 'Terminar etapa aquí',
'tours.stages.removeEnd': 'Quitar fin de etapa',
'tours.stages.stage': 'Etapa {number}',
'tours.stages.endOf': 'Fin de la etapa {number}',
'tours.stages.title': 'Etapas',
'tours.stages.count': '{count} etapas',
'tours.stages.count.one': '{count} etapa',
'tours.stages.overDay': 'Más de un día en movimiento',
'tours.stages.timeInfo':
'Tiempo en movimiento por etapa, sin pausas. En bici y en paseos urbanos, el tiempo del planificador de rutas se reparte por distancia.',
'tours.stages.lost': '{count} finales de etapa se perdieron con sus paradas; esas etapas ahora están unidas.',
'tours.stages.lost.one': '{count} final de etapa se perdió con su parada; sus dos etapas ahora están unidas.',
'tours.stages.toDayTitle': '¿Convertirla en una ruta de un día?',
'tours.stages.toDayBody': 'Se quitan los {count} finales de etapa. La ruta queda como está y Deshacer los recupera.',
'tours.stages.toDayBody.one': 'Se quita {count} final de etapa. La ruta queda como está y Deshacer lo recupera.',
'tours.stages.toDayConfirm': 'Quitar finales de etapa',
'tours.stages.show': 'Mostrar dónde termina esta etapa',
};
export default tours;
+22
View File
@@ -1256,5 +1256,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Retke ei saanud muuta',
'tours.stats.unavailable': 'Tehtud retki ei saanud laadida',
'tours.stats.openTrip': 'Ava reis',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Päevad',
'tours.stages.mode.single': 'Ühepäevane',
'tours.stages.mode.multi': 'Mitmepäevane',
'tours.stages.emptyHint': 'Marsruudi jagamiseks vali peatuse menüüst „Lõpeta etapp siin“.',
'tours.stages.endHere': 'Lõpeta etapp siin',
'tours.stages.removeEnd': 'Eemalda etapi lõpp',
'tours.stages.stage': 'Etapp {number}',
'tours.stages.endOf': 'Etapi {number} lõpp',
'tours.stages.title': 'Etapid',
'tours.stages.count': '{count} etappi',
'tours.stages.count.one': '{count} etapp',
'tours.stages.overDay': 'Üle päeva liikumises',
'tours.stages.timeInfo':
'Liikumisaeg etapi kohta, pausideta. Rattamatkadel ja linnajalutuskäikudel jagatakse teekonnaplaneerija aeg vahemaa järgi.',
'tours.stages.lost': '{count} etapi lõppu kadus koos peatustega; need etapid on nüüd ühendatud.',
'tours.stages.lost.one': '{count} etapi lõpp kadus koos peatusega; selle kaks etappi on nüüd ühendatud.',
'tours.stages.toDayTitle': 'Teha sellest ühepäevane matk?',
'tours.stages.toDayBody': '{count} etapi lõppu eemaldatakse. Marsruut jääb samaks ja Võta tagasi taastab need.',
'tours.stages.toDayBody.one': '{count} etapi lõpp eemaldatakse. Marsruut jääb samaks ja Võta tagasi taastab selle.',
'tours.stages.toDayConfirm': 'Eemalda etapi lõpud',
'tours.stages.show': 'Näita, kus see etapp lõpeb',
};
export default tours;
+23
View File
@@ -1296,5 +1296,28 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'La sortie n’a pas pu être modifiée',
'tours.stats.unavailable': 'Les sorties réalisées n’ont pas pu être chargées',
'tours.stats.openTrip': 'Ouvrir le voyage',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Jours',
'tours.stages.mode.single': 'Une journée',
'tours.stages.mode.multi': 'Plusieurs jours',
'tours.stages.emptyHint': 'Dans le menu d’un arrêt, choisissez « Terminer l’étape ici » pour découper l’itinéraire.',
'tours.stages.endHere': 'Terminer l’étape ici',
'tours.stages.removeEnd': 'Retirer la fin d’étape',
'tours.stages.stage': 'Étape {number}',
'tours.stages.endOf': 'Fin de l’étape {number}',
'tours.stages.title': 'Étapes',
'tours.stages.count': '{count} étapes',
'tours.stages.count.one': '{count} étape',
'tours.stages.overDay': 'Plus d’une journée en mouvement',
'tours.stages.timeInfo':
'Temps en mouvement par étape, pauses non comprises. Pour le vélo et les balades en ville, le temps du calculateur d’itinéraire est réparti selon la distance.',
'tours.stages.lost': '{count} fins d’étape ont disparu avec leurs arrêts ; ces étapes sont désormais réunies.',
'tours.stages.lost.one': '{count} fin d’étape a disparu avec son arrêt ; ses deux étapes sont désormais réunies.',
'tours.stages.toDayTitle': 'En faire une sortie d’une journée ?',
'tours.stages.toDayBody':
'Les {count} fins d’étape sont retirées. L’itinéraire reste tel quel, et Annuler les rétablit.',
'tours.stages.toDayBody.one': '{count} fin d’étape est retirée. L’itinéraire reste tel quel, et Annuler la rétablit.',
'tours.stages.toDayConfirm': 'Retirer les fins d’étape',
'tours.stages.show': 'Montrer où finit cette étape',
};
export default tours;
+23
View File
@@ -1299,5 +1299,28 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Δεν ήταν δυνατή η αλλαγή της εκδρομής',
'tours.stats.unavailable': 'Δεν ήταν δυνατή η φόρτωση των εκδρομών',
'tours.stats.openTrip': 'Άνοιγμα ταξιδιού',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Ημέρες',
'tours.stages.mode.single': 'Μονοήμερη',
'tours.stages.mode.multi': 'Πολυήμερη',
'tours.stages.emptyHint': 'Στο μενού μιας στάσης επιλέξτε «Τέλος σκέλους εδώ» για να χωρίσετε τη διαδρομή.',
'tours.stages.endHere': 'Τέλος σκέλους εδώ',
'tours.stages.removeEnd': 'Αφαίρεση τέλους σκέλους',
'tours.stages.stage': 'Σκέλος {number}',
'tours.stages.endOf': 'Τέλος σκέλους {number}',
'tours.stages.title': 'Σκέλη',
'tours.stages.count': '{count} σκέλη',
'tours.stages.count.one': '{count} σκέλος',
'tours.stages.overDay': 'Πάνω από μία ημέρα σε κίνηση',
'tours.stages.timeInfo':
'Χρόνος κίνησης ανά σκέλος, χωρίς διαλείμματα. Σε ποδηλατικές διαδρομές και βόλτες στην πόλη ο χρόνος του σχεδιαστή διαδρομών μοιράζεται ανάλογα με την απόσταση.',
'tours.stages.lost': '{count} τέλη σκελών χάθηκαν μαζί με τις στάσεις τους· αυτά τα σκέλη ενώθηκαν.',
'tours.stages.lost.one': '{count} τέλος σκέλους χάθηκε μαζί με τη στάση του· τα δύο σκέλη του ενώθηκαν.',
'tours.stages.toDayTitle': 'Να γίνει μονοήμερη;',
'tours.stages.toDayBody': 'Αφαιρούνται {count} τέλη σκελών. Η διαδρομή μένει ως έχει και η Αναίρεση τα επαναφέρει.',
'tours.stages.toDayBody.one':
'Αφαιρείται {count} τέλος σκέλους. Η διαδρομή μένει ως έχει και η Αναίρεση το επαναφέρει.',
'tours.stages.toDayConfirm': 'Αφαίρεση τελών σκελών',
'tours.stages.show': 'Εμφάνιση του σημείου όπου τελειώνει το σκέλος',
};
export default tours;
+23
View File
@@ -1277,5 +1277,28 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'A túrát nem sikerült módosítani',
'tours.stats.unavailable': 'A teljesített túrákat nem sikerült betölteni',
'tours.stats.openTrip': 'Utazás megnyitása',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Napok',
'tours.stages.mode.single': 'Egynapos',
'tours.stages.mode.multi': 'Többnapos',
'tours.stages.emptyHint': 'Az útvonal felosztásához egy megálló menüjében válaszd a „Szakasz vége itt” lehetőséget.',
'tours.stages.endHere': 'Szakasz vége itt',
'tours.stages.removeEnd': 'Szakaszvég eltávolítása',
'tours.stages.stage': '{number}. szakasz',
'tours.stages.endOf': 'A(z) {number}. szakasz vége',
'tours.stages.title': 'Szakaszok',
'tours.stages.count': '{count} szakasz',
'tours.stages.count.one': '{count} szakasz',
'tours.stages.overDay': 'Több mint egy nap mozgásban',
'tours.stages.timeInfo':
'Mozgási idő szakaszonként, szünetek nélkül. Kerékpártúráknál és városi sétáknál az útvonaltervező idejét táv szerint osztjuk el.',
'tours.stages.lost': '{count} szakaszvég eltűnt a megállóival; ezek a szakaszok most össze vannak vonva.',
'tours.stages.lost.one': '{count} szakaszvég eltűnt a megállójával; a két szakasza most össze van vonva.',
'tours.stages.toDayTitle': 'Legyen egynapos túra?',
'tours.stages.toDayBody':
'{count} szakaszvég törlődik. Az útvonal változatlan marad, a Visszavonás visszahozza őket.',
'tours.stages.toDayBody.one': '{count} szakaszvég törlődik. Az útvonal változatlan marad, a Visszavonás visszahozza.',
'tours.stages.toDayConfirm': 'Szakaszvégek eltávolítása',
'tours.stages.show': 'Mutasd, hol ér véget a szakasz',
};
export default tours;
+19
View File
@@ -1258,5 +1258,24 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Tur tidak dapat diubah',
'tours.stats.unavailable': 'Tur yang sudah dilakukan tidak dapat dimuat',
'tours.stats.openTrip': 'Buka perjalanan',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Hari',
'tours.stages.mode.single': 'Satu hari',
'tours.stages.mode.multi': 'Beberapa hari',
'tours.stages.emptyHint': 'Di menu sebuah perhentian, pilih "Akhiri etape di sini" untuk membagi rute.',
'tours.stages.endHere': 'Akhiri etape di sini',
'tours.stages.removeEnd': 'Hapus akhir etape',
'tours.stages.stage': 'Etape {number}',
'tours.stages.endOf': 'Akhir etape {number}',
'tours.stages.title': 'Etape',
'tours.stages.count': '{count} etape',
'tours.stages.overDay': 'Lebih dari sehari bergerak',
'tours.stages.timeInfo':
'Waktu bergerak per etape, tanpa istirahat. Untuk tur sepeda dan jalan kaki di kota, waktu perencana rute dibagi menurut jarak.',
'tours.stages.lost': '{count} akhir etape hilang bersama perhentiannya; etape-etape itu kini digabung.',
'tours.stages.toDayTitle': 'Jadikan tur sehari?',
'tours.stages.toDayBody': '{count} akhir etape dihapus. Rute tetap seperti semula, dan Urungkan mengembalikannya.',
'tours.stages.toDayConfirm': 'Hapus semua akhir etape',
'tours.stages.show': 'Tunjukkan tempat etape ini berakhir',
};
export default tours;
+22
View File
@@ -1286,5 +1286,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Non è stato possibile modificare l’escursione',
'tours.stats.unavailable': 'Non è stato possibile caricare le escursioni svolte',
'tours.stats.openTrip': 'Apri viaggio',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Giorni',
'tours.stages.mode.single': 'Un giorno',
'tours.stages.mode.multi': 'Più giorni',
'tours.stages.emptyHint': 'Nel menu di una sosta, scegli «Termina la tappa qui» per dividere il percorso.',
'tours.stages.endHere': 'Termina la tappa qui',
'tours.stages.removeEnd': 'Togli fine tappa',
'tours.stages.stage': 'Tappa {number}',
'tours.stages.endOf': 'Fine della tappa {number}',
'tours.stages.title': 'Tappe',
'tours.stages.count': '{count} tappe',
'tours.stages.count.one': '{count} tappa',
'tours.stages.overDay': 'Più di un giorno in movimento',
'tours.stages.timeInfo':
'Tempo in movimento per tappa, pause escluse. Per bici e passeggiate in città il tempo del calcolo percorso è ripartito per distanza.',
'tours.stages.lost': '{count} fini tappa sono sparite con le loro soste; quelle tappe ora sono unite.',
'tours.stages.lost.one': '{count} fine tappa è sparita con la sua sosta; le sue due tappe ora sono unite.',
'tours.stages.toDayTitle': 'Farne un giro di un giorno?',
'tours.stages.toDayBody': 'Le {count} fini tappa vengono tolte. Il percorso resta com’è e Annulla le ripristina.',
'tours.stages.toDayBody.one': '{count} fine tappa viene tolta. Il percorso resta com’è e Annulla la ripristina.',
'tours.stages.toDayConfirm': 'Togli le fini tappa',
'tours.stages.show': 'Mostra dove finisce questa tappa',
};
export default tours;
+18
View File
@@ -1222,5 +1222,23 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'ツアーを変更できませんでした',
'tours.stats.unavailable': 'ツアー実績を読み込めませんでした',
'tours.stats.openTrip': '旅行を開く',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': '日数',
'tours.stages.mode.single': '日帰り',
'tours.stages.mode.multi': '複数日',
'tours.stages.emptyHint': 'ルートを分けるには、立ち寄り地点のメニューで「ここでステージを終える」を選びます。',
'tours.stages.endHere': 'ここでステージを終える',
'tours.stages.removeEnd': 'ステージの終点を外す',
'tours.stages.stage': 'ステージ {number}',
'tours.stages.endOf': 'ステージ {number} の終点',
'tours.stages.title': 'ステージ',
'tours.stages.count': '{count} ステージ',
'tours.stages.overDay': '移動だけで1日を超えます',
'tours.stages.timeInfo': 'ステージごとの移動時間で、休憩は含みません。自転車ツアーと街歩きでは、ルート計算の時間を距離で按分します。',
'tours.stages.lost': '{count} 件のステージ終点が地点とともに消えたため、それらのステージは結合されました。',
'tours.stages.toDayTitle': '日帰りツアーにしますか?',
'tours.stages.toDayBody': '{count} 件のステージ終点を外します。ルートはそのままで、元に戻すで復元できます。',
'tours.stages.toDayConfirm': 'ステージの終点をすべて外す',
'tours.stages.show': 'このステージの終点を表示',
};
export default tours;
+18
View File
@@ -1220,5 +1220,23 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': '투어를 변경할 수 없습니다',
'tours.stats.unavailable': '투어 기록을 불러올 수 없습니다',
'tours.stats.openTrip': '여행 열기',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': '일수',
'tours.stages.mode.single': '당일',
'tours.stages.mode.multi': '여러 날',
'tours.stages.emptyHint': '경로를 나누려면 정류 지점 메뉴에서 "여기서 구간 종료"를 선택하세요.',
'tours.stages.endHere': '여기서 구간 종료',
'tours.stages.removeEnd': '구간 끝 제거',
'tours.stages.stage': '{number}구간',
'tours.stages.endOf': '{number}구간 끝',
'tours.stages.title': '구간',
'tours.stages.count': '{count}개 구간',
'tours.stages.overDay': '이동만 하루 이상',
'tours.stages.timeInfo': '구간별 이동 시간이며 휴식은 포함하지 않습니다. 자전거 투어와 도시 산책은 경로 계산 시간을 거리에 따라 나눕니다.',
'tours.stages.lost': '구간 끝 {count}개가 지점과 함께 사라져 해당 구간이 합쳐졌습니다.',
'tours.stages.toDayTitle': '당일 투어로 바꿀까요?',
'tours.stages.toDayBody': '구간 끝 {count}개를 제거합니다. 경로는 그대로이며 실행 취소로 되돌릴 수 있습니다.',
'tours.stages.toDayConfirm': '구간 끝 모두 제거',
'tours.stages.show': '이 구간이 끝나는 곳 보기',
};
export default tours;
+24
View File
@@ -1280,5 +1280,29 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'De tocht kon niet worden gewijzigd',
'tours.stats.unavailable': 'Gemaakte tochten konden niet worden geladen',
'tours.stats.openTrip': 'Reis openen',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dagen',
'tours.stages.mode.single': 'Dagtocht',
'tours.stages.mode.multi': 'Meerdaagse tocht',
'tours.stages.emptyHint': 'Kies in het menu van een stop „Etappe hier beëindigen” om de route te verdelen.',
'tours.stages.endHere': 'Etappe hier beëindigen',
'tours.stages.removeEnd': 'Etappe-einde verwijderen',
'tours.stages.stage': 'Etappe {number}',
'tours.stages.endOf': 'Einde van etappe {number}',
'tours.stages.title': 'Etappes',
'tours.stages.count': '{count} etappes',
'tours.stages.count.one': '{count} etappe',
'tours.stages.overDay': 'Meer dan een dag onderweg',
'tours.stages.timeInfo':
'Bewegingstijd per etappe, zonder pauzes. Bij fietstochten en stadswandelingen wordt de tijd van de routeplanner naar afstand verdeeld.',
'tours.stages.lost': '{count} etappe-eindes zijn met hun stops verdwenen; die etappes zijn nu samengevoegd.',
'tours.stages.lost.one': '{count} etappe-einde is met zijn stop verdwenen; zijn twee etappes zijn nu samengevoegd.',
'tours.stages.toDayTitle': 'Er een dagtocht van maken?',
'tours.stages.toDayBody':
'De {count} etappe-eindes worden verwijderd. De route blijft zoals hij is en Ongedaan maken zet ze terug.',
'tours.stages.toDayBody.one':
'{count} etappe-einde wordt verwijderd. De route blijft zoals hij is en Ongedaan maken zet het terug.',
'tours.stages.toDayConfirm': 'Etappe-eindes verwijderen',
'tours.stages.show': 'Toon waar deze etappe eindigt',
};
export default tours;
+28
View File
@@ -1300,5 +1300,33 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Nie udało się zmienić wycieczki',
'tours.stats.unavailable': 'Nie udało się wczytać odbytych wycieczek',
'tours.stats.openTrip': 'Otwórz podróż',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dni',
'tours.stages.mode.single': 'Jednodniowa',
'tours.stages.mode.multi': 'Wielodniowa',
'tours.stages.emptyHint': 'W menu przystanku wybierz „Zakończ etap tutaj”, aby podzielić trasę.',
'tours.stages.endHere': 'Zakończ etap tutaj',
'tours.stages.removeEnd': 'Usuń koniec etapu',
'tours.stages.stage': 'Etap {number}',
'tours.stages.endOf': 'Koniec etapu {number}',
'tours.stages.title': 'Etapy',
'tours.stages.count': '{count} etapu',
'tours.stages.count.one': '{count} etap',
'tours.stages.count.few': '{count} etapy',
'tours.stages.count.many': '{count} etapów',
'tours.stages.overDay': 'Ponad dzień w ruchu',
'tours.stages.timeInfo':
'Czas w ruchu na etap, bez przerw. Przy wycieczkach rowerowych i spacerach po mieście czas planera tras dzieli się według dystansu.',
'tours.stages.lost': '{count} końca etapu zniknęło z przystankami; te etapy są teraz połączone.',
'tours.stages.lost.one': '{count} koniec etapu zniknął ze swoim przystankiem; jego dwa etapy są teraz połączone.',
'tours.stages.lost.few': '{count} końce etapów zniknęły z przystankami; te etapy są teraz połączone.',
'tours.stages.lost.many': '{count} końców etapów zniknęło z przystankami; te etapy są teraz połączone.',
'tours.stages.toDayTitle': 'Zmienić na wycieczkę jednodniową?',
'tours.stages.toDayBody': 'Usuwamy {count} końca etapu. Trasa zostaje bez zmian, a Cofnij je przywróci.',
'tours.stages.toDayBody.one': 'Usuwamy {count} koniec etapu. Trasa zostaje bez zmian, a Cofnij go przywróci.',
'tours.stages.toDayBody.few': 'Usuwamy {count} końce etapów. Trasa zostaje bez zmian, a Cofnij je przywróci.',
'tours.stages.toDayBody.many': 'Usuwamy {count} końców etapów. Trasa zostaje bez zmian, a Cofnij je przywróci.',
'tours.stages.toDayConfirm': 'Usuń końce etapów',
'tours.stages.show': 'Pokaż, gdzie kończy się ten etap',
};
export default tours;
+28
View File
@@ -1307,5 +1307,33 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Не удалось изменить тур',
'tours.stats.unavailable': 'Не удалось загрузить пройденные туры',
'tours.stats.openTrip': 'Открыть поездку',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Дни',
'tours.stages.mode.single': 'Однодневный',
'tours.stages.mode.multi': 'Многодневный',
'tours.stages.emptyHint': 'В меню остановки выберите «Завершить этап здесь», чтобы разделить маршрут.',
'tours.stages.endHere': 'Завершить этап здесь',
'tours.stages.removeEnd': 'Убрать конец этапа',
'tours.stages.stage': 'Этап {number}',
'tours.stages.endOf': 'Конец этапа {number}',
'tours.stages.title': 'Этапы',
'tours.stages.count': '{count} этапа',
'tours.stages.count.one': '{count} этап',
'tours.stages.count.few': '{count} этапа',
'tours.stages.count.many': '{count} этапов',
'tours.stages.overDay': 'Больше дня в пути',
'tours.stages.timeInfo':
'Время в движении на этап, без перерывов. Для велотуров и городских прогулок время маршрутизатора делится по расстоянию.',
'tours.stages.lost': '{count} конца этапа пропало вместе с остановками; эти этапы теперь объединены.',
'tours.stages.lost.one': '{count} конец этапа пропал вместе с остановкой; два его этапа теперь объединены.',
'tours.stages.lost.few': '{count} конца этапов пропали вместе с остановками; эти этапы теперь объединены.',
'tours.stages.lost.many': '{count} концов этапов пропали вместе с остановками; эти этапы теперь объединены.',
'tours.stages.toDayTitle': 'Сделать однодневным?',
'tours.stages.toDayBody': 'Будет убрано {count} конца этапа. Маршрут останется прежним, а Отменить вернёт их.',
'tours.stages.toDayBody.one': 'Будет убран {count} конец этапа. Маршрут останется прежним, а Отменить вернёт его.',
'tours.stages.toDayBody.few': 'Будут убраны {count} конца этапов. Маршрут останется прежним, а Отменить вернёт их.',
'tours.stages.toDayBody.many': 'Будут убраны {count} концов этапов. Маршрут останется прежним, а Отменить вернёт их.',
'tours.stages.toDayConfirm': 'Убрать концы этапов',
'tours.stages.show': 'Показать, где заканчивается этап',
};
export default tours;
+25
View File
@@ -1262,5 +1262,30 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Túru sa nepodarilo zmeniť',
'tours.stats.unavailable': 'Absolvované túry sa nepodarilo načítať',
'tours.stats.openTrip': 'Otvoriť cestu',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dni',
'tours.stages.mode.single': 'Jednodňová',
'tours.stages.mode.multi': 'Viacdňová',
'tours.stages.emptyHint': 'V ponuke zastávky zvoľte „Ukončiť etapu tu“ a rozdeľte trasu.',
'tours.stages.endHere': 'Ukončiť etapu tu',
'tours.stages.removeEnd': 'Odobrať koniec etapy',
'tours.stages.stage': 'Etapa {number}',
'tours.stages.endOf': 'Koniec etapy {number}',
'tours.stages.title': 'Etapy',
'tours.stages.count': '{count} etáp',
'tours.stages.count.one': '{count} etapa',
'tours.stages.count.few': '{count} etapy',
'tours.stages.overDay': 'Viac ako deň v pohybe',
'tours.stages.timeInfo':
'Čas v pohybe na etapu, bez prestávok. Pri cyklotúrach a mestských prechádzkach sa čas plánovača trás delí podľa vzdialenosti.',
'tours.stages.lost': '{count} koncov etáp zmizlo so zastávkami; tieto etapy sú teraz spojené.',
'tours.stages.lost.one': '{count} koniec etapy zmizol so zastávkou; jeho dve etapy sú teraz spojené.',
'tours.stages.lost.few': '{count} konce etáp zmizli so zastávkami; tieto etapy sú teraz spojené.',
'tours.stages.toDayTitle': 'Urobiť z nej jednodňovú túru?',
'tours.stages.toDayBody': 'Odoberie sa {count} koncov etáp. Trasa zostane, ako je, a Späť ich vráti.',
'tours.stages.toDayBody.one': 'Odoberie sa {count} koniec etapy. Trasa zostane, ako je, a Späť ho vráti.',
'tours.stages.toDayBody.few': 'Odoberú sa {count} konce etáp. Trasa zostane, ako je, a Späť ich vráti.',
'tours.stages.toDayConfirm': 'Odobrať konce etáp',
'tours.stages.show': 'Ukázať, kde etapa končí',
};
export default tours;
+22
View File
@@ -1251,5 +1251,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Turen kunde inte ändras',
'tours.stats.unavailable': 'Genomförda turer kunde inte laddas',
'tours.stats.openTrip': 'Öppna resan',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Dagar',
'tours.stages.mode.single': 'Dagstur',
'tours.stages.mode.multi': 'Flerdagarstur',
'tours.stages.emptyHint': 'Välj ”Avsluta etappen här” i menyn för ett stopp för att dela upp rutten.',
'tours.stages.endHere': 'Avsluta etappen här',
'tours.stages.removeEnd': 'Ta bort etappslut',
'tours.stages.stage': 'Etapp {number}',
'tours.stages.endOf': 'Slutet på etapp {number}',
'tours.stages.title': 'Etapper',
'tours.stages.count': '{count} etapper',
'tours.stages.count.one': '{count} etapp',
'tours.stages.overDay': 'Mer än en dag i rörelse',
'tours.stages.timeInfo':
'Rörelsetid per etapp, utan pauser. För cykelturer och stadspromenader fördelas ruttplanerarens tid efter sträcka.',
'tours.stages.lost': '{count} etappslut försvann med sina stopp; de etapperna är nu sammanslagna.',
'tours.stages.lost.one': '{count} etappslut försvann med sitt stopp; dess två etapper är nu sammanslagna.',
'tours.stages.toDayTitle': 'Göra det till en dagstur?',
'tours.stages.toDayBody': 'De {count} etappsluten tas bort. Rutten förblir som den är, och Ångra tar tillbaka dem.',
'tours.stages.toDayBody.one': '{count} etappslut tas bort. Rutten förblir som den är, och Ångra tar tillbaka det.',
'tours.stages.toDayConfirm': 'Ta bort etappsluten',
'tours.stages.show': 'Visa var etappen slutar',
};
export default tours;
+19
View File
@@ -1203,5 +1203,24 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'เปลี่ยนทัวร์ไม่ได้',
'tours.stats.unavailable': 'โหลดทัวร์ที่ทำแล้วไม่ได้',
'tours.stats.openTrip': 'เปิดทริป',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'จำนวนวัน',
'tours.stages.mode.single': 'วันเดียว',
'tours.stages.mode.multi': 'หลายวัน',
'tours.stages.emptyHint': 'หากต้องการแบ่งเส้นทาง ให้เปิดเมนูของจุดแวะแล้วเลือก "จบช่วงที่นี่"',
'tours.stages.endHere': 'จบช่วงที่นี่',
'tours.stages.removeEnd': 'นำจุดจบช่วงออก',
'tours.stages.stage': 'ช่วงที่ {number}',
'tours.stages.endOf': 'จุดจบของช่วงที่ {number}',
'tours.stages.title': 'ช่วงการเดินทาง',
'tours.stages.count': '{count} ช่วง',
'tours.stages.overDay': 'เคลื่อนที่นานกว่าหนึ่งวัน',
'tours.stages.timeInfo':
'เวลาเคลื่อนที่ของแต่ละช่วง ไม่รวมเวลาพัก สำหรับทริปจักรยานและการเดินในเมือง เวลาของตัววางเส้นทางจะแบ่งตามระยะทาง',
'tours.stages.lost': 'จุดจบช่วง {count} จุดหายไปพร้อมจุดแวะ ช่วงเหล่านั้นจึงถูกรวมกันแล้ว',
'tours.stages.toDayTitle': 'เปลี่ยนเป็นทริปวันเดียวไหม',
'tours.stages.toDayBody': 'จะนำจุดจบช่วง {count} จุดออก เส้นทางยังคงเหมือนเดิม และเลิกทำจะนำกลับมาได้',
'tours.stages.toDayConfirm': 'นำจุดจบช่วงทั้งหมดออก',
'tours.stages.show': 'แสดงตำแหน่งที่ช่วงนี้จบ',
};
export default tours;
+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 = 1144;
const GENERAL_KEY_COUNT = 1161;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+22
View File
@@ -1258,5 +1258,27 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Tur değiştirilemedi',
'tours.stats.unavailable': 'Tamamlanan turlar yüklenemedi',
'tours.stats.openTrip': 'Seyahati aç',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Günler',
'tours.stages.mode.single': 'Günübirlik',
'tours.stages.mode.multi': 'Çok günlü',
'tours.stages.emptyHint': 'Rotayı bölmek için bir durağın menüsünde "Etabı burada bitir" seçeneğini seçin.',
'tours.stages.endHere': 'Etabı burada bitir',
'tours.stages.removeEnd': 'Etap sonunu kaldır',
'tours.stages.stage': 'Etap {number}',
'tours.stages.endOf': 'Etap {number} sonu',
'tours.stages.title': 'Etaplar',
'tours.stages.count': '{count} etap',
'tours.stages.count.one': '{count} etap',
'tours.stages.overDay': 'Bir günden fazla hareket',
'tours.stages.timeInfo':
'Etap başına hareket süresi, molalar hariç. Bisiklet turlarında ve şehir yürüyüşlerinde rota planlayıcısının süresi mesafeye göre bölünür.',
'tours.stages.lost': '{count} etap sonu durakları ile birlikte kayboldu; bu etaplar artık birleşik.',
'tours.stages.lost.one': '{count} etap sonu durağı ile birlikte kayboldu; iki etabı artık birleşik.',
'tours.stages.toDayTitle': 'Günübirlik tura çevrilsin mi?',
'tours.stages.toDayBody': '{count} etap sonu kaldırılır. Rota olduğu gibi kalır, Geri al bunları geri getirir.',
'tours.stages.toDayBody.one': '{count} etap sonu kaldırılır. Rota olduğu gibi kalır, Geri al onu geri getirir.',
'tours.stages.toDayConfirm': 'Etap sonlarını kaldır',
'tours.stages.show': 'Bu etabın bittiği yeri göster',
};
export default tours;
+28
View File
@@ -1293,5 +1293,33 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Не вдалося змінити тур',
'tours.stats.unavailable': 'Не вдалося завантажити пройдені тури',
'tours.stats.openTrip': 'Відкрити подорож',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Дні',
'tours.stages.mode.single': 'Одноденний',
'tours.stages.mode.multi': 'Багатоденний',
'tours.stages.emptyHint': 'У меню зупинки виберіть «Завершити етап тут», щоб розділити маршрут.',
'tours.stages.endHere': 'Завершити етап тут',
'tours.stages.removeEnd': 'Прибрати кінець етапу',
'tours.stages.stage': 'Етап {number}',
'tours.stages.endOf': 'Кінець етапу {number}',
'tours.stages.title': 'Етапи',
'tours.stages.count': '{count} етапу',
'tours.stages.count.one': '{count} етап',
'tours.stages.count.few': '{count} етапи',
'tours.stages.count.many': '{count} етапів',
'tours.stages.overDay': 'Понад день у русі',
'tours.stages.timeInfo':
'Час у русі на етап, без перерв. Для велотурів і міських прогулянок час маршрутизатора ділиться за відстанню.',
'tours.stages.lost': '{count} кінця етапу зникло разом із зупинками; ці етапи тепер об’єднано.',
'tours.stages.lost.one': '{count} кінець етапу зник разом із зупинкою; два його етапи тепер об’єднано.',
'tours.stages.lost.few': '{count} кінці етапів зникли разом із зупинками; ці етапи тепер об’єднано.',
'tours.stages.lost.many': '{count} кінців етапів зникли разом із зупинками; ці етапи тепер об’єднано.',
'tours.stages.toDayTitle': 'Зробити одноденним?',
'tours.stages.toDayBody': 'Буде прибрано {count} кінця етапу. Маршрут лишиться як є, а Скасувати поверне їх.',
'tours.stages.toDayBody.one': 'Буде прибрано {count} кінець етапу. Маршрут лишиться як є, а Скасувати поверне його.',
'tours.stages.toDayBody.few': 'Буде прибрано {count} кінці етапів. Маршрут лишиться як є, а Скасувати поверне їх.',
'tours.stages.toDayBody.many': 'Буде прибрано {count} кінців етапів. Маршрут лишиться як є, а Скасувати поверне їх.',
'tours.stages.toDayConfirm': 'Прибрати кінці етапів',
'tours.stages.show': 'Показати, де закінчується етап',
};
export default tours;
+19
View File
@@ -1247,5 +1247,24 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': 'Không thể thay đổi chuyến',
'tours.stats.unavailable': 'Không thể tải các chuyến đã đi',
'tours.stats.openTrip': 'Mở chuyến đi',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': 'Số ngày',
'tours.stages.mode.single': 'Một ngày',
'tours.stages.mode.multi': 'Nhiều ngày',
'tours.stages.emptyHint': 'Để chia tuyến, mở menu của một điểm dừng và chọn "Kết thúc chặng tại đây".',
'tours.stages.endHere': 'Kết thúc chặng tại đây',
'tours.stages.removeEnd': 'Bỏ điểm cuối chặng',
'tours.stages.stage': 'Chặng {number}',
'tours.stages.endOf': 'Cuối chặng {number}',
'tours.stages.title': 'Các chặng',
'tours.stages.count': '{count} chặng',
'tours.stages.overDay': 'Di chuyển hơn một ngày',
'tours.stages.timeInfo':
'Thời gian di chuyển của từng chặng, không tính nghỉ. Với tour xe đạp và dạo phố, thời gian của bộ định tuyến được chia theo quãng đường.',
'tours.stages.lost': '{count} điểm cuối chặng đã mất cùng điểm dừng; các chặng đó giờ được gộp lại.',
'tours.stages.toDayTitle': 'Chuyển thành tour một ngày?',
'tours.stages.toDayBody': 'Bỏ {count} điểm cuối chặng. Tuyến đường giữ nguyên, và Hoàn tác sẽ khôi phục chúng.',
'tours.stages.toDayConfirm': 'Bỏ các điểm cuối chặng',
'tours.stages.show': 'Hiện nơi chặng này kết thúc',
};
export default tours;
+18
View File
@@ -1163,5 +1163,23 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': '無法變更遊程',
'tours.stats.unavailable': '無法載入遊程紀錄',
'tours.stats.openTrip': '開啟行程',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': '天數',
'tours.stages.mode.single': '一日遊',
'tours.stages.mode.multi': '多日',
'tours.stages.emptyHint': '要拆分路線,請在停留點的選單中選擇「在此結束階段」。',
'tours.stages.endHere': '在此結束階段',
'tours.stages.removeEnd': '移除階段終點',
'tours.stages.stage': '第 {number} 階段',
'tours.stages.endOf': '第 {number} 階段終點',
'tours.stages.title': '階段',
'tours.stages.count': '{count} 個階段',
'tours.stages.overDay': '移動時間超過一天',
'tours.stages.timeInfo': '每個階段的移動時間,不含休息。單車與城市散步依距離分攤路線規劃的時間。',
'tours.stages.lost': '{count} 個階段終點隨停留點一起消失,相關階段已合併。',
'tours.stages.toDayTitle': '改為一日遊?',
'tours.stages.toDayBody': '將移除 {count} 個階段終點。路線保持不變,復原可以還原。',
'tours.stages.toDayConfirm': '移除全部階段終點',
'tours.stages.show': '顯示此階段的終點',
};
export default tours;
+18
View File
@@ -1164,5 +1164,23 @@ const tours: TranslationStrings = {
'tours.stats.excludeFailed': '无法更改游览',
'tours.stats.unavailable': '无法加载游览记录',
'tours.stats.openTrip': '打开行程',
// MDT-01: a multi-day tour, one route split into stages.
'tours.stages.mode': '天数',
'tours.stages.mode.single': '一日游',
'tours.stages.mode.multi': '多日',
'tours.stages.emptyHint': '要拆分路线,请在停留点的菜单中选择“在此结束阶段”。',
'tours.stages.endHere': '在此结束阶段',
'tours.stages.removeEnd': '移除阶段终点',
'tours.stages.stage': '第 {number} 阶段',
'tours.stages.endOf': '第 {number} 阶段终点',
'tours.stages.title': '阶段',
'tours.stages.count': '{count} 个阶段',
'tours.stages.overDay': '移动时间超过一天',
'tours.stages.timeInfo': '每个阶段的移动时间,不含休息。骑行和城市漫步按距离分摊路线规划的时间。',
'tours.stages.lost': '{count} 个阶段终点随停留点一起消失,相关阶段已合并。',
'tours.stages.toDayTitle': '改为一日游?',
'tours.stages.toDayBody': '将移除 {count} 个阶段终点。路线保持不变,撤销可以恢复。',
'tours.stages.toDayConfirm': '移除全部阶段终点',
'tours.stages.show': '显示此阶段的终点',
};
export default tours;
+1
View File
@@ -57,6 +57,7 @@ export * from './place/track-colors';
export * from './tours/tours.schema';
export * from './tours/run-pace';
export * from './tours/ski.schema';
export * from './tours/tour-stages';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-activity.schema';
export * from './tours/tour-activity-stats.schema';
+135
View File
@@ -0,0 +1,135 @@
import { MAX_TOUR_STAGES, maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages';
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTimeLimitMessage,
tourCreateRequestSchema,
tourListItemSchema,
tourWaypointSchema,
} from './tours.schema';
import { describe, expect, it } from 'vitest';
type Role = 'start' | 'via' | 'end';
/** A straight run of points along a meridian, each a stop unless told otherwise. */
function points(count: number, extra: (index: number) => Record<string, unknown> = () => ({})) {
return Array.from({ length: count }, (_, sequence) => ({
lat: 47 + sequence * 0.01,
lng: 11,
role: (sequence === 0 ? 'start' : sequence === count - 1 ? 'end' : 'via') as Role,
sequence,
...extra(sequence),
}));
}
function request(waypoints: ReturnType<typeof points>, fields: Record<string, unknown> = {}) {
return {
name: 'Hut to hut',
route_geometry: waypoints.map((point) => [point.lat, point.lng, 1000]),
waypoints,
...fields,
};
}
describe('MDT-01 tour stages', () => {
it('MDT-SH-001: counts one stage for a day tour and one more for every stage end', () => {
expect(tourStageCountOf([])).toBe(1);
expect(tourStageCountOf(points(3))).toBe(1);
expect(tourStageCountOf(points(5, (i) => (i === 1 || i === 3 ? { stage_end: true } : {})))).toBe(3);
expect(tourStageCountOf([{ stage_end: false }])).toBe(1);
});
it('MDT-SH-002: plans a day a stage, and never less than one day', () => {
expect(TOUR_DAY_MINUTES).toBe(MAX_PLANNED_TOUR_DURATION_MINUTES);
expect(maxTourMinutesFor(1)).toBe(1440);
expect(maxTourMinutesFor(3)).toBe(3 * 1440);
expect(maxTourMinutesFor(0)).toBe(1440);
});
it('MDT-SH-003: takes a stage end at a stop between start and end only', () => {
expect(tourStageIssues(points(4, (i) => (i === 1 ? { stage_end: true } : {})))).toEqual([]);
expect(tourStageIssues(points(3, (i) => (i === 0 ? { stage_end: true } : {})))).toEqual([
{ path: ['waypoints', 0, 'stage_end'], message: 'a stage ends at a stop between start and end' },
]);
expect(tourStageIssues(points(3, (i) => (i === 2 ? { stage_end: true } : {})))[0]?.path).toEqual([
'waypoints',
2,
'stage_end',
]);
expect(tourStageIssues(points(3, (i) => (i === 1 ? { stage_end: true, shaping: true } : {})))).toEqual([
{ path: ['waypoints', 1, 'stage_end'], message: 'a shaping point ends no stage' },
]);
});
it('MDT-SH-004: refuses a tour of more stages than the limit', () => {
const many = points(MAX_TOUR_STAGES + 1, () => ({ stage_end: true })).map((point, index, all) =>
index === 0 || index === all.length - 1 ? { ...point, stage_end: undefined } : point,
);
expect(tourStageCountOf(many)).toBe(MAX_TOUR_STAGES);
expect(tourStageIssues(many)).toEqual([]);
const tooMany = points(MAX_TOUR_STAGES + 2, (i) => (i > 0 ? { stage_end: true } : {})).map((point, index, all) =>
index === all.length - 1 ? { ...point, stage_end: undefined } : point,
);
expect(tourStageIssues(tooMany)).toContainEqual({
path: ['waypoints'],
message: `a tour has at most ${MAX_TOUR_STAGES} stages`,
});
});
it('MDT-SH-005: a waypoint carries its stage end, and one saved before reads as none', () => {
expect(tourWaypointSchema.parse({ lat: 47, lng: 11, role: 'via', sequence: 1, stage_end: true })).toMatchObject({
stage_end: true,
});
expect(tourWaypointSchema.parse({ lat: 47, lng: 11, role: 'via', sequence: 1 })).not.toHaveProperty('stage_end');
expect(tourWaypointSchema.safeParse({ lat: 47, lng: 11, role: 'via', sequence: 1, stage_end: 'yes' }).success).toBe(
false,
);
});
it('MDT-SH-006: saves a multi-day tour of any one type, and refuses a stage end out of place', () => {
const hutToHut = points(4, (i) => (i === 1 ? { stage_end: true } : {}));
expect(tourCreateRequestSchema.safeParse(request(hutToHut)).success).toBe(true);
const paddle = request(hutToHut, { tour_type: 'kayak', boat_type: 'canoe', water_context: 'river' });
expect(tourCreateRequestSchema.safeParse(paddle).success).toBe(true);
const atEnd = tourCreateRequestSchema.safeParse(request(points(3, (i) => (i === 2 ? { stage_end: true } : {}))));
expect(atEnd.success).toBe(false);
expect(atEnd.error?.issues.map((issue) => issue.path.join('.'))).toContain('waypoints.2.stage_end');
});
it('MDT-SH-007: holds a multi-day tour to a day a stage, a day tour to one day', () => {
const twoDays = points(3, (i) => (i === 1 ? { stage_end: true } : {}));
const long = { duration_seconds: 30 * 3600, break_additional_minutes: 60 };
expect(tourCreateRequestSchema.safeParse(request(twoDays, long)).success).toBe(true);
const tooLong = tourCreateRequestSchema.safeParse(request(twoDays, { ...long, duration_seconds: 48 * 3600 }));
expect(tooLong.success).toBe(false);
expect(tooLong.error?.issues[0]?.message).toBe(plannedTimeLimitMessage(2));
const dayTour = tourCreateRequestSchema.safeParse(request(points(3), long));
expect(dayTour.success).toBe(false);
expect(dayTour.error?.issues[0]?.message).toBe(
'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
);
expect(plannedTimeLimitMessage(2)).toContain('2880 minutes or less, a day a stage');
});
it('MDT-SH-008: lists the stage count of a multi-day tour, and reads a list cached before it', () => {
const item = {
place_id: 1,
tour_type: 'hike',
distance: 12,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
name: 'Hut to hut',
planned: false,
caution: false,
};
expect(tourListItemSchema.parse({ ...item, stage_count: 3 }).stage_count).toBe(3);
expect(tourListItemSchema.parse({ ...item, stage_count: null }).stage_count).toBeNull();
expect(tourListItemSchema.parse(item).stage_count).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...item, stage_count: 1 }).success).toBe(false);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* MDT-01: a multi-day tour is one tour, planned whole, whose stops mark where
* each day's stage ends. The stages are read off the route in its order: the
* first runs from the start to the first stage end, the next from there on,
* the last to the end of the tour. Nothing about a stage is stored besides the
* mark on its last stop, so the whole route and its stages cannot drift apart.
* A tour without a mark is a day tour, as every tour saved before was.
*/
/** A day in minutes: the most one stage, and a day tour, is planned for. */
export const TOUR_DAY_MINUTES = 24 * 60;
/** The most stages a tour is split into, two months of days. */
export const MAX_TOUR_STAGES = 60;
/** A point as a stage end is read from: where it sits on the tour, and whether it steers only. */
interface StagePoint {
role: 'start' | 'via' | 'end';
shaping?: boolean;
stage_end?: boolean;
}
/** Where a request's stage ends do not hold, and why. */
interface StageIssue {
path: Array<string | number>;
message: string;
}
/** How many stages the points make: one more than their stage ends, one for a day tour. */
export function tourStageCountOf(points: ReadonlyArray<{ stage_end?: boolean }>): number {
return 1 + points.filter((point) => point.stage_end === true).length;
}
/** The longest moving time plus breaks a tour of so many stages is planned for: a day each. */
export function maxTourMinutesFor(stageCount: number): number {
return Math.max(1, stageCount) * TOUR_DAY_MINUTES;
}
/**
* Where a stage end may not sit: at the start or the end of the tour, which
* begin and finish the first and the last stage anyway, and on a shaping
* point, which is no stop to spend a night at. Beyond {@link MAX_TOUR_STAGES}
* the tour is refused as a whole.
*/
export function tourStageIssues(points: readonly StagePoint[]): StageIssue[] {
const issues: StageIssue[] = [];
points.forEach((point, index) => {
if (point.stage_end !== true) return;
if (point.role !== 'via')
issues.push({ path: ['waypoints', index, 'stage_end'], message: 'a stage ends at a stop between start and end' });
else if (point.shaping === true)
issues.push({ path: ['waypoints', index, 'stage_end'], message: 'a shaping point ends no stage' });
});
if (tourStageCountOf(points) > MAX_TOUR_STAGES)
issues.push({ path: ['waypoints'], message: `a tour has at most ${MAX_TOUR_STAGES} stages` });
return issues;
}
+32 -8
View File
@@ -11,19 +11,21 @@ import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema';
import { tourActivityRefSchema, tourActivitySchema } from './tour-activity.schema';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
import { maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages';
import { z } from 'zod';
/**
* Tours contracts — the generalisation of "hike" into a domain of self-contained,
* single-day, non-motorized activities that follow a track (hike, bike, run, ski,
* kayak, city walk, food tour, ...). Tour types use a controlled key list.
* non-motorized activities that follow a track (hike, bike, run, ski, kayak,
* city walk, food tour, ...), a day each or (MDT-01) one route split into
* stages over several days. Tour types use a controlled key list.
*
* Isolated bounded context:
* no columns on `places`/`days`/`users`. A tour is a Place (route_geometry reused
* as-is) plus a day_assignments row (reused as-is, `assignPlaceToDay`) plus this
* facet table keyed by place_id. Boundary vs. Road-Trip: Tours = non-motorized +
* single-day, Road-Trip = motorized + multi-day.
* facet table keyed by place_id. Boundary vs. Road-Trip: Tours = non-motorized,
* Road-Trip = motorized.
*/
export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak', 'walk', 'food', 'horse']);
@@ -136,8 +138,8 @@ export function hikingPreferencesOf(value: HikingPreferences | null | undefined)
return kept.length === 0 ? null : Object.fromEntries(kept.map((key) => [key, true]));
}
/** Tours are single-day activities, so a planned total is bounded to 24 hours. */
export const MAX_PLANNED_TOUR_DURATION_MINUTES = 24 * 60;
/** A planned total is bounded to a day; a multi-day tour's moving time to a day a stage (MDT-01). */
export const MAX_PLANNED_TOUR_DURATION_MINUTES = TOUR_DAY_MINUTES;
export const plannedTourDurationMinutesSchema = z
.number()
.int()
@@ -305,6 +307,11 @@ export const tourListItemSchema = tourSchema.extend({
activity_tracks: z.array(tourActivityRefSchema).optional(),
/** ACT-01: the tour this recording documents, when it is assigned to one; null otherwise. */
activity_of: z.number().int().positive().nullable().optional(),
/**
* MDT-01: the stages a multi-day tour's stops split it into; null for a day
* tour. Absent in a list cached before it existed.
*/
stage_count: z.number().int().min(2).nullable().optional(),
});
export type TourListItem = z.infer<typeof tourListItemSchema>;
@@ -448,6 +455,11 @@ export const tourWaypointSchema = z.object({
* map drew the place. Null on a point set by hand.
*/
poi_category: tourPoiCategorySchema.nullable().optional(),
/**
* MDT-01: a stop where a stage of a multi-day tour ends and the next one
* begins. Absent means the route goes on, as on every point saved before.
*/
stage_end: z.boolean().optional(),
});
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
@@ -511,15 +523,17 @@ export const tourCreateRequestSchema = z
value.planned_duration_minutes == null &&
value.duration_seconds != null &&
value.break_additional_minutes != null &&
Math.round(value.duration_seconds / 60) + value.break_additional_minutes > MAX_PLANNED_TOUR_DURATION_MINUTES
Math.round(value.duration_seconds / 60) + value.break_additional_minutes >
maxTourMinutesFor(tourStageCountOf(value.waypoints))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['break_additional_minutes'],
message: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
message: plannedTimeLimitMessage(tourStageCountOf(value.waypoints)),
});
}
tourTypeIssues(value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }));
tourStageIssues(value.waypoints).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }));
value.waypoints.forEach((point, index) => {
const leg = point.fixed_leg;
const next = value.waypoints[index + 1];
@@ -572,6 +586,16 @@ const TYPE_NAMED: Record<PlannableTourType, string> = {
horse: 'a horse tour',
};
/**
* What a save over its time limit is told: a day for a day tour, as it always
* was, and a day a stage for a multi-day tour (MDT-01).
*/
export function plannedTimeLimitMessage(stageCount: number): string {
return stageCount > 1
? `walking time plus breaks must be ${maxTourMinutesFor(stageCount)} minutes or less, a day a stage, unless a total is overridden`
: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden';
}
const PADDLE_FIELDS = [
'boat_type',
'water_context',
+2 -2
View File
@@ -237,8 +237,8 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| Tool | Description | Scope |
|---|---|---|
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` |
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` |
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), for a multi-day tour how many stages its route is split into (`stage_count`, `null` for a tour of one day), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` |
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` |
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |