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 && (