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 ( +
+