diff --git a/client/src/components/Tours/planner/paddleRouting.test.ts b/client/src/components/Tours/planner/paddleRouting.test.ts index 95748b54d..c1eef3299 100644 --- a/client/src/components/Tours/planner/paddleRouting.test.ts +++ b/client/src/components/Tours/planner/paddleRouting.test.ts @@ -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', () => { diff --git a/client/src/components/Tours/planner/paddleRouting.ts b/client/src/components/Tours/planner/paddleRouting.ts index 94383e415..911b9cc62 100644 --- a/client/src/components/Tours/planner/paddleRouting.ts +++ b/client/src/components/Tours/planner/paddleRouting.ts @@ -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( 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(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(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; diff --git a/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx b/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx index e27ac0d99..4f76d437c 100644 --- a/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.stages.test.tsx @@ -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); + }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index e3d5c3ccd..22b491269 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -162,8 +162,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const [routeProfileFocus, setRouteProfileFocus] = useState(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)