diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx index acd77fef5..cb483db20 100644 --- a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx @@ -1,7 +1,8 @@ // FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B). import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../../../tests/helpers/render'; -import { PaddleObstaclePlaceButton, placeOf } from './PaddleObstaclePlaceButton'; +import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton'; +import { placeOf } from './paddleObstaclePlace'; describe('placeOf', () => { it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => { diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx index bf93c635f..0e48c2ade 100644 --- a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx @@ -2,6 +2,7 @@ import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared'; import { MapPinPlus } from 'lucide-react'; import { useTranslation } from '../../../i18n'; import { fs } from '../../shared/DialogShell'; +import { placeOf } from './paddleObstaclePlace'; /** Where a paddler gets in or out: these places can become a waypoint from their card. */ const PLACEABLE: Partial> = { @@ -10,28 +11,6 @@ const PLACEABLE: Partial> = { egress: 'egress', }; -/** - * The spot a waypoint goes to: the place itself, the middle of a way (a - * slipway drawn as a ramp), or the middle of an area's outline. - */ -export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null { - const line = - geometry.type === 'Point' - ? [geometry.coordinates] - : geometry.type === 'LineString' - ? geometry.coordinates - : geometry.coordinates[0]; - if (!line?.length) return null; - if (geometry.type === 'Polygon') { - const ring = line.slice(0, -1); - const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; - const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length; - return { lat, lng }; - } - const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? []; - return lat === undefined || lng === undefined ? null : { lat, lng }; -} - /** * PAD-05B: a put-in, a slipway or an egress taken over as the tour's next * waypoint from its card on the map, so the point sits where the paddler diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx index 085be1586..88f49f3cd 100644 --- a/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx @@ -2,7 +2,8 @@ import type { PaddleRegionLoadState } from '@trek/shared'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../../../tests/helpers/render'; -import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from './PaddleRegionLoadRow'; +import { PaddleRegionLoadRow } from './PaddleRegionLoadRow'; +import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad'; const REF = { id: 'oberpfalz', name: 'Oberpfalz' }; const t = (key: string, params?: Record) => `${key}${params ? JSON.stringify(params) : ''}`; diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx index a44d70913..613889365 100644 --- a/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx @@ -5,37 +5,6 @@ import { fs } from '../../shared/DialogShell'; type Translate = ReturnType['t']; -/** The states in which the data is on its way: the leg waits rather than fails. */ -export function isRegionLoadPending( - state: PaddleRegionLoadState | undefined -): state is Extract { - return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested'; -} - -/** - * Why a leg stays unplanned once the data cannot come: declined, failed, no - * water near the place, nothing to load. Null where the leg's own reason says - * it best (loading is off, or nothing was asked yet). - */ -export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null { - if (!state) return null; - const region = state.region?.name ?? ''; - switch (state.state) { - case 'declined': - return t('tours.paddle.load.declined', { region }); - case 'failed': - return t('tours.paddle.load.failed', { region }); - case 'no_water': - return t('tours.paddle.load.noWater', { region }); - case 'unavailable': - if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract'); - if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable'); - return null; - default: - return null; - } -} - function headline(state: Extract, t: Translate) { const region = state.region.name; if (state.state === 'ready') return t('tours.paddle.load.ready', { region }); diff --git a/client/src/components/Tours/paddle/paddleObstaclePlace.ts b/client/src/components/Tours/paddle/paddleObstaclePlace.ts new file mode 100644 index 000000000..f19147581 --- /dev/null +++ b/client/src/components/Tours/paddle/paddleObstaclePlace.ts @@ -0,0 +1,23 @@ +import type { PaddleGeometry } from '@trek/shared'; + +/** + * The spot a waypoint goes to: the place itself, the middle of a way (a + * slipway drawn as a ramp), or the middle of an area's outline. + */ +export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null { + const line = + geometry.type === 'Point' + ? [geometry.coordinates] + : geometry.type === 'LineString' + ? geometry.coordinates + : geometry.coordinates[0]; + if (!line?.length) return null; + if (geometry.type === 'Polygon') { + const ring = line.slice(0, -1); + const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; + const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length; + return { lat, lng }; + } + const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? []; + return lat === undefined || lng === undefined ? null : { lat, lng }; +} diff --git a/client/src/components/Tours/paddle/paddleRegionLoad.ts b/client/src/components/Tours/paddle/paddleRegionLoad.ts new file mode 100644 index 000000000..e5789cb5f --- /dev/null +++ b/client/src/components/Tours/paddle/paddleRegionLoad.ts @@ -0,0 +1,35 @@ +import type { PaddleRegionLoadState } from '@trek/shared'; +import type { useTranslation } from '../../../i18n'; + +type Translate = ReturnType['t']; + +/** The states in which the data is on its way: the leg waits rather than fails. */ +export function isRegionLoadPending( + state: PaddleRegionLoadState | undefined +): state is Extract { + return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested'; +} + +/** + * Why a leg stays unplanned once the data cannot come: declined, failed, no + * water near the place, nothing to load. Null where the leg's own reason says + * it best (loading is off, or nothing was asked yet). + */ +export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null { + if (!state) return null; + const region = state.region?.name ?? ''; + switch (state.state) { + case 'declined': + return t('tours.paddle.load.declined', { region }); + case 'failed': + return t('tours.paddle.load.failed', { region }); + case 'no_water': + return t('tours.paddle.load.noWater', { region }); + case 'unavailable': + if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract'); + if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable'); + return null; + default: + return null; + } +} diff --git a/client/src/components/Tours/planner/TourWaypointList.test.tsx b/client/src/components/Tours/planner/TourWaypointList.test.tsx index 4d20820c5..0be1bc1e6 100644 --- a/client/src/components/Tours/planner/TourWaypointList.test.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.test.tsx @@ -10,6 +10,13 @@ import type { PaddleTourRoute } from './paddleRouting'; import TourWaypointList from './TourWaypointList'; import type { TourPlannerController } from './useTourPlanner'; +/** The item at `i`, or a failure that names the index instead of a later undefined. */ +function at(list: readonly T[], i: number): T { + const item = list[i]; + if (item === undefined) throw new Error(`nothing at ${i}`); + return item; +} + vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) })); const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const }; @@ -67,7 +74,7 @@ describe('TourWaypointList', () => { render(); const rows = screen.getAllByTestId('tour-planner-waypoint-row'); expect(rows).toHaveLength(4); - expect(within(rows[1]!).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true'); + expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true'); expect( [...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number')) ).toEqual(['1', '2', '3']); @@ -77,7 +84,7 @@ describe('TourWaypointList', () => { it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => { const controller = planner({ selectedWaypointId: 'b' }); render(); - const row = screen.getAllByTestId('tour-planner-waypoint-row')[2]!; + const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2); await userEvent.click(within(row).getByRole('button', { name: 'More actions' })); await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' })); expect(controller.recomputeLeg).toHaveBeenCalledWith('b'); @@ -95,14 +102,14 @@ describe('TourWaypointList', () => { const controller = planner({ selectedWaypointId: 's' }); const { rerender } = render(); await userEvent.click( - within(screen.getAllByTestId('tour-planner-waypoint-row')[1]!).getByRole('button', { name: 'More actions' }) + within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' }) ); await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' })); expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false); rerender(); await userEvent.click( - within(screen.getAllByTestId('tour-planner-waypoint-row')[0]!).getByRole('button', { name: 'More actions' }) + within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' }) ); expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument(); }); diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index a99ca9ebf..167734bd9 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -37,7 +37,8 @@ import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; -import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from '../paddle/PaddleRegionLoadRow'; +import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad'; +import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow'; import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts'; import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; diff --git a/client/src/components/Tours/planner/paddleRouting.test.ts b/client/src/components/Tours/planner/paddleRouting.test.ts index abdda712b..95748b54d 100644 --- a/client/src/components/Tours/planner/paddleRouting.test.ts +++ b/client/src/components/Tours/planner/paddleRouting.test.ts @@ -11,6 +11,13 @@ import { type PaddlePoint, } from './paddleRouting'; +/** The item at `i`, or a failure that names the index instead of a later undefined. */ +function at(list: readonly T[], i: number): T { + const item = list[i]; + if (item === undefined) throw new Error(`nothing at ${i}`); + return item; +} + vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } })); const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const }; @@ -83,7 +90,7 @@ describe('routePaddleTour', () => { [false, false], ]); expect(route.legLengths[1]).toBe(5500); - expect(route.distanceMeters).toBeCloseTo(route.legLengths[0]! + 5500); + expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500); expect(route.coordinates[0]).toEqual([A.lat, A.lng]); expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]); }); @@ -171,7 +178,7 @@ describe('automatic points', () => { expect(pruneAutoPoints(points)).toEqual(points); const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point)); expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']); - const shaped = [points[0]!, { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)]; + const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)]; expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx index 2ab3f2d87..eb1f3a427 100644 --- a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx @@ -24,6 +24,12 @@ vi.mock('../useTourPermissions', () => ({ import { useTourPlanner } from './useTourPlanner'; +/** The value, or a failure saying it is missing instead of a later undefined. */ +function must(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error('expected a value'); + return value; +} + const routed = { coordinates: [ [48, 11], @@ -393,17 +399,17 @@ describe('useTourPlanner route tools', () => { const [start, end] = result.current.waypoints; let id: string | null = null; act(() => { - id = result.current.insertShapingPoint(start!.id, 48.01, 11.005); + id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005); }); expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]); expect(result.current.waypoints[1]?.id).toBe(id); act(() => { - expect(result.current.insertShapingPoint(end!.id, 48.03, 11.03)).toBeNull(); - expect(result.current.insertShapingPoint(start!.id, Number.NaN, 11)).toBeNull(); + expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull(); + expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull(); }); - act(() => result.current.setWaypointShaping(id!, false)); + act(() => result.current.setWaypointShaping(must(id), false)); expect(result.current.waypoints[1]).not.toHaveProperty('shaping'); - act(() => result.current.setWaypointShaping(start!.id, true)); + act(() => result.current.setWaypointShaping(must(start).id, true)); expect(result.current.waypoints[0]).not.toHaveProperty('shaping'); // One undo takes back one change. act(() => result.current.undo()); @@ -425,11 +431,11 @@ describe('useTourPlanner route tools', () => { await settle(); expect(paddlePlan).toHaveBeenCalledTimes(1); const [start, end] = result.current.waypoints; - act(() => result.current.recomputeLeg(start!.id)); + act(() => result.current.recomputeLeg(must(start).id)); expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' }); await settle(); expect(paddlePlan).toHaveBeenCalledTimes(2); - act(() => result.current.recomputeLeg(end!.id)); + act(() => result.current.recomputeLeg(must(end).id)); expect(paddleForget).toHaveBeenCalledTimes(1); }); @@ -502,7 +508,9 @@ describe('useTourPlanner route tools', () => { await settle(); await settle(); const end = result.current.waypoints[result.current.waypoints.length - 1]; - act(() => result.current.setWaypointPosition(end.id, 48.2, 11)); + act(() => { + void result.current.setWaypointPosition(end.id, 48.2, 11); + }); await settle(); expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe( true diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 5c3487320..afedb5c60 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -139,14 +139,7 @@ export function TourPlannerMapStage({ const closingPress = useOutsidePressGuard(); const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null); const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close); - // PAD-05B: a put-in, slipway or egress taken over from its card as the tour's next point. - const placeFromCard = - canPlaceEdit && tourPlanner.isPaddle && !tourPlanner.isSaving && tourPlanner.mode.type !== 'view-gpx' - ? (lat: number, lng: number) => { - tourPlanner.addWaypoint(lat, lng); - selectPaddleObstacle(null); - } - : undefined; + const placeFromCard = paddlePlaceFromCard(tourPlanner, canPlaceEdit); const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]); const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); // The "set points" tool, per mode; only a real click places, never the end of a drag. @@ -165,6 +158,8 @@ export function TourPlannerMapStage({ // What each panel covers of the map, a collapsed one its rail. const leftInset = leftHidden ? 56 : leftPanelPx; const rightInset = rightHidden ? 56 : rightPanelPx; + // The cards over the map keep clear of the layer switcher, on the side the text ends. + const cardInsetEnd = (isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET; const bottomRowTop = terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM; return ( <> @@ -240,14 +235,14 @@ export function TourPlannerMapStage({ selection={ferrataSelected} onClose={ferrata.close} onOutsidePress={closingPress.arm} - insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET} + insetEnd={cardInsetEnd} {...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)} onAdopt={adoptFerrata} onTourIds={ferrataOnTour} /> ( <> @@ -319,3 +314,20 @@ export function TourPlannerMapStage({ ); } + +/** + * PAD-05B: a put-in, slipway or egress taken over from its card as the tour's + * next point; undefined where the tour cannot take one now (not a paddle tour, + * saving, a GPX shown read only, or no right to edit). + */ +function paddlePlaceFromCard( + tourPlanner: PageState['tourPlanner'], + canPlaceEdit: boolean +): ((lat: number, lng: number) => void) | undefined { + if (!canPlaceEdit || !tourPlanner.isPaddle || tourPlanner.isSaving || tourPlanner.mode.type === 'view-gpx') + return undefined; + return (lat, lng) => { + tourPlanner.addWaypoint(lat, lng); + selectPaddleObstacle(null); + }; +} diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts index cb9198a9f..e7f26d7b2 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts @@ -171,9 +171,11 @@ describe('PaddleRegionLoadService', () => { }); it('PLOAD-004: holds TREK to a few regions a day and asks the admins for the next', async () => { - for (let i = 0; i < PADDLE_AUTO_REGIONS_PER_DAY; i += 1) { - await insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' }); - } + await Promise.all( + Array.from({ length: PADDLE_AUTO_REGIONS_PER_DAY }, (_, i) => + insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' }), + ), + ); const [answer] = await ask(paddler); expect(answer).toMatchObject({ state: 'requested', reason: 'limit' }); });