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();