fix(tours): keep a lost stage end visible on paddle tours

A stage end set on a lock or portage point the water router placed left
the point automatic, so the router could drop it with its leg later,
silently and without an undo step. Ending a stage there now makes the
point the paddler's own, like any other edit of it.

The notice for stage ends a change could not keep also vanished as soon
as the router laid its points in again after the change. It now stays
until the next change the user makes, an undo or redo, a save, or
another draft.
This commit is contained in:
Maurice
2026-10-20 12:44:56 +02:00
parent 2449616ee9
commit 6ec06c90f4
4 changed files with 74 additions and 8 deletions
@@ -1,4 +1,4 @@
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-011: a paddle tour's route put together from the water router's legs (PAD-11).
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
@@ -191,6 +191,18 @@ describe('automatic points', () => {
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
});
it('FE-PADDLE-ROUTING-011: an automatic point made a stage end belongs to the paddler and stays when its leg changes', () => {
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
const marked = adoptChangedPoints(
points,
points.map((point) => (point.id === 'auto-3' ? { ...point, stageEnd: true } : point))
);
expect(marked.find((point) => point.id === 'auto-3')).toMatchObject({ stageEnd: true });
expect(marked.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
const moved = marked.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'auto-3', 'b', 'c']);
});
});
describe('planPaddleStep', () => {
@@ -28,6 +28,7 @@ export interface PaddlePoint {
fixedLeg?: TourFixedLeg | null;
lock?: PaddleLock | null;
shaping?: boolean;
stageEnd?: boolean;
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
auto?: AutoBase;
}
@@ -224,7 +225,11 @@ export function materializePaddleLegs<T extends PaddlePoint>(
return result;
}
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
/**
* A point the paddler changed is theirs from then on: it loses its automatic
* mark. Ending a stage there counts as a change (MDT-01), so the router never
* drops a stage end with the leg around it.
*/
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
const previous = new Map(before.map((point) => [point.id, point]));
return after.map((point) => {
@@ -233,6 +238,7 @@ export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[],
const same =
!!was &&
samePlace(was, point) &&
!!was.stageEnd === !!point.stageEnd &&
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
if (same) return point;
@@ -1,16 +1,18 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan } = vi.hoisted(() => ({
routeWalkingTour: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
paddlePlan: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: vi.fn() } }));
vi.mock('../useTourPermissions', () => ({
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
canEdit,
@@ -21,7 +23,7 @@ vi.mock('../useTourPermissions', () => ({
import { validDraft } from './tourPlannerDraft';
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-008 (MDT-01)
// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-009 (MDT-01)
const POINTS: Array<[number, number]> = [
[48, 11],
@@ -54,6 +56,23 @@ const savedHike = {
stage_count: 2,
};
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
/** The water router's answer for every leg asked: water, a portage round a weir, water. */
const withPortage = (legs: LegAsked[]) =>
legs.map(({ from, to }) => {
const at = (t: number): [number, number] => [
from.lat + (to.lat - from.lat) * t,
from.lng + (to.lng - from.lng) * t,
];
const segments = [
{ kind: 'water', from: 0, to: 1 },
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
{ kind: 'water', from: 2, to: 3 },
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
const geometry = [at(0), at(0.3), at(0.4), at(1)];
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
});
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
@@ -79,6 +98,7 @@ describe('useTourPlanner, multi-day tours (MDT-01)', () => {
createTour.mockReset();
detailTour.mockReset();
updateTour.mockReset();
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => withPortage(legs));
});
afterEach(() => vi.useRealTimers());
@@ -229,4 +249,26 @@ describe('useTourPlanner, multi-day tours (MDT-01)', () => {
draft.waypoints[1].stageEnd = 'yes';
expect(validDraft(draft)).toBe(false);
});
it('FE-TOUR-STAGES-009: on a paddle tour the loss stays said while the router lays its points in again', async () => {
const { result } = await plannedHike(70);
act(() => result.current.setTourType('kayak'));
await settle();
await settle();
expect(result.current.waypoints.some((point) => point.auto)).toBe(true);
const stop = result.current.waypoints.filter((point) => !point.auto)[1].id;
act(() => result.current.stages.toggleStageEnd(stop));
act(() => result.current.removeWaypoint(stop));
const removed = result.current.waypoints;
expect(result.current.stages.lost).toBe(1);
await settle();
await settle();
await settle();
// The router dropped and set its points around the gap without a step of the history.
expect(result.current.waypoints).not.toBe(removed);
expect(result.current.stages.lost).toBe(1);
act(() => result.current.undo());
expect(result.current.stages.lost).toBe(0);
expect(result.current.waypoints.find((point) => point.id === stop)?.stageEnd).toBe(true);
});
});
@@ -162,8 +162,9 @@ 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)
// MDT-01: stage ends the last change of the points could not keep. Said until the user's next change,
// undo or redo, or another draft, so a point the router lays in afterwards does not take the notice away.
const [stageLoss, setStageLoss] = useState(0)
const draftRevisionRef = useRef(0)
const saveRequestIdRef = useRef(0)
const saveInFlightRef = useRef(false)
@@ -268,7 +269,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
waypointsRef.current = next
setWaypoints(next)
const lost = lostStageEnds(current, changed, next)
setStageLoss(lost > 0 ? { count: lost, after: next } : null)
setStageLoss(lost)
// 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()
@@ -451,6 +452,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
runProfileRef.current = entry.runProfile
tourTypeRef.current = entry.tourType
setWaypoints(entry.waypoints)
setStageLoss(0)
setMaxHikingDifficultyState(entry.difficulty)
setHikingPreferencesState(entry.preferences)
setBikeProfileState(entry.bikeProfile)
@@ -519,6 +521,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
recordStep(snapshot())
tourTypeRef.current = value
setTourTypeState(value)
setStageLoss(0)
const noFerrata = value === 'bike' || value === 'run'
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value)
waypointsRef.current = next
@@ -551,7 +554,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
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,
lost: stageLoss,
})
const stageCount = Math.max(1, stages.stages.length)
const facts = useMemo(() => tourPlannerFacts({
@@ -598,6 +601,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
clearHistory()
setStageLoss(0)
setStatus('empty')
clearRoute()
setError(null)
@@ -778,6 +782,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
setStageLoss(0)
setStatus('dirty')
clearRoute()
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
@@ -921,6 +926,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRoutePending(false)
setStatus('saved')
clearHistory()
setStageLoss(0)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setDraftSource(null)