From de86e6383a901934eeb2c584980ff772eae34ba7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 17 Oct 2026 01:16:41 +0000 Subject: [PATCH] feat(tours): the obstacle checklist, the decision panel and the safety note in the planner PAD-06 and PAD-09 on screen. A paddle tour open in the planner or shown as recorded carries a checklist card: the obstacles of the data along its route in route order with the paddler's own portages and locks among them, each with its status, its paddling rule and the SUP advice at a lock, and the earlier decisions the route or the data no longer carries behind a fold. A row moves the camera onto the obstacle and the other parts of its lock and opens its card on the map, where the decision panel sits: four choices, the notices that stay after a choice (a ban, the SUP advice, a portage still to plan), the date of the last check on site, a note and who decided when. An object the map does not hold opens in a dialog instead. Deciding waits while the route has unsaved changes, and a stale or dirty list is dimmed, never hidden. The tours list, the desktop day plan and the phone day plan show the bench as one pill, the tour detail shows it with the data's date, and every paddle tour reaches the safety note from its footer. Nothing reads as safe where TREK knows nothing, and nothing blocks. --- .../components/Tours/TourDayRowFacts.test.tsx | 39 +- .../src/components/Tours/TourDayRowFacts.tsx | 15 +- .../Tours/TourDetailDialog.test.tsx | 16 + .../src/components/Tours/TourDetailDialog.tsx | 2 + .../Tours/TourWorkspaceRail.test.tsx | 12 + .../components/Tours/TourWorkspaceRail.tsx | 13 +- .../paddle/ObstacleDecisionPanel.test.tsx | 449 ++++++++++++ .../Tours/paddle/ObstacleDecisionPanel.tsx | 439 ++++++++++++ .../Tours/paddle/ObstacleEntryDialog.test.tsx | 158 +++++ .../Tours/paddle/ObstacleEntryDialog.tsx | 75 ++ .../Tours/paddle/PaddleCheckBadges.test.tsx | 421 +++++++++++ .../Tours/paddle/PaddleCheckBadges.tsx | 209 ++++++ .../Tours/paddle/PaddleCheckCard.test.tsx | 434 ++++++++++++ .../Tours/paddle/PaddleCheckCard.tsx | 386 ++++++++++ .../Tours/paddle/PaddleCheckSection.test.tsx | 75 ++ .../Tours/paddle/PaddleCheckSection.tsx | 36 + .../Tours/paddle/PaddleObstacleNotice.tsx | 4 +- .../Tours/paddle/PaddleSafety.test.tsx | 129 ++++ .../components/Tours/paddle/PaddleSafety.tsx | 101 +++ .../Tours/paddle/paddleCheckModel.test.ts | 263 +++++++ .../Tours/paddle/paddleCheckModel.ts | 8 +- .../Tours/paddle/paddleMapLayer.test.ts | 27 +- .../Tours/paddle/usePaddleChecklist.test.tsx | 664 ++++++++++++++++++ .../Tours/paddle/usePaddleChecklist.ts | 112 +-- .../paddle/usePaddleObstacleSync.test.ts | 1 + .../Tours/planner/TourPlannerPanels.test.tsx | 69 ++ .../Tours/planner/TourPlannerPanels.tsx | 42 +- .../Tours/planner/TourTypeFields.test.tsx | 4 +- .../Tours/planner/TourTypeFields.tsx | 3 - client/src/db/cacheTour.test.ts | 8 +- client/src/pages/TripPlannerPage.tsx | 2 +- .../tripPlanner/TourPlannerMapStage.test.tsx | 60 +- .../pages/tripPlanner/TourPlannerMapStage.tsx | 9 + .../src/pages/tripPlanner/useTourWorkspace.ts | 24 +- .../pages/tripPlanner/useTripPlannerPage.ts | 8 +- client/src/store/paddleObstacleStore.test.ts | 95 +++ .../mutationQueue.obstacleReviews.test.ts | 8 +- 37 files changed, 4341 insertions(+), 79 deletions(-) create mode 100644 client/src/components/Tours/paddle/ObstacleDecisionPanel.test.tsx create mode 100644 client/src/components/Tours/paddle/ObstacleDecisionPanel.tsx create mode 100644 client/src/components/Tours/paddle/ObstacleEntryDialog.test.tsx create mode 100644 client/src/components/Tours/paddle/ObstacleEntryDialog.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckBadges.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckBadges.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckCard.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckCard.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckSection.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleCheckSection.tsx create mode 100644 client/src/components/Tours/paddle/PaddleSafety.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleSafety.tsx create mode 100644 client/src/components/Tours/paddle/paddleCheckModel.test.ts create mode 100644 client/src/components/Tours/paddle/usePaddleChecklist.test.tsx create mode 100644 client/src/store/paddleObstacleStore.test.ts diff --git a/client/src/components/Tours/TourDayRowFacts.test.tsx b/client/src/components/Tours/TourDayRowFacts.test.tsx index 7ba87cda9..355404b2d 100644 --- a/client/src/components/Tours/TourDayRowFacts.test.tsx +++ b/client/src/components/Tours/TourDayRowFacts.test.tsx @@ -1,8 +1,9 @@ import type { TourListItem } from '@trek/shared'; import { Bike, Footprints, Mountain, Route } from 'lucide-react'; import { describe, expect, it, vi } from 'vitest'; +import { paddleBench } from '../../../tests/helpers/paddleCheck'; import { fireEvent, render, screen } from '../../../tests/helpers/render'; -import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts'; +import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts'; import { tourDayRowIcon } from './tourDayRowIcon'; const tour: TourListItem = { @@ -74,3 +75,39 @@ describe('RC-01 Tour day-row facts', () => { expect(openRow).not.toHaveBeenCalled(); }); }); + +describe('PAD-09 the check bench on a tour row', () => { + const kayak: TourListItem = { ...tour, tour_type: 'kayak', paddle_check: paddleBench() }; + + it('PCD-001: a kayak tour with a bench shows one checklist pill, its tooltip saying it is no clearance', () => { + render(); + const pill = screen.getByTestId('plan-tour-paddle-check'); + expect(pill).toHaveTextContent('1 open'); + expect(pill.getAttribute('aria-label')).toContain('A checklist is no safety or legal clearance.'); + }); + + it('PCD-002: missing obstacle data reads as such in the pill, never as a count', () => { + render(); + expect(screen.getByTestId('plan-tour-paddle-check')).toHaveTextContent('No data'); + }); + + it.each([false, true])('PCD-003: the day plan facts name the bench as one more fact (mobile=%s)', (mobile) => { + render(); + expect(screen.getByTestId('plan-tour-paddle-check')).toHaveAccessibleName('Checklist: 1 open'); + }); + + const without: Array<[string, TourListItem]> = [ + ['a hike with a bench', { ...tour, paddle_check: paddleBench() }], + ['a kayak tour without a bench', { ...tour, tour_type: 'kayak' }], + ['a kayak tour whose bench is null', { ...tour, tour_type: 'kayak', paddle_check: null }], + ]; + it.each(without)('PCD-004: %s shows no checklist pill and no checklist fact', (_case, item) => { + render( + <> + + + + ); + expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 5e0d39d98..68428d1d0 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -7,6 +7,7 @@ import { formatDistance, formatElevation } from '../../utils/units'; import { WhiteBadge } from '../Planner/planParts'; import { fs } from '../shared/DialogShell'; import { Tooltip } from '../shared/Tooltip'; +import { paddleCheckFact } from './paddle/paddleCheckModel'; import { tourDayRowIcon } from './tourDayRowIcon'; import { movingTimeLabel } from './tourFacts'; import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation'; @@ -118,7 +119,7 @@ export function TourDayRowAvatar({ } /** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */ -function TourDayRowPill({ +export function TourDayRowPill({ icon: Icon, value, label, @@ -140,10 +141,16 @@ function TourDayRowPill({ ); } +/** A paddle tour's check bench as one fact (PAD-09), or null for a hike or a tour listed without one. */ +function paddleCheckOf(tour: TourListItem, t: Parameters[1]) { + return tour.tour_type === 'kayak' && tour.paddle_check ? paddleCheckFact(tour.paddle_check, t) : null; +} + /** * A tour's facts on its day-plan row, in the badges every other stop uses: the * time it takes (the planned total where there is one, otherwise the walking - * time, the parts in the tooltip), how far and how much climbing. + * time, the parts in the tooltip), how far, how much climbing and, for a paddle + * tour, its check bench. */ export function TourDayRowPills({ tour }: { tour: TourListItem }) { const { t } = useTranslation(); @@ -161,6 +168,7 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) { ? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}` : null, ].filter((part): part is string => part !== null); + const check = paddleCheckOf(tour, t); return ( <> {shownMinutes != null && ( @@ -187,6 +195,7 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) { testId="plan-tour-ascent" /> )} + {check && } ); } @@ -223,6 +232,8 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi `${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`, 'trip-plan-tour-planned-duration' ); + const check = paddleCheckOf(tour, t); + if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check'); return ( {facts.map((fact) => ( diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index 473fade6c..dd8d16185 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TourListItem } from '@trek/shared' import { buildPlace } from '../../../tests/helpers/factories' +import { paddleBench } from '../../../tests/helpers/paddleCheck' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useSettingsStore } from '../../store/settingsStore' import TourDetailDialog from './TourDetailDialog' @@ -224,4 +225,19 @@ describe('TourDetailDialog', () => { expect(screen.getAllByText('T1').length).toBeGreaterThan(0) expect(screen.queryByText(/easy/i)).not.toBeInTheDocument() }) + + it('PCT-001: a paddle tour shows its checklist and the safety row before the wind, a hike neither', () => { + const { unmount } = render() + + expect(screen.getByText('Checklist')).toBeInTheDocument() + expect(screen.getByTestId('paddle-check-summary')).toBeInTheDocument() + const safety = screen.getByRole('button', { name: /Paddling has risks/ }) + expect(safety.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + unmount() + + render() + expect(screen.queryByText('Checklist')).not.toBeInTheDocument() + expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument() + }) }) diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 6c29d3572..bb5bf2463 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -47,6 +47,7 @@ import { Tooltip } from '../shared/Tooltip'; import TrackColorPicker from '../shared/TrackColorPicker'; import { markdownLinkComponents } from '../shared/markdownLink'; import { TourDayRowAvatar } from './TourDayRowFacts'; +import { PaddleCheckSection } from './paddle/PaddleCheckSection'; import { PaddleWindSection } from './paddle/PaddleWindCard'; import { TourProvenanceBlock } from './provenance/TourProvenanceSection'; import { tourDayRowIcon } from './tourDayRowIcon'; @@ -646,6 +647,7 @@ export default function TourDetailDialog({ placeId={place.id} /> )} + {tour.tour_type === 'kayak' && } {tour.tour_type === 'kayak' && } {description && ( diff --git a/client/src/components/Tours/TourWorkspaceRail.test.tsx b/client/src/components/Tours/TourWorkspaceRail.test.tsx index 7996715b7..00c9c29a3 100644 --- a/client/src/components/Tours/TourWorkspaceRail.test.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '../../../tests/helpers/render'; import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail'; import type { GpxConversion } from './conversion/useGpxConversion'; +import type { PaddleChecklist } from './paddle/usePaddleChecklist'; +import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import type { TourPlannerController } from './planner/useTourPlanner'; import type { RecordedTrack } from './useRecordedTrack'; import type { WandererBrowser } from './wanderer/useWandererBrowser'; @@ -51,4 +53,14 @@ describe('TourWorkspaceRail', () => { view.rerender(); expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined(); }); + + it('PCW-001: hands the paddle plan and the checklist of the tour in view on to the editor rail', () => { + const trackPaddlePlan = { pick: null } as unknown as TrackPaddlePlan; + const paddleChecklist = { status: 'ready' } as unknown as PaddleChecklist; + const parts = { ...workspace(wanderer({})), trackPaddlePlan, paddleChecklist }; + seen.rail.length = 0; + render(); + expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan); + expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist); + }); }); diff --git a/client/src/components/Tours/TourWorkspaceRail.tsx b/client/src/components/Tours/TourWorkspaceRail.tsx index 44b00f693..25593cd61 100644 --- a/client/src/components/Tours/TourWorkspaceRail.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.tsx @@ -1,4 +1,5 @@ import type { GpxConversion } from './conversion/useGpxConversion'; +import type { PaddleChecklist } from './paddle/usePaddleChecklist'; import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import { TourPlannerRail } from './planner/TourPlannerPanels'; import type { TourPlannerController } from './planner/useTourPlanner'; @@ -14,6 +15,8 @@ export interface TourWorkspaceRailParts { recordedTrack: RecordedTrack; /** The portages and locks of a recorded paddle track in view. */ trackPaddlePlan?: TrackPaddlePlan; + /** The obstacle checklist of the paddle tour open in the planner (PAD-06). */ + paddleChecklist?: PaddleChecklist; } /** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */ @@ -26,7 +29,14 @@ export default function TourWorkspaceRail({ canEdit: boolean; canAssign: boolean; }) { - const { tourPlanner, wandererBrowser: wanderer, tourConversion, recordedTrack, trackPaddlePlan } = workspace; + const { + tourPlanner, + wandererBrowser: wanderer, + tourConversion, + recordedTrack, + trackPaddlePlan, + paddleChecklist, + } = workspace; return wanderer.open ? ( ) : ( @@ -37,6 +47,7 @@ export default function TourWorkspaceRail({ conversion={tourConversion} recordedTrack={recordedTrack} trackPaddlePlan={trackPaddlePlan} + paddleChecklist={paddleChecklist} onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined} /> ); diff --git a/client/src/components/Tours/paddle/ObstacleDecisionPanel.test.tsx b/client/src/components/Tours/paddle/ObstacleDecisionPanel.test.tsx new file mode 100644 index 000000000..ae264f8df --- /dev/null +++ b/client/src/components/Tours/paddle/ObstacleDecisionPanel.test.tsx @@ -0,0 +1,449 @@ +// FE-PADDLE-DECIDE-001 to FE-PADDLE-DECIDE-016: the decision panel of one checklist entry (PAD-06). +// FE-PADDLE-DECIDE-017 to FE-PADDLE-DECIDE-020: the panel below a paddle object's map card, wired to the checklist. +import type { ComponentProps } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ObstacleDecision, ObstacleDecisionRecord, TourObstacleCheck, TourObstacleEntry } from '@trek/shared'; + +import { buildSettings } from '../../../../tests/helpers/factories'; +import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { seedStore } from '../../../../tests/helpers/store'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel'; +import { formatCheckDate } from './paddleCheckModel'; +import type { PaddleChecklist } from './usePaddleChecklist'; + +/** What the panel says, as shared/src/i18n/en/tours.ts words it. */ +const SAYS = { + bans: 'OpenStreetMap records a ban or private ground here. A decision does not lift it.', + portageMissing: 'Choosing it plans no portage. Plan the carry on the map.', + gone: 'Missing from the data is no proof that the obstacle is gone.', + supLock: 'SUP: check a portage', + supLockInfo: + 'For SUP, the German canoe federation and rescue organisations advise checking a permitted portage at locks rather than locking through. This is advice, not a ban everywhere, and local rules still apply.', +}; + +/** The four choices in the order the panel offers them. */ +const CHOICES = ['Paddle through', 'Carry round', 'Go round', 'Not affected']; + +const DECIDED_AT = '2026-09-20T10:00:00.000Z'; +/** The day a decision was made, with the spaces the DOM queries read. */ +const DECIDED_ON = formatCheckDate(DECIDED_AT, 'en').replace(/\s/g, ' '); + +/** Paddling forbidden by the canoe tag. */ +const BANNED: TourObstacleEntry['access'] = { + value: 'no', + source: 'canoe', + condition: null, + forbidden: true, + private: false, +}; + +function record(decision: ObstacleDecision, note: string | null = null): ObstacleDecisionRecord { + return { decision, note, decided_at: DECIDED_AT, decided_by: 1 }; +} + +/** The weir on the route, decided about. */ +function decidedEntry(decision: ObstacleDecision = 'pass', note: string | null = null): TourObstacleEntry { + return obstacleEntry({ status: 'decided', decision: record(decision, note) }); +} + +type PanelProps = ComponentProps; + +const onDecide = vi.fn(); +const onNote = vi.fn(); +const onClear = vi.fn(); +const onKeep = vi.fn(); +/** Trip member 1 is Anna; anyone else has left the trip. */ +const memberName = (id: number | null) => (id === 1 ? 'Anna' : null); + +/** The panel for an entry a paddler may decide about, nothing to move on to and no portage to plan. */ +function panel(entry: TourObstacleEntry, props: Partial = {}) { + return ( + + ); +} + +const renderPanel = (entry: TourObstacleEntry, props: Partial = {}) => render(panel(entry, props)); + +/** The notices the panel shows; it holds one at least. */ +const notices = () => screen.getByTestId('obstacle-notices'); + +/** + * A checklist as usePaddleChecklist hands it to the panel: ready, open to + * decisions, nothing saving, entries found by their refs, every action a spy. + */ +function checklistOf( + check: TourObstacleCheck | null = obstacleCheck(), + overrides: Partial = {} +): PaddleChecklist { + return { + status: 'ready', + tour: null, + check, + summary: null, + stale: false, + dirty: false, + canDecide: true, + savingKey: null, + selectedKey: null, + dialogKey: null, + entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null, + select: vi.fn(), + selectOwn: vi.fn(), + closeDialog: vi.fn(), + nextOf: () => null, + next: vi.fn(), + planPortage: vi.fn(), + decide: vi.fn(() => Promise.resolve()), + setNote: vi.fn(() => Promise.resolve()), + clear: vi.fn(() => Promise.resolve()), + keep: vi.fn(() => Promise.resolve()), + memberName, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + seedStore(useSettingsStore, { settings: buildSettings() }); +}); + +describe('ObstacleDecisionPanel', () => { + it('FE-PADDLE-DECIDE-001: an open entry offers the four choices as one radio group named after it, none checked', () => { + const { rerender } = renderPanel(obstacleEntry()); + + const group = screen.getByRole('radiogroup', { name: 'Wehr Regendorf' }); + const radios = within(group).getAllByRole('radio'); + expect(radios.map((radio) => radio.textContent)).toEqual(CHOICES); + for (const radio of radios) { + expect(radio).toHaveAttribute('aria-checked', 'false'); + expect(radio).toBeEnabled(); + } + // Nothing decided yet: no note to add, nothing to remove, nothing to warn about. + expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove decision' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument(); + + // An unnamed object names the group by its kind. + rerender(panel(obstacleEntry({ name: null, kind: 'dam' }))); + expect(screen.getByRole('radiogroup', { name: 'Dam' })).toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-002: a choice decides; the decided one is filled and checked, and choosing it again sends nothing', () => { + const { rerender } = renderPanel(obstacleEntry()); + + fireEvent.click(screen.getByRole('radio', { name: 'Carry round' })); + expect(onDecide).toHaveBeenCalledTimes(1); + expect(onDecide).toHaveBeenCalledWith('portage'); + + rerender(panel(decidedEntry('portage'))); + const chosen = screen.getByRole('radio', { name: 'Carry round' }); + expect(chosen).toHaveAttribute('aria-checked', 'true'); + expect(chosen).toHaveClass('bg-accent', 'text-accent-text'); + expect(screen.getByRole('radio', { name: 'Paddle through' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('radio', { name: 'Paddle through' })).not.toHaveClass('bg-accent'); + + fireEvent.click(chosen); + expect(onDecide).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('radio', { name: 'Not affected' })); + expect(onDecide).toHaveBeenLastCalledWith('not_affected'); + }); + + it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => { + renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') })); + + const earlier = screen.getByRole('radio', { name: 'Paddle through' }); + expect(earlier).toHaveAttribute('aria-checked', 'false'); + expect(earlier).toHaveClass('border-dashed'); + expect(earlier).not.toHaveClass('bg-accent'); + for (const name of CHOICES.slice(1)) { + expect(screen.getByRole('radio', { name })).not.toHaveClass('border-dashed'); + } + expect(screen.getByTestId('obstacle-decision-panel')).toHaveTextContent('Route changed'); + + // The earlier choice can be made again, against the route as it is now. + fireEvent.click(earlier); + expect(onDecide).toHaveBeenCalledWith('pass'); + }); + + it('FE-PADDLE-DECIDE-004: a ban or private ground in the data stays a notice after every decision', () => { + const { rerender } = renderPanel(obstacleEntry({ access: BANNED })); + expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument(); + + rerender(panel(obstacleEntry({ access: BANNED, status: 'decided', decision: record('pass') }))); + expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument(); + + const privateGround = { ...BANNED, value: 'private', forbidden: false, private: true }; + rerender(panel(obstacleEntry({ access: privateGround, status: 'decided', decision: record('not_affected') }))); + expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-005: a lock advised for SUP says so, with the explanation behind an info dot', () => { + const lock = { kind: 'lock' as const, name: 'Schleuse Regensburg' }; + const { rerender } = renderPanel(obstacleEntry({ ...lock, advice: 'sup_lock_portage' })); + + expect(within(notices()).getByText(SAYS.supLock)).toBeInTheDocument(); + expect(within(notices()).getByRole('button', { name: SAYS.supLock })).toHaveAccessibleDescription(SAYS.supLockInfo); + + rerender(panel(obstacleEntry(lock))); + expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-006: carrying round with no portage planned yet says so and offers to plan it', () => { + const onPlanPortage = vi.fn<() => void>(); + const todo = obstacleEntry({ status: 'todo', decision: record('portage') }); + const { rerender } = renderPanel(todo, { onPlanPortage }); + + expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument(); + fireEvent.click(within(notices()).getByRole('button', { name: 'Plan the portage' })); + expect(onPlanPortage).toHaveBeenCalledTimes(1); + + // Without a way to plan it the notice stays, without the action. + rerender(panel(todo)); + expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument(); + + // Going round while the route still passes is no missing portage. + rerender(panel(obstacleEntry({ status: 'todo', decision: record('reroute') }), { onPlanPortage })); + expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-007: never more than two notices; the ban and the missing portage come before the SUP advice', () => { + renderPanel( + obstacleEntry({ + kind: 'lock', + advice: 'sup_lock_portage', + access: BANNED, + status: 'todo', + decision: record('portage'), + }), + { onPlanPortage: vi.fn<() => void>() } + ); + + const shown = notices(); + expect(within(shown).getByText(SAYS.bans)).toBeInTheDocument(); + expect(within(shown).getByText(SAYS.portageMissing)).toBeInTheDocument(); + expect(within(shown).queryByText(SAYS.supLock)).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-008: the check on site the data records reads as a month, and as unknown when there is none', () => { + const { rerender } = renderPanel(obstacleEntry({ local_check: '2025-06' })); + + const badge = screen.getByTestId('obstacle-on-site'); + expect(badge).toHaveTextContent('On site'); + expect(badge).toHaveTextContent(formatCheckDate('2025-06', 'en').replace(/\s/g, ' ')); + + rerender(panel(obstacleEntry())); + expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown'); + }); + + it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', () => { + renderPanel(decidedEntry()); + + fireEvent.click(screen.getByRole('button', { name: 'Note' })); + const box = screen.getByRole('textbox', { name: 'Note' }); + expect(box).toHaveAttribute('rows', '2'); + expect(box).toHaveAttribute('maxlength', '500'); + expect(box).toHaveFocus(); + + fireEvent.change(box, { target: { value: ' Links an der Bootsrutsche ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onNote).toHaveBeenCalledTimes(1); + expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => { + renderPanel(decidedEntry('pass', 'Bootsrutsche am linken Ufer')); + + expect(screen.getByText('Bootsrutsche am linken Ufer')).toHaveClass('line-clamp-3'); + expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + const box = screen.getByRole('textbox', { name: 'Note' }); + expect(box).toHaveValue('Bootsrutsche am linken Ufer'); + fireEvent.change(box, { target: { value: 'Anders' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onNote).not.toHaveBeenCalled(); + expect(screen.getByText('Bootsrutsche am linken Ufer')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onNote).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onNote).toHaveBeenCalledWith(null); + }); + + it('FE-PADDLE-DECIDE-011: Escape in the note ends the edit and goes no further, so the map card around it stays open', () => { + const parent = vi.fn<() => void>(); + const onDocument = vi.fn<(event: KeyboardEvent) => void>(); + document.addEventListener('keydown', onDocument); + try { + render(
{panel(decidedEntry())}
); + + fireEvent.click(screen.getByRole('button', { name: 'Note' })); + const box = screen.getByRole('textbox', { name: 'Note' }); + // Any other key reaches the card and the page as always. + fireEvent.keyDown(box, { key: 'a' }); + expect(parent).toHaveBeenCalledTimes(1); + expect(onDocument).toHaveBeenCalledTimes(1); + + const delivered = fireEvent.keyDown(box, { key: 'Escape' }); + expect(delivered).toBe(false); + expect(parent).toHaveBeenCalledTimes(1); + expect(onDocument).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(onNote).not.toHaveBeenCalled(); + } finally { + document.removeEventListener('keydown', onDocument); + } + }); + + it('FE-PADDLE-DECIDE-012: the foot says who decided when, removes the decision and moves on to the next entry', () => { + const onNext = vi.fn<() => void>(); + const { rerender } = renderPanel(decidedEntry(), { onNext }); + + expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove decision' })); + expect(onClear).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalledTimes(1); + + // Someone who left the trip leaves the date alone; without a next entry there is no way on. + rerender(panel(obstacleEntry({ status: 'decided', decision: { ...record('pass'), decided_by: 7 } }))); + expect(screen.getByText(`Decided ${DECIDED_ON}`)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + + // An open entry moves on all the same. + rerender(panel(obstacleEntry(), { onNext })); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + expect(screen.queryByText(/^Decided/)).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-013: an object the data no longer holds offers keeping or removing its decision, no choices', () => { + renderPanel(earlierEntry()); + + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(screen.getByText(SAYS.gone)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Keep decision' })); + expect(onKeep).toHaveBeenCalledTimes(1); + // One way to remove it: the foot leaves it to the actions. + fireEvent.click(screen.getByRole('button', { name: 'Remove decision' })); + expect(onClear).toHaveBeenCalledTimes(1); + expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-014: an earlier decision the route no longer meets has no choices to make, yet can be removed', () => { + renderPanel(earlierEntry({ status: 'off_route' })); + + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(screen.queryByText(SAYS.gone)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Keep decision' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove decision' })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-DECIDE-015: a viewer reads the panel: the choices, the note and removing are off, moving on is not', () => { + const onNext = vi.fn<() => void>(); + renderPanel(decidedEntry('pass', 'Bootsrutsche'), { canDecide: false, onNext }); + + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled(); + fireEvent.click(screen.getByRole('radio', { name: 'Go round' })); + expect(onDecide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + }); + + it('FE-PADDLE-DECIDE-016: while its change saves the choices wait, and a viewer cannot keep or remove a vanished one', () => { + const { rerender } = renderPanel(obstacleEntry(), { saving: true }); + + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled(); + expect(screen.getByTestId('obstacle-decision-panel')).toHaveAttribute('aria-busy', 'true'); + + rerender(panel(earlierEntry(), { canDecide: false })); + expect(screen.getByRole('button', { name: 'Keep decision' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled(); + }); +}); + +describe('ChecklistObstaclePanel', () => { + it('FE-PADDLE-DECIDE-017: shows nothing for an object the checklist does not list or while it is not ready', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + rerender( + + ); + expect(container).toBeEmptyDOMElement(); + + rerender(); + expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', () => { + const entry = obstacleEntry({ status: 'todo', decision: record('portage') }); + const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 }); + const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), { + nextOf: (item) => (item.key === entry.key ? following : null), + }); + render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Go round' })); + expect(checklist.decide).toHaveBeenCalledWith(entry, 'reroute'); + fireEvent.click(screen.getByRole('button', { name: 'Plan the portage' })); + expect(checklist.planPortage).toHaveBeenCalledWith(entry); + + fireEvent.click(screen.getByRole('button', { name: 'Note' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts aussteigen' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(checklist.setNote).toHaveBeenCalledWith(entry, 'Rechts aussteigen'); + + fireEvent.click(screen.getByRole('button', { name: 'Remove decision' })); + expect(checklist.clear).toHaveBeenCalledWith(entry); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(checklist.next).toHaveBeenCalledWith(entry); + expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => { + const gone = earlierEntry(); + const checklist = checklistOf(obstacleCheck({ entries: [obstacleEntry(), gone] })); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Keep decision' })); + expect(checklist.keep).toHaveBeenCalledWith(gone); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-DECIDE-020: the entry whose change is saving waits, as every entry does when the checklist cannot decide', () => { + const check = obstacleCheck({ entries: [obstacleEntry(), obstacleEntry({ key: 'node/2', refs: ['node/2'] })] }); + const { rerender } = render( + + ); + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeEnabled(); + + rerender(); + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled(); + + rerender(); + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled(); + }); +}); diff --git a/client/src/components/Tours/paddle/ObstacleDecisionPanel.tsx b/client/src/components/Tours/paddle/ObstacleDecisionPanel.tsx new file mode 100644 index 000000000..517dcf5fe --- /dev/null +++ b/client/src/components/Tours/paddle/ObstacleDecisionPanel.tsx @@ -0,0 +1,439 @@ +import { + OBSTACLE_DECISIONS, + type ObstacleDecision, + type ObstacleDecisionRecord, + type TourObstacleEntry, +} from '@trek/shared'; +import { + ArrowRight, + Ban, + Check, + LifeBuoy, + MoveRight, + Pencil, + SearchX, + Split, + StickyNote, + Trash2, + Unlink, + X, +} from 'lucide-react'; +import { useState, type KeyboardEvent, type ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { CardAction, InfoDot, TourNotice } from '../tourParts'; +import { FigureBadge } from './FigureBadge'; +import { ObstacleStatusBadges } from './PaddleCheckBadges'; +import { PortageIcon } from './PaddleGlyph'; +import { formatCheckDate } from './paddleCheckModel'; +import type { PaddleChecklist } from './usePaddleChecklist'; + +type Translate = (key: string, params?: Record) => string; + +/** The longest note a decision keeps, as the checklist contract allows it. */ +const NOTE_MAX = 500; + +/** The notices an entry can call for, in the order they are shown: a ban first, since no decision lifts it. */ +const NOTICE_ORDER = ['bans', 'portage', 'sup'] as const; +type NoticeId = (typeof NOTICE_ORDER)[number]; +/** The most notices the panel shows at once. */ +const MAX_NOTICES = 2; + +const CHOICE_ICON: Record = { + pass: , + portage: , + reroute: , + not_affected: , +}; + +/** How a choice looks: decided, decided before the route or the data changed, or not chosen. */ +type ChoiceLook = 'chosen' | 'earlier' | 'idle'; + +const CHOICE_LOOK: Record = { + chosen: 'border-transparent bg-accent text-accent-text', + earlier: 'border-dashed border-accent bg-surface-card text-content', + idle: 'border-edge-faint bg-surface-card text-content-secondary enabled:hover:text-content', +}; + +const CHOICE = + 'flex min-w-0 items-center gap-1.5 rounded-[10px] border px-2.5 py-1.5 text-start font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50'; +const QUIET = + 'rounded-sm font-semibold text-content-muted underline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content enabled:hover:underline disabled:cursor-default disabled:opacity-40'; +const ICON_BUTTON = + 'grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-surface-hover enabled:hover:text-content disabled:cursor-default disabled:opacity-40'; +const SAVE_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-full bg-accent px-2.5 font-semibold text-accent-text shadow-sm transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:opacity-90 disabled:cursor-default disabled:opacity-40'; + +/** The four choices as a radio group of two by two: the decided one filled, in review the earlier one dashed. */ +function DecisionChoices({ + entry, + label, + disabled, + onDecide, +}: { + entry: TourObstacleEntry; + label: string; + disabled: boolean; + onDecide: (decision: ObstacleDecision) => void; +}) { + const { t } = useTranslation(); + const decided = entry.decision?.decision ?? null; + // In review the earlier choice no longer holds: it is shown, not checked, and can be made again. + const reviewing = entry.status === 'review'; + const lookOf = (choice: ObstacleDecision): ChoiceLook => { + if (choice !== decided) return 'idle'; + return reviewing ? 'earlier' : 'chosen'; + }; + return ( +
+ {OBSTACLE_DECISIONS.map((choice) => { + const look = lookOf(choice); + return ( + + ); + })} +
+ ); +} + +/** Which notices an entry calls for, at most MAX_NOTICES of them. */ +function noticesOf(entry: TourObstacleEntry): NoticeId[] { + const wanted: Record = { + bans: entry.access.forbidden || entry.access.private, + portage: entry.status === 'todo' && entry.decision?.decision === 'portage', + sup: entry.advice === 'sup_lock_portage', + }; + return NOTICE_ORDER.filter((id) => wanted[id]).slice(0, MAX_NOTICES); +} + +/** What stays true whatever was chosen: a ban in the data, a portage not planned yet, the SUP advice at a lock. */ +function PanelNotices({ entry, onPlanPortage }: { entry: TourObstacleEntry; onPlanPortage: (() => void) | null }) { + const { t } = useTranslation(); + const shown = noticesOf(entry); + if (shown.length === 0) return null; + const notices: Record = { + bans: ( + }> + {t('tours.paddle.check.bansInfo')} + + ), + portage: ( + }> +
+ {t('tours.paddle.check.portageMissingInfo')} + {onPlanPortage && ( + } onClick={onPlanPortage}> + {t('tours.paddle.check.planPortage')} + + )} +
+
+ ), + sup: ( + }> +
+ {t('tours.paddle.check.supLock')} + + + +
+
+ ), + }; + return ( +
+ {shown.map((id) => notices[id])} +
+ ); +} + +/** An entry the data no longer holds: why that proves nothing, and keeping or removing its decision. */ +function GoneActions({ disabled, onKeep, onClear }: { disabled: boolean; onKeep: () => void; onClear: () => void }) { + const { t } = useTranslation(); + return ( +
+ }>{t('tours.paddle.check.goneInfo')} +
+ } onClick={onKeep} disabled={disabled}> + {t('tours.paddle.check.keep')} + + } onClick={onClear} disabled={disabled}> + {t('tours.paddle.check.clear')} + +
+
+ ); +} + +/** Writing the note in two rows; Escape drops the edit without closing the map card or the dialog around it. */ +function NoteEditor({ + draft, + disabled, + onChange, + onSave, + onCancel, +}: { + draft: string; + disabled: boolean; + onChange: (draft: string) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + // The map card and the dialog close on Escape; inside the note it only ends the edit. + event.preventDefault(); + event.stopPropagation(); + onCancel(); + }; + return ( +
+