+ {planner.isPaddle &&
}
} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t('common.discard')}
diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx
index 71004b554..04c59b0db 100644
--- a/client/src/components/Tours/planner/TourTypeFields.test.tsx
+++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx
@@ -32,10 +32,10 @@ describe('TourTypeFields (PAD-01)', () => {
fireEvent.click(screen.getByRole('button', { name: 'Lake' }));
expect(paddleTour.paddle.setBoatType).toHaveBeenCalledWith('canoe');
expect(paddleTour.paddle.setWaterContext).toHaveBeenCalledWith('lake');
- // Two notes speak here: what the tour still needs, and what is known about obstacles on the water.
+ // What the tour still needs speaks here; the obstacles on the water moved into the checklist card.
const notes = screen.getAllByRole('status').map((note) => note.textContent);
expect(notes).toContain('Choose the boat and the water to save this paddle tour.');
- expect(notes).toContain('Loading the obstacles along the route …');
+ expect(notes).not.toContain('Loading the obstacles along the route …');
});
it('reads a typed speed with either decimal separator, and an empty field as the suggestion', () => {
diff --git a/client/src/components/Tours/planner/TourTypeFields.tsx b/client/src/components/Tours/planner/TourTypeFields.tsx
index 78f3b9c4c..c51eb8baa 100644
--- a/client/src/components/Tours/planner/TourTypeFields.tsx
+++ b/client/src/components/Tours/planner/TourTypeFields.tsx
@@ -10,7 +10,6 @@ import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
-import { PaddleObstacleNotice } from '../paddle/PaddleObstacleNotice';
import { TourNotice } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
@@ -161,8 +160,6 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
)
)}
- {/* PAD-04/05B: what the map knows about the water, and where it knows nothing, never read as open water. */}
-
{planner.paddleIncomplete && (
}>
{t('tours.paddle.chooseBoat')}
diff --git a/client/src/db/cacheTour.test.ts b/client/src/db/cacheTour.test.ts
index e572c15fe..339035c66 100644
--- a/client/src/db/cacheTour.test.ts
+++ b/client/src/db/cacheTour.test.ts
@@ -1,6 +1,6 @@
// A tour answer written to the cache (PAD-06): its control points, and where it
// came from and its checklist kept from the cached row when the answer lacks them.
-import type { TourListItem } from '@trek/shared';
+import type { TourListItem, TourProvenance } from '@trek/shared';
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -32,10 +32,10 @@ const waypoints = [
{ lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 },
{ lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 },
];
-const provenance = {
- origin: 'trek' as const,
+const provenance: TourProvenance = {
+ origin: 'trek',
createdAt: null,
- source: null,
+ source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index f22605a99..9df6a9e7a 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -634,7 +634,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
- onViewportChange={wandererBrowser.onViewportChange}
+ onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
/>
diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx
index ae64ce233..39cad066c 100644
--- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx
+++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx
@@ -1,10 +1,11 @@
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
-import type { Ferrata } from '@trek/shared';
+import type { Ferrata, PaddleFeature } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
+import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
@@ -89,6 +90,12 @@ vi.mock('../../components/Planner/LocationSelect', () => ({
}));
// What the layer asks the server is useTripFerratas' own suite; here it answers from memory.
vi.mock('../../components/Ferratas/useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
+// The checklist's part of the card is ObstacleDecisionPanel's own suite; here it shows what it was handed.
+vi.mock('../../components/Tours/paddle/ObstacleDecisionPanel', () => ({
+ ChecklistObstaclePanel: ({ checklist, featureRef }: { checklist: { status: string }; featureRef: string }) => (
+
{`${checklist.status} ${featureRef}`}
+ ),
+}));
const FERRATA: Ferrata = {
id: 'way/42',
@@ -193,6 +200,57 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+describe('the paddle checklist in the obstacle card (PAD-06)', () => {
+ type Checklist = NonNullable
;
+
+ const WEIR: PaddleFeature = {
+ ref: 'node/101',
+ kind: 'weir',
+ group_key: null,
+ name: 'Hammer weir',
+ geometry: { type: 'Point', coordinates: [7, 46] },
+ tags: { waterway: 'weir' },
+ tag_hash: 'weir-hash',
+ region_id: 'valais',
+ };
+
+ /** The weir in the obstacle store and picked, as a click on its map pin or a row of the checklist picks it. */
+ function pickWeir() {
+ act(() => {
+ publishPaddleObstacles({
+ status: 'ready',
+ features: [WEIR],
+ related: [],
+ routeLengthM: 0,
+ coverage: 'covered',
+ regions: [],
+ truncated: false,
+ });
+ selectPaddleObstacle(WEIR.ref);
+ });
+ }
+
+ afterEach(() => {
+ act(() => clearPaddleObstacles());
+ });
+
+ it('PCM-001: the picked object card carries the checklist part for that object', () => {
+ const paddleChecklist = { status: 'ready' } as unknown as Checklist;
+ render();
+ pickWeir();
+ const panel = screen.getByTestId('checklist-obstacle-panel');
+ expect(screen.getByTestId('paddle-obstacle-overlay')).toContainElement(panel);
+ expect(panel).toHaveTextContent('ready node/101');
+ });
+
+ it('PCM-002: without a checklist the card shows the object alone', () => {
+ renderStage(planner('edit-saved'));
+ pickWeir();
+ expect(screen.getByTestId('paddle-obstacle-overlay')).toBeInTheDocument();
+ expect(screen.queryByTestId('checklist-obstacle-panel')).not.toBeInTheDocument();
+ });
+});
+
describe('TourPlannerMapStage', () => {
it('FE-PAGE-TOURSTAGE-001: the flat map adds and moves control points while a tour is edited', () => {
const tourPlanner = planner('new-draft');
diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
index 3c343d4ba..6cfe3e926 100644
--- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
+++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
@@ -9,6 +9,7 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../componen
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
+import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
import { isRtlLanguage, useTranslation } from '../../i18n';
@@ -63,6 +64,8 @@ export interface TourPlannerMapStageProps
rightPanelPx: number;
/** Told the visible area too, besides the via ferrata layer (the wanderer area search). */
onViewportChange?: (bounds: FerrataBbox) => void;
+ /** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
+ paddleChecklist?: PageState['paddleChecklist'];
}
/**
@@ -83,6 +86,7 @@ export function TourPlannerMapStage({
leftPanelPx,
rightPanelPx,
onViewportChange,
+ paddleChecklist,
}: Readonly): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
@@ -245,6 +249,11 @@ export function TourPlannerMapStage({
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
+ extra={
+ paddleChecklist
+ ? (detail) =>
+ : undefined
+ }
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts
index 8b81786b6..05f5869d3 100644
--- a/client/src/pages/tripPlanner/useTourWorkspace.ts
+++ b/client/src/pages/tripPlanner/useTourWorkspace.ts
@@ -1,6 +1,7 @@
import type { TourListItem } from '@trek/shared';
import { useCallback } from 'react';
import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion';
+import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleChecklist';
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
@@ -32,6 +33,7 @@ export function useTourWorkspace(
tours,
tripActions,
tripId,
+ tripMembers,
upsertTour,
} = planner;
// wanderer only while the admin allows it under Tours (the store reads Tours being on into it too).
@@ -118,15 +120,28 @@ export function useTourWorkspace(
},
});
+ // A tour saved by a paddle card, its portages and locks or a checklist decision: lists and map take it in.
+ const takeInSavedTour = (tour: TourListItem) => {
+ upsertTour(tour);
+ void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
+ };
// PAD-03/PAD-07: portages and locks laid onto a recorded paddle track, without redrawing it.
const trackPaddlePlan = useTrackPaddlePlan({
tripId,
planner: tourPlanner,
canEdit: canPlaceEdit,
- onSaved: (tour) => {
- upsertTour(tour);
- void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
- },
+ onSaved: takeInSavedTour,
+ });
+ // PAD-06/PAD-09: the obstacle checklist of the paddle tour open in the planner.
+ const paddleChecklist = usePaddleChecklist({
+ tripId,
+ planner: tourPlanner,
+ tours,
+ tripMembers,
+ canEdit: canPlaceEdit,
+ active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
+ trackPaddlePlan,
+ onSaved: takeInSavedTour,
});
const tourDetailActions = useTourDetailActions(planner);
@@ -139,6 +154,7 @@ export function useTourWorkspace(
tourConversion,
recordedTrack,
trackPaddlePlan,
+ paddleChecklist,
tourDetailActions,
};
}
diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts
index 54cd0ab72..7602ec158 100644
--- a/client/src/pages/tripPlanner/useTripPlannerPage.ts
+++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts
@@ -7,6 +7,7 @@ import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPort
import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync'
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
+import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
import { useMapLocationSearch } from './useMapLocationSearch'
import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
@@ -26,7 +27,7 @@ export function useTripPlannerPage() {
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
- const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, tourDetailActions } =
+ const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, paddleChecklist, tourDetailActions } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
@@ -65,7 +66,9 @@ export function useTripPlannerPage() {
wandererBrowser.line,
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
- const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints)
+ // PAD-06: an obstacle or area the paddle checklist asks for moves the camera as a picked search result does.
+ const paddleFocus = usePaddleObstacleStore(state => state.focus)
+ const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints, paddleFocus)
const trackParts = useTrackPortageParts(tourPlanner)
// PAD-05B: the obstacles around the paddle tour in view, for the map and the rails alike.
@@ -143,6 +146,7 @@ export function useTripPlannerPage() {
tourConversion,
recordedTrack,
trackPaddlePlan,
+ paddleChecklist,
tourDetailActions,
tourMap: { route,
routeWalking,
diff --git a/client/src/store/paddleObstacleStore.test.ts b/client/src/store/paddleObstacleStore.test.ts
new file mode 100644
index 000000000..83bad928f
--- /dev/null
+++ b/client/src/store/paddleObstacleStore.test.ts
@@ -0,0 +1,95 @@
+// FE-STORE-PADDLE-001 to FE-STORE-PADDLE-004: the camera focus of the paddle obstacle store (PAD-06). A click in
+// the checklist selects an object and frames it with the other parts of its lock; an area is framed as asked; each
+// focus is numbered so the newest wins; publishing new objects keeps it, clearing the store drops it.
+import type { PaddleFeature, PaddleGeometry } from '@trek/shared';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ clearPaddleObstacles,
+ focusPaddleArea,
+ focusPaddleObstacle,
+ publishPaddleObstacles,
+ usePaddleObstacleStore,
+} from './paddleObstacleStore';
+
+const point = (lng: number, lat: number): PaddleGeometry => ({ type: 'Point', coordinates: [lng, lat] });
+
+function feature(ref: string, geometry: PaddleGeometry, groupKey: string | null = null): PaddleFeature {
+ return {
+ ref,
+ kind: 'lock',
+ group_key: groupKey,
+ name: null,
+ geometry,
+ tags: {},
+ tag_hash: ref,
+ region_id: 'oberpfalz',
+ };
+}
+
+const LOCK = [
+ feature('node/3', point(12.001, 49.2), 'lock:node/3'),
+ feature('node/4', point(12.003, 49.201), 'lock:node/3'),
+];
+const WEIR = feature('node/9', point(12.01, 49.21));
+
+function publish(features: PaddleFeature[]): void {
+ publishPaddleObstacles({
+ status: 'ready',
+ features,
+ related: [],
+ routeLengthM: 1000,
+ coverage: 'covered',
+ regions: [],
+ truncated: false,
+ });
+}
+
+beforeEach(() => {
+ clearPaddleObstacles();
+});
+
+describe('paddleObstacleStore focus', () => {
+ it('FE-STORE-PADDLE-001: focusing a lock part selects it and frames every part with a margin', () => {
+ publish([...LOCK, WEIR]);
+ expect(focusPaddleObstacle('node/4')).toBe(true);
+ const { selectedRef, focus } = usePaddleObstacleStore.getState();
+ expect(selectedRef).toBe('node/4');
+ expect(focus?.seq).toBe(1);
+ const [[south, west], [north, east]] = focus?.points ?? [
+ [0, 0],
+ [0, 0],
+ ];
+ expect(south).toBeCloseTo(49.2 - 0.0015, 6);
+ expect(west).toBeCloseTo(12.001 - 0.0015, 6);
+ expect(north).toBeCloseTo(49.201 + 0.0015, 6);
+ expect(east).toBeCloseTo(12.003 + 0.0015, 6);
+ });
+
+ it('FE-STORE-PADDLE-002: an object the store does not hold is not focused', () => {
+ publish([WEIR]);
+ expect(focusPaddleObstacle('node/404')).toBe(false);
+ expect(usePaddleObstacleStore.getState()).toMatchObject({ selectedRef: null, focus: null });
+ });
+
+ it('FE-STORE-PADDLE-003: an area is framed as asked, each focus numbered after the last, none for no points', () => {
+ focusPaddleArea([[49.2, 12.005]]);
+ focusPaddleArea([]);
+ focusPaddleArea([
+ [49.2, 12],
+ [49.3, 12.1],
+ ]);
+ const { focus } = usePaddleObstacleStore.getState();
+ expect(focus?.seq).toBe(2);
+ expect(focus?.points[1]?.[0]).toBeCloseTo(49.3015, 6);
+ });
+
+ it('FE-STORE-PADDLE-004: publishing new objects keeps the focus, clearing the store drops it', () => {
+ publish([WEIR]);
+ focusPaddleObstacle('node/9');
+ publish([WEIR, ...LOCK]);
+ expect(usePaddleObstacleStore.getState().focus?.seq).toBe(1);
+ clearPaddleObstacles();
+ expect(usePaddleObstacleStore.getState().focus).toBeNull();
+ });
+});
diff --git a/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts b/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts
index 1775d9b3b..029508e97 100644
--- a/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts
+++ b/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts
@@ -4,7 +4,7 @@
* checklist this build cannot read never stalls the queue, a refused body is
* parked as failed and an obstacle data outage waits for a retry.
*/
-import type { TourListItem } from '@trek/shared';
+import type { TourListItem, TourProvenance } from '@trek/shared';
import 'fake-indexeddb/auto';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -45,10 +45,10 @@ const waypoints = [
{ lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 },
{ lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 },
];
-const provenance = {
- origin: 'trek' as const,
+const provenance: TourProvenance = {
+ origin: 'trek',
createdAt: null,
- source: null,
+ source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,