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.
This commit is contained in:
Maurice
2026-10-17 01:16:41 +00:00
parent 14b917ee97
commit de86e6383a
37 changed files with 4341 additions and 79 deletions
@@ -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(<TourDayRowPills tour={kayak} />);
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(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'none', open: 3 }) }} />);
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(<TourDayRowFacts tour={kayak} mobile={mobile} />);
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(
<>
<TourDayRowPills tour={item} />
<TourDayRowFacts tour={item} />
</>
);
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
});
});
@@ -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<typeof paddleCheckFact>[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 && <TourDayRowPill {...check} testId="plan-tour-paddle-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 (
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
{facts.map((fact) => (
@@ -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(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', paddle_check: paddleBench() }} />)
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(<TourDetailDialog {...defaultProps} tour={{ ...tour, paddle_check: paddleBench() }} />)
expect(screen.queryByText('Checklist')).not.toBeInTheDocument()
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument()
})
})
@@ -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' && <PaddleCheckSection tour={tour} />}
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
{description && (
@@ -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(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
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(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
});
});
@@ -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 ? (
<WandererBrowserRail browser={wanderer} />
) : (
@@ -37,6 +47,7 @@ export default function TourWorkspaceRail({
conversion={tourConversion}
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
@@ -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<typeof ObstacleDecisionPanel>;
const onDecide = vi.fn<PanelProps['onDecide']>();
const onNote = vi.fn<PanelProps['onNote']>();
const onClear = vi.fn<PanelProps['onClear']>();
const onKeep = vi.fn<PanelProps['onKeep']>();
/** 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<PanelProps> = {}) {
return (
<ObstacleDecisionPanel
entry={entry}
canDecide
saving={false}
memberName={memberName}
onDecide={onDecide}
onNote={onNote}
onClear={onClear}
onKeep={onKeep}
onNext={null}
onPlanPortage={null}
{...props}
/>
);
}
const renderPanel = (entry: TourObstacleEntry, props: Partial<PanelProps> = {}) => 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> = {}
): 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<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve()),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve()),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve()),
keep: vi.fn<PaddleChecklist['keep']>(() => 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(<div onKeyDown={parent}>{panel(decidedEntry())}</div>);
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(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
expect(container).toBeEmptyDOMElement();
rerender(
<ChecklistObstaclePanel checklist={checklistOf(obstacleCheck(), { status: 'loading' })} featureRef="node/1" />
);
expect(container).toBeEmptyDOMElement();
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
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(<ChecklistObstaclePanel checklist={checklist} featureRef="node/1" />);
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(<ChecklistObstaclePanel checklist={checklist} featureRef="node/9" />);
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(
<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/2' })} featureRef="node/1" />
);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeEnabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/1' })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { canDecide: false })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
});
});
@@ -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, string | number>) => 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<ObstacleDecision, ReactNode> = {
pass: <MoveRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />,
portage: <PortageIcon size={14} />,
reroute: <Split size={14} strokeWidth={2.2} />,
not_affected: <Unlink size={14} strokeWidth={2.2} />,
};
/** 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<ChoiceLook, string> = {
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 (
<div role="radiogroup" aria-label={label} className="grid grid-cols-2 gap-1.5">
{OBSTACLE_DECISIONS.map((choice) => {
const look = lookOf(choice);
return (
<button
key={choice}
type="button"
role="radio"
aria-checked={look === 'chosen'}
disabled={disabled}
onClick={() => {
if (look !== 'chosen') onDecide(choice);
}}
className={`${CHOICE} ${CHOICE_LOOK[look]}`}
style={fs(11.5, 'body')}
>
<span className="flex-none">{CHOICE_ICON[choice]}</span>
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
</button>
);
})}
</div>
);
}
/** Which notices an entry calls for, at most MAX_NOTICES of them. */
function noticesOf(entry: TourObstacleEntry): NoticeId[] {
const wanted: Record<NoticeId, boolean> = {
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<NoticeId, ReactNode> = {
bans: (
<TourNotice key="bans" icon={<Ban size={13} strokeWidth={2} />}>
{t('tours.paddle.check.bansInfo')}
</TourNotice>
),
portage: (
<TourNotice key="portage" icon={<PortageIcon size={13} />}>
<div className="flex flex-col items-start gap-1.5">
<span>{t('tours.paddle.check.portageMissingInfo')}</span>
{onPlanPortage && (
<CardAction icon={<PortageIcon size={12} />} onClick={onPlanPortage}>
{t('tours.paddle.check.planPortage')}
</CardAction>
)}
</div>
</TourNotice>
),
sup: (
<TourNotice key="sup" icon={<LifeBuoy size={13} strokeWidth={2} />}>
<div className="flex items-center gap-1">
<span className="min-w-0">{t('tours.paddle.check.supLock')}</span>
<span className="-my-1 flex-none">
<InfoDot label={t('tours.paddle.check.supLock')} description={t('tours.paddle.check.supLockInfo')} />
</span>
</div>
</TourNotice>
),
};
return (
<div data-testid="obstacle-notices" className="flex flex-col gap-2">
{shown.map((id) => notices[id])}
</div>
);
}
/** 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 (
<div className="flex flex-col gap-2">
<TourNotice icon={<SearchX size={13} strokeWidth={2} />}>{t('tours.paddle.check.goneInfo')}</TourNotice>
<div className="flex flex-wrap items-center gap-1.5">
<CardAction icon={<Check size={12} strokeWidth={2.4} />} onClick={onKeep} disabled={disabled}>
{t('tours.paddle.check.keep')}
</CardAction>
<CardAction icon={<Trash2 size={12} strokeWidth={2.2} />} onClick={onClear} disabled={disabled}>
{t('tours.paddle.check.clear')}
</CardAction>
</div>
</div>
);
}
/** 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<HTMLTextAreaElement>) => {
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 (
<div className="flex flex-col gap-1.5">
<textarea
rows={2}
maxLength={NOTE_MAX}
value={draft}
autoFocus
aria-label={t('tours.paddle.check.note')}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
className="w-full resize-none rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-content outline-none focus:border-accent"
style={fs(12, 'body')}
/>
<div className="flex items-center justify-end gap-1.5">
<CardAction icon={<X size={12} strokeWidth={2.4} />} onClick={onCancel}>
{t('common.cancel')}
</CardAction>
<button type="button" onClick={onSave} disabled={disabled} className={SAVE_BUTTON} style={fs(11.5, 'body')}>
<Check size={12} strokeWidth={2.4} aria-hidden="true" />
{t('common.save')}
</button>
</div>
</div>
);
}
/** The decision's note: read clamped with a way to edit it, or a way to add one. */
function NoteBlock({
note,
disabled,
onNote,
}: {
note: string | null;
disabled: boolean;
onNote: (note: string | null) => void;
}) {
const { t } = useTranslation();
// The text being written, or null while the note is only read.
const [draft, setDraft] = useState<string | null>(null);
const save = (text: string) => {
const next = text.trim() || null;
if (next !== note) onNote(next);
setDraft(null);
};
if (draft !== null)
return (
<NoteEditor
draft={draft}
disabled={disabled}
onChange={setDraft}
onSave={() => save(draft)}
onCancel={() => setDraft(null)}
/>
);
if (!note)
return (
<div>
<CardAction icon={<StickyNote size={12} strokeWidth={2.2} />} onClick={() => setDraft('')} disabled={disabled}>
{t('tours.paddle.check.note')}
</CardAction>
</div>
);
return (
<div className="flex items-start gap-1.5">
<p
className="m-0 line-clamp-3 min-w-0 flex-1 break-words text-content-muted"
style={fs(11.5, 'body')}
title={note}
>
{note}
</p>
<Tooltip label={t('common.edit')}>
<button
type="button"
aria-label={t('common.edit')}
onClick={() => setDraft(note)}
disabled={disabled}
className={ICON_BUTTON}
>
<Pencil size={12} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
</div>
);
}
/** Who decided and when: the trip member by name while they are on the trip, otherwise the date alone. */
function decidedLine(
decision: ObstacleDecisionRecord,
memberName: (id: number | null) => string | null,
t: Translate,
locale: string
): string {
const date = formatCheckDate(decision.decided_at, locale);
const name = memberName(decision.decided_by);
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
}
/** The foot of the panel: who decided when, removing the decision, and the way on to the next open entry. */
function MetaLine({
decision,
memberName,
clearable,
disabled,
onClear,
onNext,
}: {
decision: ObstacleDecisionRecord | null;
memberName: (id: number | null) => string | null;
clearable: boolean;
disabled: boolean;
onClear: () => void;
onNext: (() => void) | null;
}) {
const { t, locale } = useTranslation();
if (!decision && !onNext) return null;
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-content-faint" style={fs(10.5, 'body')}>
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale)}</span>}
{decision && clearable && (
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
{t('tours.paddle.check.clear')}
</button>
)}
{onNext && (
<span className="ms-auto">
<CardAction icon={<ArrowRight size={12} strokeWidth={2.4} className="rtl:rotate-180" />} onClick={onNext}>
{t('tours.paddle.check.next')}
</CardAction>
</span>
)}
</div>
);
}
/**
* PAD-06: what the paddler decides about one obstacle of the checklist, below
* its map card or in its dialog. The four choices, the notices that stay
* whatever was chosen (a ban in the data, a portage not planned yet, the SUP
* advice at a lock), the check on site the data records, a note, and who
* decided when. An object the data no longer holds offers keeping or removing
* its decision instead of the choices. A decision is the paddler's choice,
* never a safety or legal clearance.
*/
export function ObstacleDecisionPanel({
entry,
canDecide,
saving,
memberName,
onDecide,
onNote,
onClear,
onKeep,
onNext,
onPlanPortage,
}: {
entry: TourObstacleEntry;
canDecide: boolean;
/** This entry's change is on its way to the server. */
saving: boolean;
memberName: (id: number | null) => string | null;
onDecide: (decision: ObstacleDecision) => void;
onNote: (note: string | null) => void;
onClear: () => void;
onKeep: () => void;
/** Moves on to the next entry still asking for a look; null when there is none. */
onNext: (() => void) | null;
/** Starts planning the portage round the obstacle; null where that cannot be offered. */
onPlanPortage: (() => void) | null;
}) {
const { t, locale } = useTranslation();
const locked = !canDecide || saving;
const gone = entry.status === 'gone';
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
return (
<div data-testid="obstacle-decision-panel" aria-busy={saving || undefined} className="flex flex-col gap-2.5">
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
<ObstacleStatusBadges entry={entry} />
</div>
{entry.relation !== null && <DecisionChoices entry={entry} label={label} disabled={locked} onDecide={onDecide} />}
{gone && <GoneActions disabled={locked} onKeep={onKeep} onClear={onClear} />}
<PanelNotices entry={entry} onPlanPortage={onPlanPortage} />
<div className="flex">
<FigureBadge
testId="obstacle-on-site"
label={t('tours.paddle.check.onSite')}
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
/>
</div>
{entry.decision && <NoteBlock note={entry.decision.note} disabled={locked} onNote={onNote} />}
<MetaLine
decision={entry.decision}
memberName={memberName}
clearable={!gone}
disabled={locked}
onClear={onClear}
onNext={onNext}
/>
</div>
);
}
/** The decision panel of one checklist entry wired to the checklist; the map card and the entry dialog share it. */
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
return (
<ObstacleDecisionPanel
key={entry.key}
entry={entry}
canDecide={checklist.canDecide}
saving={checklist.savingKey === entry.key}
memberName={checklist.memberName}
onDecide={(decision) => void checklist.decide(entry, decision)}
onNote={(note) => void checklist.setNote(entry, note)}
onClear={() => void checklist.clear(entry)}
onKeep={() => void checklist.keep(entry)}
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
onPlanPortage={() => checklist.planPortage(entry)}
/>
);
}
/**
* The checklist's part of the map card of a paddle object (PAD-06): the
* decision panel of the entry the object belongs to, below its facts, or
* nothing while the checklist is not ready or does not list the object.
*/
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
const entry = checklist.status === 'ready' ? checklist.entryForRef(featureRef) : null;
if (!entry) return null;
return (
<div className="mt-3 border-t border-edge-faint pt-3">
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</div>
);
}
@@ -0,0 +1,158 @@
// FE-PADDLE-ENTRY-DIALOG-001 to FE-PADDLE-ENTRY-DIALOG-007: the dialog of a checklist entry the map holds no card for.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { 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 { ObstacleEntryDialog } from './ObstacleEntryDialog';
import type { PaddleChecklist } from './usePaddleChecklist';
const OSM = 'View in OpenStreetMap';
/**
* A checklist as usePaddleChecklist hands it to the dialog: ready, open to
* decisions, no dialog open unless told, every action a spy.
*/
function checklistOf(
check: TourObstacleCheck | null = obstacleCheck(),
overrides: Partial<PaddleChecklist> = {}
): 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<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve()),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve()),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve()),
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve()),
memberName: (id) => (id === 1 ? 'Anna' : null),
...overrides,
};
}
/** The dialog opened for an entry the way the checklist opens it: by its key. */
function openFor(entry: TourObstacleEntry): PaddleChecklist {
const checklist = checklistOf(obstacleCheck({ entries: [entry] }), { dialogKey: entry.key });
render(<ObstacleEntryDialog checklist={checklist} />);
return checklist;
}
/** The head band of the dialog, apart from the panel in its body. */
function headerOf(dialog: HTMLElement): HTMLElement {
const header = dialog.querySelector('header');
if (!header) throw new Error('the dialog has no head band');
return header;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the dialog reads metric.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('ObstacleEntryDialog', () => {
it('FE-PADDLE-ENTRY-DIALOG-001: stays closed without a dialog key, for a key the checklist lacks and without a checklist', () => {
const { rerender } = render(<ObstacleEntryDialog checklist={checklistOf()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(obstacleCheck(), { dialogKey: 'node/404' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(null, { dialogKey: 'node/1' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-ENTRY-DIALOG-002: names the obstacle and its kind, how far along the route it lies, and links to OSM', () => {
openFor(obstacleEntry());
const header = headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }));
expect(header).toHaveTextContent('Weir');
expect(header).toHaveTextContent('1.2 km');
const link = within(header).getByRole('link', { name: OSM });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/node/1');
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('rel')).toContain('noreferrer');
});
it('FE-PADDLE-ENTRY-DIALOG-003: its body is the decision panel, wired to the checklist', () => {
const entry = obstacleEntry();
const checklist = openFor(entry);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
expect(within(dialog).getByTestId('obstacle-decision-panel')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('radio', { name: 'Carry round' }));
expect(checklist.decide).toHaveBeenCalledWith(entry, 'portage');
});
it('FE-PADDLE-ENTRY-DIALOG-004: an object the data no longer holds gives no distance and offers keeping its decision', () => {
const gone = earlierEntry();
const checklist = openFor(gone);
const dialog = screen.getByRole('dialog', { name: 'Alte Sohlschwelle' });
const header = headerOf(dialog);
expect(header).not.toHaveTextContent('km');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/node/9'
);
expect(within(dialog).queryByRole('radiogroup')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep decision' }));
expect(checklist.keep).toHaveBeenCalledWith(gone);
});
it('FE-PADDLE-ENTRY-DIALOG-005: an unnamed obstacle is titled by its kind and links its nearest object', () => {
openFor(obstacleEntry({ key: 'lock:way/42', refs: ['way/42', 'way/43'], kind: 'lock', name: null, along_m: 800 }));
const header = headerOf(screen.getByRole('dialog', { name: 'Lock' }));
expect(within(header).getByRole('heading', { name: 'Lock' })).toBeInTheDocument();
expect(header).toHaveTextContent('800 m');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/way/42'
);
});
it('FE-PADDLE-ENTRY-DIALOG-006: the distance follows the chosen unit', () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
openFor(obstacleEntry());
expect(headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }))).toHaveTextContent('0.7 mi');
});
it('FE-PADDLE-ENTRY-DIALOG-007: the close button and Escape close it, while Escape in the note only ends the note', () => {
const decided = obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: null, decided_at: '2026-09-20T10:00:00.000Z', decided_by: 1 },
});
const checklist = openFor(decided);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Note' }));
fireEvent.keyDown(within(dialog).getByRole('textbox', { name: 'Note' }), { key: 'Escape' });
expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument();
expect(checklist.closeDialog).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: 'Escape' });
expect(checklist.closeDialog).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
expect(checklist.closeDialog).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,75 @@
import type { TourObstacleEntry } from '@trek/shared';
import { ExternalLink } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../../shared/DialogShell';
import { ChecklistEntryPanel } from './ObstacleDecisionPanel';
import { ChecklistGlyph } from './PaddleCheckBadges';
import { osmUrlOf } from './paddleObstacleModel';
import type { PaddleChecklist } from './usePaddleChecklist';
/** The pills of the head band: how far along the route the object lies and the object on OpenStreetMap. */
function EntryPills({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const ref = entry.refs[0];
return (
<>
{entry.along_m !== null && (
<span className={`${PILL} tabular-nums`}>{formatDistance(entry.along_m / 1000, unit)}</span>
)}
{ref && (
<a href={osmUrlOf(ref)} target="_blank" rel="noopener noreferrer" className={`${PILL} hover:underline`}>
{t('ferratas.viewInOsm')}
<ExternalLink size={12} strokeWidth={2.2} aria-hidden="true" />
</a>
)}
</>
);
}
/** The dialog for an entry the checklist holds: its head band, then the decision panel. */
function EntryDialog({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
const { t } = useTranslation();
const labelId = useId();
const kind = t(`tours.paddle.obstacle.kind.${entry.kind}`);
return (
<DialogShell
onClose={checklist.closeDialog}
labelledBy={labelId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<ChecklistGlyph kind={entry.kind} dashed={entry.relation === 'near'} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={checklist.closeDialog}
title={entry.name ?? kind}
sub={entry.name ? kind : undefined}
pills={<EntryPills entry={entry} />}
/>
}
>
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</DialogShell>
);
}
/**
* PAD-06: one entry of the checklist in a narrow dialog, for an object the
* map holds no card for (offline, or an earlier decision the data or the
* route no longer carries): what it is, how far along the route it lies, its
* object on OpenStreetMap and the same decision panel the map card carries.
*/
export function ObstacleEntryDialog({ checklist }: { checklist: PaddleChecklist }) {
const { check, dialogKey } = checklist;
const entry = dialogKey === null ? null : (check?.entries.find((item) => item.key === dialogKey) ?? null);
if (!entry) return null;
return <EntryDialog checklist={checklist} entry={entry} />;
}
@@ -0,0 +1,421 @@
// PCB-001 to PCB-033: the badges of a paddle tour's checklist (PAD-06, PAD-09): the bench as one pill, how far its
// data reaches and how current it is, the ring a row's symbol sits in, where an entry stands and its paddling rule.
import { describe, expect, it } from 'vitest';
import type { ObstacleDecision, PaddleCheck, PaddleCheckData, TourObstacleEntry } from '@trek/shared';
import { earlierEntry, obstacleEntry, paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { formatCheckDate } from './paddleCheckModel';
const NOT_CLEARANCE = 'A checklist is no safety or legal clearance.';
const BANS_INFO = 'OpenStreetMap records a ban or private ground here. A decision does not lift it.';
const GONE_INFO = 'Missing from the data is no proof that the obstacle is gone.';
/** A conditional rule as OpenStreetMap writes it: shown as written, never evaluated. */
const CONDITION = 'no @ (Nov-Mar)';
/** The dates of a bench as the fixtures carry them, and as the badges word them in the suite's locale (en-US). */
const DATA_AS_OF = '2026-10-01T00:00:00.000Z';
const READ_AT = '2026-10-02T04:20:00.000Z';
const AS_OF = formatCheckDate(DATA_AS_OF, 'en-US');
const READ = formatCheckDate(READ_AT, 'en-US');
/** No paddling rule in the tags at all. */
const NO_RULE: TourObstacleEntry['access'] = {
value: null,
source: null,
condition: null,
forbidden: false,
private: false,
};
function decisionOf(decision: ObstacleDecision): TourObstacleEntry['decision'] {
return { decision, note: null, decided_at: '2026-10-10T10:00:00.000Z', decided_by: 1 };
}
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
async function tooltipOf(element: HTMLElement): Promise<string | null> {
fireEvent.mouseEnter(element);
const text = (await screen.findByRole('tooltip')).textContent;
fireEvent.mouseLeave(element);
return text;
}
function pillOf(check: PaddleCheck): HTMLElement {
render(<PaddleCheckPill check={check} />);
return screen.getByTestId('plan-tour-paddle-check');
}
interface Precedence {
id: string;
rule: string;
bench: Partial<PaddleCheck>;
value: string;
icon: string;
}
const PRECEDENCE: Precedence[] = [
{
id: 'PCB-002',
rule: 'unavailable data says so over every count and every ban',
bench: { data: 'unavailable', open: 2, review: 1, bans: 1 },
value: 'Unavailable',
icon: 'circle-dashed',
},
{
id: 'PCB-003',
rule: 'no data says so and never shows a count',
bench: { data: 'none', open: 2, decided: 3 },
value: 'No data',
icon: 'circle-dashed',
},
{
id: 'PCB-004',
rule: 'entries open or to check again come before a partial coverage, counted together',
bench: { data: 'partial', open: 1, review: 2 },
value: '3 open',
icon: 'list-checks',
},
{
id: 'PCB-005',
rule: 'partial data with nothing pending names the coverage',
bench: { data: 'partial', open: 0, decided: 2 },
value: 'Partial',
icon: 'list-checks',
},
{
id: 'PCB-006',
rule: 'data cut short with nothing pending names it',
bench: { data: 'limited', open: 0 },
value: 'Cut short',
icon: 'list-checks',
},
{
id: 'PCB-007',
rule: 'a route without known obstacles says none known, never clear',
bench: { entries: 0, open: 0 },
value: 'None known',
icon: 'list-checks',
},
{
id: 'PCB-008',
rule: 'every entry decided counts the decisions',
bench: { entries: 2, open: 0, decided: 2 },
value: '2 decided',
icon: 'list-checks',
},
];
describe('PaddleCheckPill', () => {
it('PCB-001: an open entry on covered data reads as open and is named by its counts and the caveat', () => {
const pill = pillOf(paddleBench());
expect(pill.textContent).toBe('1 open');
expect(pill).toHaveAttribute('aria-label', `1 open\n${NOT_CLEARANCE}`);
expect(pill.querySelector('svg.lucide-list-checks')).not.toBeNull();
});
it.each(PRECEDENCE)('$id: $rule', ({ bench, value, icon }) => {
const pill = pillOf(paddleBench(bench));
expect(pill.textContent).toBe(value);
expect(pill.querySelector(`svg.lucide-${icon}`)).not.toBeNull();
});
it('PCB-009: the tooltip lists the data, every count, the ban and that a checklist is no clearance', async () => {
const pill = pillOf(paddleBench({ data: 'partial', open: 2, review: 1, decided: 3, bans: 1 }));
const label = [
'Obstacle data: Partial',
'2 open',
'1 to check again',
'3 decided',
'1 with a ban',
BANS_INFO,
NOT_CLEARANCE,
].join('\n');
expect(pill.textContent).toBe('3 open');
expect(pill.querySelector('svg.lucide-ban')).not.toBeNull();
expect(pill).toHaveAttribute('aria-label', label);
expect(await tooltipOf(pill)).toBe(label);
});
});
interface Coverage {
id: string;
data: Exclude<PaddleCheckData, 'covered'>;
word: string;
info: string;
}
const COVERAGE: Coverage[] = [
{
id: 'PCB-011',
data: 'partial',
word: 'Partial',
info: `Obstacle data covers only part of the route (as of ${AS_OF}). Check the rest yourself.`,
},
{
id: 'PCB-012',
data: 'none',
word: 'No data',
info: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
},
{
id: 'PCB-013',
data: 'limited',
word: 'Cut short',
info: 'TREK shows only part of the objects here; the area holds more. Check the water yourself.',
},
{
id: 'PCB-014',
data: 'unavailable',
word: 'Unavailable',
info: 'The obstacle data could not be loaded. Check the water yourself.',
},
];
describe('PaddleCheckDataLine', () => {
it('PCB-010: covered data shows no coverage badge, the date OSM is current to and when TREK fetched it', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data_as_of: DATA_AS_OF, read_at: READ_AT })} />);
expect(screen.queryByTestId('paddle-check-coverage')).not.toBeInTheDocument();
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('OSM as of')).toBeInTheDocument();
expect(within(asOf).getByText(AS_OF)).toBeInTheDocument();
const read = screen.getByTestId('paddle-check-read');
expect(within(read).getByText('Fetched')).toBeInTheDocument();
expect(within(read).getByText(READ)).toBeInTheDocument();
// Both sit in the one wrapping line.
expect(screen.getByTestId('paddle-check-data')).toContainElement(asOf);
expect(screen.getByTestId('paddle-check-data')).toContainElement(read);
expect(await tooltipOf(asOf)).toBe(`OpenStreetMap data as of ${AS_OF}, not a check on site.`);
});
it.each(COVERAGE)(
'$id: $data data names its coverage and says to check the water yourself',
async ({ data, word, info }) => {
render(<PaddleCheckDataLine check={paddleBench({ data, data_as_of: DATA_AS_OF })} />);
const coverage = screen.getByTestId('paddle-check-coverage');
expect(within(coverage).getByText('Obstacle data')).toBeInTheDocument();
expect(within(coverage).getByText(word)).toBeInTheDocument();
expect(await tooltipOf(coverage)).toBe(info);
}
);
it('PCB-015: an unknown date reads as unknown, and data never fetched shows no fetch date', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data: 'partial', data_as_of: null, read_at: null })} />);
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('unknown')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-read')).not.toBeInTheDocument();
expect(await tooltipOf(asOf)).toBe('OpenStreetMap data of an unknown date, not a check on site.');
expect(await tooltipOf(screen.getByTestId('paddle-check-coverage'))).toBe(
'Obstacle data covers only part of the route (as of an unknown date). Check the rest yourself.'
);
});
});
describe('ChecklistGlyph', () => {
it('PCB-016: an obstacle on the route sits in a solid 24 px ring of its kind colour', () => {
render(<ChecklistGlyph kind="weir" />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.width).toBe('24px');
expect(ring.style.height).toBe('24px');
expect(ring.style.color).toBe('var(--map-paddle-obstacle)');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring).toHaveClass('border-solid');
expect(ring).not.toHaveClass('border-dashed');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
});
it('PCB-017: an object only near the route gets a dashed ring at the size asked for; a lock gate shows the lock', () => {
render(<ChecklistGlyph kind="lock_gate" dashed size={20} />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring).toHaveClass('border-dashed');
expect(ring.style.width).toBe('20px');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
});
it('PCB-018: an own portage, an own lock and an access rule take the colour and symbol of their kind', () => {
const { rerender } = render(<ChecklistGlyph kind="portage" />);
let ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-portage)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'portage');
rerender(<ChecklistGlyph kind="lock" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
rerender(<ChecklistGlyph kind="access" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-access)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'access');
});
});
interface EarlierStatus {
id: string;
status: 'off_route' | 'unknown';
decision: ObstacleDecision;
chosen: string;
word: string;
}
const EARLIER: EarlierStatus[] = [
{
id: 'PCB-027',
status: 'off_route',
decision: 'not_affected',
chosen: 'Not affected',
word: 'No longer on the route',
},
{ id: 'PCB-028', status: 'unknown', decision: 'reroute', chosen: 'Go round', word: 'Not comparable' },
];
describe('ObstacleStatusBadges', () => {
it('PCB-019: an open entry reads as open and nothing more', () => {
render(<ObstacleStatusBadges entry={obstacleEntry()} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Open');
});
it('PCB-020: a decided entry shows the decision alone', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: decisionOf('not_affected') })} />);
expect(screen.getByTestId('paddle-check-status').textContent).toBe('Not affected');
});
it('PCB-021: an entry counted as decided that carries no decision shows no empty badge', () => {
const { container } = render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: null })} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-022: a choice whose plan is missing names the choice and what is missing', () => {
const { rerender } = render(
<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('portage') })} />
);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Carry round')).toBeInTheDocument();
expect(within(badge).getByText('No portage yet')).toBeInTheDocument();
rerender(<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('reroute') })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Go round')).toBeInTheDocument();
expect(within(badge).getByText('Route still passes')).toBeInTheDocument();
});
it('PCB-023: an entry to check again gets one badge for each change, each with the turning arrow', () => {
const entry = obstacleEntry({ status: 'review', changed: ['route', 'source'], decision: decisionOf('pass') });
render(<ObstacleStatusBadges entry={entry} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges.map((badge) => badge.textContent)).toEqual(['Check againRoute changed', 'Check againData changed']);
for (const badge of badges) expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
// The earlier choice is the panel's to show, not the badge's.
expect(screen.queryByText('Paddle through')).not.toBeInTheDocument();
});
it('PCB-024: one change gives one badge', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: ['source'] })} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Check againData changed');
});
it('PCB-025: an entry to check again without a recorded change still says so, with the turning arrow', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: [] })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(badge.textContent).toBe('Check again');
expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
});
it('PCB-026: a decision about an object the data no longer holds says so, and kept it steps back', async () => {
const { rerender } = render(<ObstacleStatusBadges entry={earlierEntry()} />);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
// Missing from the data is no proof the obstacle is gone.
expect(await tooltipOf(badge)).toBe(GONE_INFO);
rerender(<ObstacleStatusBadges entry={earlierEntry({ status: 'kept' })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).not.toBeNull();
});
it.each(EARLIER)(
'$id: an earlier decision now $status names the decision and why it stands apart',
({ status, decision, chosen, word }) => {
render(<ObstacleStatusBadges entry={earlierEntry({ status, decision: decisionOf(decision) })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText(chosen)).toBeInTheDocument();
expect(within(badge).getByText(word)).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
}
);
});
describe('ObstacleAccessBadge', () => {
it('PCB-029: a rule that neither forbids, makes private nor carries a condition shows nothing', () => {
const { container, rerender } = render(<ObstacleAccessBadge access={NO_RULE} />);
expect(container).toBeEmptyDOMElement();
rerender(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'yes', source: 'canoe' }} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-030: a ban for canoes names the canoe rule and colours its value as a warning', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'no', source: 'canoe', forbidden: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Canoe')).not.toHaveClass('text-warning');
expect(within(badge).getByText('no').parentElement).toHaveClass('text-warning');
});
it('PCB-031: private ground under the general access rule is a warning too', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'private', source: 'access', private: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Access')).toBeInTheDocument();
expect(within(badge).getByText('private').parentElement).toHaveClass('text-warning');
});
it('PCB-032: a value TREK has no words for is shown as written, its condition in the tooltip', async () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'permit', source: 'boat', condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Boat')).toBeInTheDocument();
expect(within(badge).getByText('permit').parentElement).toHaveClass('text-content');
expect(within(badge).getByText('permit').parentElement).not.toHaveClass('text-warning');
expect(await tooltipOf(badge)).toBe(`Condition: ${CONDITION}`);
});
it('PCB-033: a condition without a value of its own is shown as written under its own label', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Condition')).toBeInTheDocument();
expect(within(badge).getByText(CONDITION)).toBeInTheDocument();
});
});
@@ -0,0 +1,209 @@
import type {
ObstacleDecision,
ObstacleEntryStatus,
PaddleCheck,
PaddleCheckData,
PaddleObstacleKind,
TourObstacleEntry,
} from '@trek/shared';
import { RotateCcw } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { TourDayRowPill } from '../TourDayRowFacts';
import { FigureBadge } from './FigureBadge';
import { PaddleGlyph } from './PaddleGlyph';
import { checkDataWord, formatCheckDate, paddleCheckFact } from './paddleCheckModel';
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
import { KNOWN_ACCESS_VALUES } from './paddleObstacleModel';
import { paddleKindCss } from './paddlePaint';
type Translate = (key: string, params?: Record<string, string | number>) => string;
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
/** Why data that falls short of the route asks for a look of one's own, as the obstacle notice says it. */
const COVERAGE_INFO: Record<Exclude<PaddleCheckData, 'covered'>, (t: Translate, date: string) => string> = {
partial: (t, date) => t('tours.paddle.obstacles.partial', { date }),
none: (t) => t('tours.paddle.obstacles.none'),
limited: (t) => t('tours.paddle.obstacles.truncated'),
unavailable: (t) => t('tours.paddle.obstacles.error'),
};
/** How a status reads beside the decision it is about. */
interface StatusLook {
/** The status in words; null where the decision alone says it. */
word: (t: Translate, decision: ObstacleDecision | null) => string | null;
info?: (t: Translate) => string;
/** A decision kept about an object the data no longer holds steps back. */
dimmed?: boolean;
}
const GONE: StatusLook = {
word: (t) => t('tours.paddle.check.status.gone'),
info: (t) => t('tours.paddle.check.goneInfo'),
};
/** Every status but review, which gets a badge for each thing that changed. */
const STATUS_LOOK: Record<Exclude<ObstacleEntryStatus, 'review'>, StatusLook> = {
open: { word: (t) => t('tours.paddle.check.status.open') },
decided: { word: () => null },
// A choice whose plan is missing: no portage drawn yet, or the route still passes.
todo: {
word: (t, decision) =>
t(
decision === 'reroute' ? 'tours.paddle.check.status.reroutePending' : 'tours.paddle.check.status.portageMissing'
),
},
gone: GONE,
kept: { ...GONE, dimmed: true },
off_route: { word: (t) => t('tours.paddle.check.status.offRoute') },
unknown: { word: (t) => t('tours.paddle.check.status.unknown') },
};
/**
* The check bench of a paddle tour as one pill (PAD-09), in the badge the day
* plan gives a tour's facts: the first rule that applies gives its value, and
* its tooltip names the whole bench and that it is no clearance.
*/
export function PaddleCheckPill({ check }: { check: PaddleCheck }) {
const { t } = useTranslation();
const { icon, value, label } = paddleCheckFact(check, t);
return <TourDayRowPill icon={icon} value={value} label={label} testId="plan-tour-paddle-check" />;
}
/**
* How far the obstacle data of a bench reaches and how current it is: a badge
* for data that does not cover the route, the date OpenStreetMap is current
* to, and when TREK fetched it, which is never a check on site.
*/
export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
const { t, locale } = useTranslation();
const word = checkDataWord(check.data, t);
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale) : null;
const coverage =
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
return (
<div data-testid="paddle-check-data" className="flex flex-wrap items-center gap-1">
{word && coverage && (
<FigureBadge
testId="paddle-check-coverage"
label={t('tours.paddle.check.dataLabel')}
value={word}
info={coverage}
/>
)}
<FigureBadge
testId="paddle-check-as-of"
label={t('tours.paddle.check.sourceDate')}
value={asOf ?? t('tours.paddle.check.unknown')}
info={
asOf ? t('tours.paddle.obstacleCard.source', { date: asOf }) : t('tours.paddle.obstacleCard.sourceUndated')
}
/>
{check.read_at ? (
<FigureBadge
testId="paddle-check-read"
label={t('tours.paddle.check.readDate')}
value={formatCheckDate(check.read_at, locale)}
/>
) : null}
</div>
);
}
/**
* The symbol of a checklist row: the kind's glyph in a ring of its colour,
* the ring dashed for an object only near the route. The paddler's own
* portages and locks take the portage and lock kinds.
*/
export function ChecklistGlyph({
kind,
dashed = false,
size = 24,
}: {
kind: PaddleObstacleKind;
/** An object only near the route: how it stands to it is uncertain. */
dashed?: boolean;
size?: number;
}) {
const color = paddleKindCss(kind);
return (
<span
data-testid="paddle-check-glyph"
aria-hidden="true"
className={`grid flex-none place-items-center rounded-full border-2 bg-surface-card ${dashed ? 'border-dashed' : 'border-solid'}`}
style={{ width: size, height: size, color, borderColor: color }}
>
<PaddleGlyph kind={OBSTACLE_GLYPH[kind]} size={Math.round(size * 0.55)} strokeWidth={2.3} />
</span>
);
}
/** An entry to check again: one badge for each thing that changed since the decision. */
function ReviewBadges({ changed }: { changed: TourObstacleEntry['changed'] }) {
const { t } = useTranslation();
const review = t('tours.paddle.check.status.review');
const icon = <RotateCcw size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" />;
if (changed.length === 0) return <FigureBadge testId="paddle-check-status" value={review} icon={icon} />;
return (
<>
{changed.map((reason) => (
<FigureBadge
key={reason}
testId="paddle-check-status"
label={review}
value={t(`tours.paddle.check.changed.${reason}`)}
icon={icon}
/>
))}
</>
);
}
/**
* Where a checklist entry stands, as its row and its decision panel show it:
* the decision with what still stands in its way, a badge for each change an
* entry to check again has seen, and a decision kept about an object the data
* no longer holds dimmed. The badges come without a box of their own, so they
* flow on in the line they are put into.
*/
export function ObstacleStatusBadges({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
if (entry.status === 'review') return <ReviewBadges changed={entry.changed} />;
const look = STATUS_LOOK[entry.status];
const decision = entry.decision?.decision ?? null;
const chosen = decision ? t(`tours.paddle.check.decision.${decision}`) : undefined;
const word = look.word(t, decision);
const value = word ?? chosen;
if (!value) return null;
const badge = (
<FigureBadge testId="paddle-check-status" label={word ? chosen : undefined} value={value} info={look.info?.(t)} />
);
return look.dimmed ? <span className="inline-flex opacity-60">{badge}</span> : badge;
}
/** A rule's value in the user's words where TREK knows it, as the tags write it where it does not. */
function accessValueOf(value: string, t: Translate): string {
return ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
}
/**
* The paddling rule an obstacle's tags give, on its row only when it forbids,
* makes the ground private or carries a condition. A rule that forbids or
* makes it private takes the warning tone, as on the map card; a condition
* without a value of its own is shown as written.
*/
export function ObstacleAccessBadge({ access }: { access: TourObstacleEntry['access'] }) {
const { t } = useTranslation();
if (!access.forbidden && !access.private && !access.condition) return null;
const condition = t('tours.paddle.obstacleCard.condition');
const { value } = access;
return (
<FigureBadge
testId="paddle-check-access"
label={value === null ? condition : t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
value={value === null ? (access.condition ?? '') : accessValueOf(value, t)}
info={access.condition ? `${condition}: ${access.condition}` : undefined}
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
/>
);
}
@@ -0,0 +1,434 @@
// FE-PADDLE-CHECKCARD-001 to FE-PADDLE-CHECKCARD-017: the checklist card of a paddle tour (PAD-06) in the planner
// rail, its state notices, its rows in route order and the fold of earlier decisions.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleAccess, PaddleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
} 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 { PaddleCheckCard } from './PaddleCheckCard';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
// The badges, the entry dialog and the data notice have suites of their own; here they show what the card hands them.
vi.mock('./PaddleCheckBadges', () => ({
ChecklistGlyph: ({ kind, dashed = false }: { kind: string; dashed?: boolean; size?: number }) => (
<span data-testid="checklist-glyph" data-kind={kind} data-dashed={String(dashed)} />
),
ObstacleStatusBadges: ({ entry }: { entry: TourObstacleEntry }) => <span>status {entry.status}</span>,
ObstacleAccessBadge: ({ access }: { access: PaddleAccess }) =>
access.forbidden || access.private ? <span>access {access.value}</span> : null,
PaddleCheckPill: ({ check }: { check: PaddleCheck }) => (
<span data-testid="paddle-check-pill">{check.open} open</span>
),
PaddleCheckDataLine: ({ check }: { check: PaddleCheck }) => <span data-testid="paddle-check-data">{check.data}</span>,
}));
vi.mock('./ObstacleEntryDialog', () => ({
ObstacleEntryDialog: ({ checklist }: { checklist: PaddleChecklist }) => (
<div role="dialog" aria-label="Obstacle entry">
{checklist.dialogKey}
</div>
),
}));
vi.mock('./PaddleObstacleNotice', () => ({ PaddleObstacleNotice: () => <p>What TREK knows about the water</p> }));
/** The line the data notice stub shows. */
const OBSTACLE_NOTICE = 'What TREK knows about the water';
/** The words as shared/src/i18n/en/tours.ts has them. */
const SAYS = {
title: 'Checklist',
saveFirst: 'Save the tour to check it.',
loading: 'Loading the obstacles along the route …',
offlineEmpty: 'The checklist loads once you are online.',
error: 'The obstacle data could not be loaded. Check the water yourself.',
unsaved: 'Unsaved changes. The list shows the saved route.',
none: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
dateUnknown: 'an unknown date',
notClearance: 'A checklist is no safety or legal clearance.',
earlier: 'Earlier decisions',
own: 'Own',
ownInfo: 'Your own plan, not a fact from a data source.',
near: 'Near the route',
supLock: 'SUP: check a portage',
privateWay: 'Private way',
carry: 'Carrying',
bypassed: 'Carried round, the portage counts',
};
const staleLine = (time: string): string => `As of ${time}. Updates once online.`;
const coveredEmptyLine = (date: string): string =>
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
/** A date as the card writes it, with the spaces the DOM queries read. */
const dateOf = (value: string): string => formatCheckDate(value, 'en').replace(/\s/g, ' ');
/** The weir of the fixtures: on the route at 1.2 km, open. */
const WEIR = obstacleEntry();
/** A weir further on that OpenStreetMap closes to canoes. */
const BANNED_WEIR = obstacleEntry({
key: 'way/3',
refs: ['way/3'],
name: 'Wehr Kallmünz',
along_m: 2500,
access: { value: 'no', source: 'canoe', condition: null, forbidden: true, private: false },
});
/** An unnamed lock beside the route, decided already, where SUP paddlers are advised to carry round. */
const NEAR_LOCK = obstacleEntry({
key: 'lock:node/2',
refs: ['node/2', 'node/3'],
kind: 'lock',
name: null,
relation: 'near',
along_m: 3400,
distance_m: 40,
advice: 'sup_lock_portage',
status: 'decided',
decision: { decision: 'portage', note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 1 },
});
/** The paddler's own portage round the first weir. */
const OWN_PORTAGE = ownPortage({ from_m: 1100, to_m: 1250 });
/** A lock the paddler marked and locks through on a SUP, so the advice to check a portage stands. */
const ADVISED_LOCK = ownPortage({
kind: 'lock',
index: 4,
from_m: 5000,
to_m: 5000,
covers: [],
advice: 'sup_lock_portage',
});
/** A lock the paddler marked that a portage carries round; the server gives it no advice. */
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
/** All of it out of route order, as the server may list it. */
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
/** The weir, and an earlier decision about a sill the data no longer holds. */
const GONE = earlierEntry();
const GONE_NAME = 'Alte Sohlschwelle';
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
return {
status: 'ready',
tour: null,
check: obstacleCheck(),
summary: paddleBench(),
stale: false,
dirty: false,
canDecide: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: vi.fn(() => null),
select: vi.fn(),
selectOwn: vi.fn(),
closeDialog: vi.fn(),
decide: vi.fn(async () => undefined),
setNote: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
keep: vi.fn(async () => undefined),
nextOf: vi.fn(() => null),
next: vi.fn(),
planPortage: vi.fn(),
memberName: vi.fn(() => null),
...overrides,
};
}
const renderCard = (checklist: PaddleChecklist) => render(<PaddleCheckCard checklist={checklist} />);
/** The rows along the route, in the order the card lists them. */
function rowButtons(): HTMLElement[] {
return within(screen.getByTestId('paddle-check-rows')).getAllByRole('button');
}
/** The row along the route whose name starts as given. */
function rowNamed(name: RegExp): HTMLElement {
return within(screen.getByTestId('paddle-check-rows')).getByRole('button', { name });
}
/** The element a badge's tooltip hangs on. */
function parentOf(element: HTMLElement): HTMLElement {
const parent = element.parentElement;
if (!parent) throw new Error('no parent around the badge');
return parent;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the card reads metric, in English.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('PaddleCheckCard', () => {
it('FE-PADDLE-CHECKCARD-001: renders nothing while the checklist is off', () => {
const { container } = renderCard(checklistOf({ status: 'off', check: null, summary: null }));
expect(container).toBeEmptyDOMElement();
});
it('FE-PADDLE-CHECKCARD-002: a new paddle tour asks to be saved first and says what is known about the water', () => {
renderCard(checklistOf({ status: 'new', check: null, summary: null, dirty: true }));
const card = screen.getByRole('region', { name: SAYS.title });
const saveFirst = within(card).getByText(SAYS.saveFirst);
const notice = within(card).getByText(OBSTACLE_NOTICE);
// The request to save comes first, the data notice below it; a new draft is unsaved by nature and says no more.
expect(saveFirst.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-pill')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-data')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-003: while the list loads it says so as a status with a spinner, the bench already in the head', () => {
renderCard(checklistOf({ status: 'loading', check: null, dirty: true }));
const status = screen.getByRole('status');
expect(status).toHaveTextContent(SAYS.loading);
expect(status.querySelector('svg.animate-spin')).not.toBeNull();
// Loading speaks before unsaved changes.
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.queryByText(OBSTACLE_NOTICE)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(screen.getByTestId('paddle-check-data')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-004: offline without a cached list it says the list loads once online', () => {
renderCard(checklistOf({ status: 'offline-empty', check: null }));
expect(screen.getByText(SAYS.offlineEmpty)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-005: a list that could not be read says the obstacle data could not be loaded', () => {
renderCard(checklistOf({ status: 'error', check: null }));
expect(screen.getByText(SAYS.error)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-006: unsaved route changes speak before the cache and dim the rows without hiding them', () => {
renderCard(checklistOf({ check: ROUTE, dirty: true, stale: true }));
expect(screen.getByText(SAYS.unsaved)).toBeInTheDocument();
expect(screen.queryByText(/Updates once online/)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-007: a list from the cache says when it was worked out and dims the rows', () => {
renderCard(checklistOf({ check: ROUTE, stale: true }));
expect(screen.getByText(staleLine(dateOf(ROUTE.evaluated_at)))).toBeInTheDocument();
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-008: lists obstacles and own marks in route order, each with its place and where it stands', () => {
renderCard(checklistOf({ check: ROUTE }));
const card = screen.getByRole('region', { name: SAYS.title });
expect(within(card).getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(within(card).getByTestId('paddle-check-data')).toHaveTextContent('covered');
// A ready list that matches the saved route has nothing to say above it and is not dimmed.
expect(within(card).queryByRole('status')).not.toBeInTheDocument();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).not.toHaveClass('opacity-60');
const rows = rowButtons();
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringMatching(/^Portage1\.1 km/),
expect.stringMatching(/^Wehr Regendorf1\.2 km/),
expect.stringMatching(/^Wehr Kallmünz2\.5 km/),
expect.stringMatching(/^Lock3\.4 km/),
expect.stringMatching(/^Lock5 km/),
]);
const [, weir, banned, lock] = rows;
expect(within(weir).getByText('status open')).toBeInTheDocument();
expect(within(weir).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'false');
expect(within(weir).queryByText(SAYS.near)).not.toBeInTheDocument();
expect(within(weir).queryByText(/^access/)).not.toBeInTheDocument();
expect(within(banned).getByText('access no')).toBeInTheDocument();
// An unnamed lock goes by its kind: near the route on a dashed ring, its decision and the SUP advice.
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'true');
expect(within(lock).getByText(SAYS.near)).toBeInTheDocument();
expect(within(lock).getByText('status decided')).toBeInTheDocument();
expect(within(lock).getByText(SAYS.supLock)).toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-009: an own portage reads as the own plan, how far it carries, what it carries round', async () => {
renderCard(
checklistOf({ check: obstacleCheck({ own: [ownPortage({ from_m: 1100, to_m: 1250, conflict: 'private' })] }) })
);
const row = rowNamed(/^Portage/);
expect(
within(row)
.getAllByTestId('checklist-glyph')
.map((glyph) => glyph.dataset.kind)
).toEqual(['portage', 'weir']);
expect(within(row).getByText(SAYS.carry).nextElementSibling).toHaveTextContent('150 m');
expect(within(row).getByRole('img', { name: 'Weir' })).toBeInTheDocument();
expect(within(row).getByText(SAYS.privateWay)).toBeInTheDocument();
fireEvent.mouseEnter(parentOf(within(row).getByText(SAYS.own)));
expect(await screen.findByRole('tooltip')).toHaveTextContent(SAYS.ownInfo);
});
it('FE-PADDLE-CHECKCARD-010: a forbidden carrying way reads as its access rule, a lock carried round is dimmed', () => {
const forbidden = ownPortage({ from_m: 2000, to_m: 2200, conflict: 'forbidden' });
renderCard(checklistOf({ check: obstacleCheck({ entries: [], own: [BYPASSED_LOCK, ADVISED_LOCK, forbidden] }) }));
const [portage, advised, bypassed] = rowButtons();
expect(portage).toHaveTextContent('access no');
expect(portage).not.toHaveTextContent(SAYS.privateWay);
// A covered obstacle the checklist does not hold gets no chip.
expect(within(portage).queryByRole('img')).not.toBeInTheDocument();
expect(portage).not.toHaveClass('opacity-60');
// A lock passed on a SUP carries the advice; one a portage carries round is dimmed and says why.
expect(within(advised).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(advised).toHaveTextContent(SAYS.own);
expect(advised).toHaveTextContent(SAYS.supLock);
expect(advised).not.toHaveTextContent(SAYS.carry);
expect(advised).not.toHaveClass('opacity-60');
expect(bypassed).toHaveClass('opacity-60');
expect(bypassed).toHaveTextContent(SAYS.own);
expect(bypassed).toHaveTextContent(SAYS.bypassed);
expect(bypassed).not.toHaveTextContent(SAYS.supLock);
});
it('FE-PADDLE-CHECKCARD-011: a row is one button that selects its obstacle or its own mark', () => {
const select = vi.fn<PaddleChecklist['select']>();
const selectOwn = vi.fn<PaddleChecklist['selectOwn']>();
renderCard(checklistOf({ check: ROUTE, select, selectOwn }));
for (const row of rowButtons()) expect(row).toHaveAttribute('type', 'button');
// Nothing to press inside a row.
expect(within(rowNamed(/^Wehr Kallmünz/)).queryByRole('button')).not.toBeInTheDocument();
fireEvent.click(rowNamed(/^Wehr Kallmünz/));
expect(select).toHaveBeenCalledWith(BANNED_WEIR);
fireEvent.click(rowNamed(/^Portage/));
expect(selectOwn).toHaveBeenCalledWith(OWN_PORTAGE);
expect(select).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-CHECKCARD-012: the selected entry is the current row, scrolled into view, and the mark follows the selection', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: ROUTE, selectedKey: WEIR.key }));
const weir = rowNamed(/^Wehr Regendorf/);
expect(weir).toHaveAttribute('aria-current', 'true');
expect(weir).toHaveClass('ring-1', 'ring-inset', 'ring-accent', 'bg-surface-hover');
expect(rowButtons().filter((row) => row.hasAttribute('aria-current'))).toEqual([weir]);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts[0]).toBe(weir);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE, selectedKey: BANNED_WEIR.key })} />);
const banned = rowNamed(/^Wehr Kallmünz/);
expect(banned).toHaveAttribute('aria-current', 'true');
expect(weir).not.toHaveAttribute('aria-current');
expect(weir).not.toHaveClass('ring-accent');
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(scrollIntoView.mock.contexts[1]).toBe(banned);
});
it('FE-PADDLE-CHECKCARD-013: earlier decisions wait folded under their count, apart from the route, and open on request', () => {
const select = vi.fn<PaddleChecklist['select']>();
renderCard(checklistOf({ check: WITH_EARLIER, select }));
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(within(screen.getByTestId('paddle-check-earlier')).getByText('1')).toBeInTheDocument();
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
expect(rowButtons()).toHaveLength(1);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'true');
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
// No longer on the route, so no place along it.
expect(earlier).not.toHaveTextContent('km');
expect(earlier).toHaveTextContent('status gone');
fireEvent.click(earlier);
expect(select).toHaveBeenCalledWith(GONE);
});
it('FE-PADDLE-CHECKCARD-014: a selected earlier decision opens the fold by itself and comes into view, until folded again', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, selectedKey: GONE.key })} />);
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'true');
const row = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
expect(row).toHaveAttribute('aria-current', 'true');
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts).toContain(row);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
const empty = obstacleCheck({ entries: [], own: [] });
const bench = paddleBench({ entries: 0, open: 0 });
const { rerender } = renderCard(checklistOf({ check: empty, summary: bench }));
expect(screen.getByText(coveredEmptyLine(dateOf(bench.data_as_of ?? '')))).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-rows')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data_as_of: null } })} />);
expect(screen.getByText(coveredEmptyLine(SAYS.dateUnknown))).toBeInTheDocument();
rerender(
<PaddleCheckCard
checklist={checklistOf({ check: empty, summary: { ...bench, data: 'none', data_as_of: null } })}
/>
);
expect(screen.getByText(SAYS.none)).toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
// Partial data says so in its data line; the card adds no sentence of its own.
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data: 'partial' } })} />);
expect(screen.queryByText(SAYS.none)).not.toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-016: mounts the entry dialog only while an entry the map cannot show is open in it', () => {
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, dialogKey: GONE.key })} />);
expect(screen.getByRole('dialog', { name: 'Obstacle entry' })).toHaveTextContent(GONE.key);
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-017: closes on the line that a checklist is no clearance, before and after the tour is saved', () => {
const { rerender } = renderCard(checklistOf({ status: 'new', check: null, summary: null }));
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE })} />);
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
});
});
@@ -0,0 +1,386 @@
import type {
PaddleAccess,
PaddleCheck,
PaddleObstacleKind,
TourObstacleCheck,
TourObstacleEntry,
TourOwnEntry,
} from '@trek/shared';
import { AlertTriangle, CloudOff, LifeBuoy, ListChecks, Loader2, PencilLine, Save, WifiOff } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FoldButton, RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { checklistRows, formatCheckDate, type ChecklistRow } from './paddleCheckModel';
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
import type { PaddleChecklist, PaddleChecklistStatus } from './usePaddleChecklist';
type Translate = ReturnType<typeof useTranslation>['t'];
/** What the card says above the rows: the tour's own state, unsaved changes, or an answer from the cache. */
type CheckNotice = Exclude<PaddleChecklistStatus, 'off' | 'ready'> | 'dirty' | 'stale';
const ICON = { size: 13, strokeWidth: 2 } as const;
/** Each state's sentence and icon; while the list loads it is a status with a spinner. */
const NOTICES: Record<CheckNotice, { textKey: string; icon: ReactNode; role?: 'status' }> = {
new: { textKey: 'tours.paddle.check.saveFirst', icon: <Save {...ICON} /> },
loading: {
textKey: 'tours.paddle.obstacles.loading',
icon: <Loader2 {...ICON} className="animate-spin" />,
role: 'status',
},
'offline-empty': { textKey: 'tours.paddle.check.offlineEmpty', icon: <WifiOff {...ICON} /> },
error: { textKey: 'tours.paddle.obstacles.error', icon: <AlertTriangle {...ICON} /> },
dirty: { textKey: 'tours.paddle.check.unsaved', icon: <PencilLine {...ICON} /> },
stale: { textKey: 'tours.paddle.check.stale', icon: <CloudOff {...ICON} /> },
};
/** A carrying way the data forbids (`access=no` or `portage:access=no`), as the access rule it runs into. */
const FORBIDDEN_WAY: PaddleAccess = { value: 'no', source: 'access', condition: null, forbidden: true, private: false };
const ROW =
'flex w-full items-start gap-2 rounded-[10px] px-1.5 py-1.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
const ROW_SELECTED = 'bg-surface-hover ring-1 ring-inset ring-accent';
/** The one notice that applies: the tour's state first, then unsaved route changes, then a list from the cache. */
function noticeOf({ status, dirty, stale }: Pick<PaddleChecklist, 'status' | 'dirty' | 'stale'>): CheckNotice | null {
if (status === 'off') return null;
if (status !== 'ready') return status;
if (dirty) return 'dirty';
return stale ? 'stale' : null;
}
function kindLabel(kind: PaddleObstacleKind, t: Translate): string {
return t(`tours.paddle.obstacle.kind.${kind}`);
}
/** Metres along the route in the user's distance unit. */
function useDistanceLabel(): (metres: number) => string {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (metres) => formatDistance(metres / 1000, unit);
}
function rowKey(row: ChecklistRow, index: number): string {
return row.type === 'obstacle' ? row.entry.key : `own:${row.own.kind}:${row.own.from_m}:${index}`;
}
/** The sentence the checklist's state calls for. */
function CheckNoticeLine({ notice, evaluatedAt }: { notice: CheckNotice; evaluatedAt: string | null }) {
const { t, locale } = useTranslation();
const { textKey, icon, role } = NOTICES[notice];
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale) : '';
return (
<TourNotice role={role} icon={icon}>
{t(textKey, { time })}
</TourNotice>
);
}
/** A row of the checklist: one full width button with the glyph, the title and a line of badges that wraps between them. */
function CheckRowButton({
selected = false,
dimmed = false,
onClick,
glyph,
title,
children,
}: {
selected?: boolean;
dimmed?: boolean;
onClick: () => void;
glyph: ReactNode;
title: string;
children: ReactNode;
}) {
const ref = useRef<HTMLButtonElement>(null);
// An entry picked on the map comes into view in the rail.
useEffect(() => {
if (selected) ref.current?.scrollIntoView?.({ block: 'nearest' });
}, [selected]);
return (
<button
ref={ref}
type="button"
onClick={onClick}
aria-current={selected ? 'true' : undefined}
className={`${ROW} ${selected ? ROW_SELECTED : 'hover:bg-surface-hover'} ${dimmed ? 'opacity-60' : ''}`}
>
<span className="flex-none">{glyph}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-semibold text-content" style={fs(12.5, 'body')} title={title}>
{title}
</span>
<span className="flex flex-wrap items-center gap-1">{children}</span>
</span>
</button>
);
}
/** The SUP advice at a lock: check a portage rather than lock through. */
function SupLockBadge() {
const { t } = useTranslation();
return (
<SoftPill icon={<LifeBuoy size={10} strokeWidth={2.2} className="flex-none" />}>
{t('tours.paddle.check.supLock')}
</SoftPill>
);
}
/** Marks a row as the paddler's own plan, never a fact from a data source. */
function OwnBadge() {
const { t } = useTranslation();
return (
<Tooltip label={t('tours.paddle.check.ownInfo')}>
<span className="inline-flex flex-none">
<SoftPill>{t('tours.paddle.check.own')}</SoftPill>
</span>
</Tooltip>
);
}
/** The obstacles an own portage carries round, each a small glyph its tooltip names. */
function CoveredChips({ keys, entries }: { keys: readonly string[]; entries: readonly TourObstacleEntry[] }) {
const { t } = useTranslation();
const covered = keys
.map((key) => entries.find((entry) => entry.key === key))
.filter((entry): entry is TourObstacleEntry => entry !== undefined);
return (
<>
{covered.map((entry) => {
const label = kindLabel(entry.kind, t);
return (
<Tooltip key={entry.key} label={label}>
<span role="img" aria-label={label} className="inline-flex flex-none">
<ChecklistGlyph kind={entry.kind} size={18} />
</span>
</Tooltip>
);
})}
</>
);
}
/** A carrying way the data records as private or forbidden, in the warning tone the obstacle card gives rules. */
function ConflictBadge({ conflict }: { conflict: NonNullable<TourOwnEntry['conflict']> }) {
const { t } = useTranslation();
if (conflict === 'forbidden') return <ObstacleAccessBadge access={FORBIDDEN_WAY} />;
return <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>;
}
/** One obstacle: what it is, where on the route, and where its decision stands. */
function PaddleCheckRow({
entry,
selected,
onSelect,
}: {
entry: TourObstacleEntry;
selected: boolean;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const near = entry.relation === 'near';
return (
<CheckRowButton
selected={selected}
onClick={() => onSelect(entry)}
glyph={<ChecklistGlyph kind={entry.kind} dashed={near} />}
title={entry.name ?? kindLabel(entry.kind, t)}
>
{entry.along_m !== null && <SoftPill className="tabular-nums">{distance(entry.along_m)}</SoftPill>}
{near && <SoftPill>{t('tours.paddle.obstacleCard.near')}</SoftPill>}
<ObstacleStatusBadges entry={entry} />
<ObstacleAccessBadge access={entry.access} />
{entry.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/**
* The paddler's own portage or lock among the obstacles. A portage says how
* far it carries and what it carries round; a lock the portage carries round
* is dimmed and says so.
*/
function OwnCheckRow({
own,
entries,
onSelect,
}: {
own: TourOwnEntry;
entries: readonly TourObstacleEntry[];
onSelect: (own: TourOwnEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const portage = own.kind === 'portage';
const bypassed = !portage && own.bypassed;
return (
<CheckRowButton
dimmed={bypassed}
onClick={() => onSelect(own)}
glyph={<ChecklistGlyph kind={own.kind} />}
title={kindLabel(own.kind, t)}
>
<SoftPill className="tabular-nums">{distance(own.from_m)}</SoftPill>
<OwnBadge />
{portage && <FigureBadge label={t('tours.paddle.time.carry')} value={distance(own.to_m - own.from_m)} />}
{portage && <CoveredChips keys={own.covers} entries={entries} />}
{own.conflict && <ConflictBadge conflict={own.conflict} />}
{bypassed && <SoftPill>{t('tours.paddle.lockBypassed')}</SoftPill>}
{own.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/** One row along the route: an obstacle picks its entry, an own mark its place on the map. */
function RouteRow({
row,
checklist,
entries,
}: {
row: ChecklistRow;
checklist: PaddleChecklist;
entries: readonly TourObstacleEntry[];
}) {
if (row.type === 'own') return <OwnCheckRow own={row.own} entries={entries} onSelect={checklist.selectOwn} />;
return (
<PaddleCheckRow entry={row.entry} selected={row.entry.key === checklist.selectedKey} onSelect={checklist.select} />
);
}
/**
* The decisions the route or the data no longer carries, folded away under
* their count. A selection among them opens the fold, until the paddler folds
* it again for that selection.
*/
function EarlierDecisions({
entries,
selectedKey,
onSelect,
}: {
entries: readonly TourObstacleEntry[];
selectedKey: string | null;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const id = useId();
const [choice, setChoice] = useState<{ open: boolean; key: string | null }>({ open: false, key: null });
const holdsSelected = entries.some((entry) => entry.key === selectedKey);
const expanded = (holdsSelected && choice.key !== selectedKey) || choice.open;
const label = t('tours.paddle.check.earlier');
return (
<div data-testid="paddle-check-earlier" className="border-t border-edge-faint pt-1.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-semibold text-content-secondary" style={fs(11.5, 'body')}>
{label}
</span>
<SoftPill className="tabular-nums">{entries.length}</SoftPill>
<FoldButton
expanded={expanded}
controls={id}
label={label}
onToggle={() => setChoice({ open: !expanded, key: selectedKey })}
/>
</div>
<ul id={id} hidden={!expanded} className="-mx-1.5 mt-1 list-none p-0">
{expanded &&
entries.map((entry) => (
<li key={entry.key}>
<PaddleCheckRow entry={entry} selected={entry.key === selectedKey} onSelect={onSelect} />
</li>
))}
</ul>
</div>
);
}
/** A route without rows: nothing known where the data covers it, no data where there is none; else the data line speaks. */
function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
const { t, locale } = useTranslation();
let text: string | null = null;
if (summary?.data === 'none') text = t('tours.paddle.obstacles.none');
else if (summary?.data === 'covered') {
const date = summary.data_as_of
? formatCheckDate(summary.data_as_of, locale)
: t('tours.paddle.obstacles.dateUnknown');
text = t('tours.paddle.obstacles.coveredEmpty', { date });
}
if (!text) return null;
return (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{text}
</p>
);
}
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
const dimmed = checklist.dirty || checklist.stale;
return (
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
{rows.length > 0 ? (
<ul data-testid="paddle-check-rows" className="-mx-1.5 flex list-none flex-col gap-0.5 p-0">
{rows.map((row, index) => (
<li key={rowKey(row, index)}>
<RouteRow row={row} checklist={checklist} entries={check.entries} />
</li>
))}
</ul>
) : (
<EmptyLine summary={checklist.summary} />
)}
{earlier.length > 0 && (
<EarlierDecisions entries={earlier} selectedKey={checklist.selectedKey} onSelect={checklist.select} />
)}
</div>
);
}
/**
* PAD-06 in the planner rail: the obstacle checklist of the open paddle tour.
* The obstacles and the paddler's own portages and locks stand in route
* order, each a row that picks it on the map. One line says what state the
* list is in; while the route has unsaved changes or the list comes from the
* cache, the rows stay readable but dimmed. The card closes on the line that
* a checklist is no safety or legal clearance, and opens an entry the map
* cannot show in a dialog of its own.
*/
export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
const { t } = useTranslation();
if (checklist.status === 'off') return null;
const notice = noticeOf(checklist);
const { summary, check } = checklist;
return (
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
</RailCard>
);
}
@@ -0,0 +1,75 @@
// PCS-001 to PCS-004: the checklist section in a paddle tour's detail (PAD-09): the bench and how current its data
// is in one box, then the safety note, which stays when the tour has no bench yet.
import { describe, expect, it } from 'vitest';
import type { TourListItem } from '@trek/shared';
import { paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { PaddleCheckSection } from './PaddleCheckSection';
const SAFETY = 'Paddling has risks. Check water, rules and weather yourself.';
/** A planned paddle tour as the list carries it, without a bench. */
const TOUR: TourListItem = {
place_id: 21,
name: 'Naab paddle',
tour_type: 'kayak',
distance: 12.4,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'canoe',
};
interface Benchless {
id: string;
what: string;
tour: TourListItem;
}
const BENCHLESS: Benchless[] = [
{ id: 'PCS-002', what: 'no bench yet', tour: { ...TOUR, paddle_check: null } },
{ id: 'PCS-003', what: 'a list cached before the bench existed', tour: TOUR },
];
describe('PaddleCheckSection', () => {
it('PCS-001: a tour with a bench shows its pill and its data in one box, the safety note below it', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench({ data: 'partial', open: 2 }) }} />);
expect(screen.getByText('Checklist')).toBeInTheDocument();
const summary = screen.getByTestId('paddle-check-summary');
expect(within(summary).getByTestId('plan-tour-paddle-check').textContent).toBe('2 open');
expect(within(summary).getByTestId('paddle-check-coverage')).toHaveTextContent('Partial');
expect(within(summary).getByTestId('paddle-check-as-of')).toHaveTextContent('OSM as of');
expect(within(summary).getByTestId('paddle-check-read')).toHaveTextContent('Fetched');
const row = screen.getByRole('button', { name: SAFETY });
expect(summary).not.toContainElement(row);
expect(summary.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it.each(BENCHLESS)('$id: a tour with $what shows the safety note alone', ({ tour }) => {
render(<PaddleCheckSection tour={tour} />);
expect(screen.getByRole('button', { name: SAFETY })).toBeInTheDocument();
expect(screen.queryByText('Checklist')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-data')).not.toBeInTheDocument();
});
it('PCS-004: the safety note of the section opens the full note', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench() }} />);
fireEvent.click(screen.getByRole('button', { name: SAFETY }));
expect(screen.getByRole('dialog', { name: 'Safety on the water' })).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { TourListItem } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { BOX } from '../../Planner/bookings/bookingParts';
import { DialogSection } from '../../shared/DialogShell';
import { PaddleCheckDataLine, PaddleCheckPill } from './PaddleCheckBadges';
import { PaddleSafetyRow } from './PaddleSafety';
/**
* The checklist of a paddle tour in its detail (PAD-09): the check bench as
* the day plan shows it and how current its data is, then the safety note.
* A tour without a bench yet shows the safety note alone.
*/
export function PaddleCheckSection({ tour }: { tour: TourListItem }) {
const { t } = useTranslation();
const check = tour.paddle_check ?? null;
if (!check) {
return (
<div className="flex-none">
<PaddleSafetyRow />
</div>
);
}
return (
<DialogSection className="flex-none" label={t('tours.paddle.check.title')}>
<div className="flex flex-col gap-2">
<div data-testid="paddle-check-summary" className={`${BOX} flex flex-col gap-2 px-3 py-2.5`}>
<div className="flex flex-wrap items-center gap-1">
<PaddleCheckPill check={check} />
</div>
<PaddleCheckDataLine check={check} />
</div>
<PaddleSafetyRow />
</div>
</DialogSection>
);
}
@@ -14,8 +14,8 @@ function dataDate(regions: readonly { source_timestamp?: string | null }[], lang
}
/**
* PAD-05B: what TREK knows about the water along the route, in one line under
* the boat fields and in the recorded track's rail. It never reads as an
* PAD-05B: what TREK knows about the water along the route, in one line of
* the checklist card until a new paddle tour is saved. It never reads as an
* all-clear: covered or not, the paddler checks the water, where nothing is
* known it says so instead of leaving an empty map to be read as open water,
* and an answer cut at its limit says that more is there.
@@ -0,0 +1,129 @@
// PSF-001 to PSF-006: the safety note of a paddle tour: the quiet row and the dialog it opens, which never asks for
// a confirmation, never stands in the way and closes on Escape without taking the tour detail with it.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { PaddleSafetyDialog, PaddleSafetyRow } from './PaddleSafety';
/** The note as shared/src/i18n/en/tours.ts words it. */
const SHORT = 'Paddling has risks. Check water, rules and weather yourself.';
const TITLE = 'Safety on the water';
const TEXT =
'Paddling carries risks. Maps, route suggestions and notes can be incomplete or out of date. Before the tour, check the current local conditions, the rules for using the water and the weather. Take suitable safety measures. TREK supports your planning and does not replace your own check and responsible decision.';
/** The footer's Close, told apart from the round close of the head band by its words. */
function footerClose(dialog: HTMLElement): HTMLElement {
const button = within(dialog).getByText('Close').closest('button');
if (!button) throw new Error('no Close button in the footer');
return button;
}
/** The round close of the head band, named by its label alone. */
function headClose(dialog: HTMLElement): HTMLElement {
const button = within(dialog)
.getAllByRole('button', { name: 'Close' })
.find((candidate) => candidate.textContent === '');
if (!button) throw new Error('no close button in the head band');
return button;
}
describe('PaddleSafetyRow', () => {
it('PSF-001: is a quiet button saying paddling has risks that opens a dialog, closed at first', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
expect(row).toHaveAttribute('type', 'button');
expect(row).toHaveAttribute('aria-haspopup', 'dialog');
expect(row.querySelector('svg.lucide-life-buoy')).not.toBeNull();
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('PSF-002: opens the full note, closes on Close and opens again', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
fireEvent.click(row);
const dialog = screen.getByRole('dialog', { name: TITLE });
expect(within(dialog).getByText(TEXT)).toBeInTheDocument();
fireEvent.click(footerClose(dialog));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
expect(screen.getByRole('dialog', { name: TITLE })).toBeInTheDocument();
});
it('PSF-003: the round close, Escape and the backdrop close it as well', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
fireEvent.click(row);
fireEvent.click(headClose(screen.getByRole('dialog', { name: TITLE })));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
const backdrop = screen.getByRole('dialog', { name: TITLE }).parentElement;
if (!backdrop) throw new Error('no backdrop around the dialog');
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('PSF-004: Escape closes the note alone; the tour detail listening on the page does not see it', () => {
// The tour detail closes on an Escape nobody handled, from a listener it registered before the note opened.
const detailEscape = vi.fn();
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) detailEscape();
};
document.addEventListener('keydown', onKey);
try {
render(<PaddleSafetyRow />);
fireEvent.click(screen.getByRole('button', { name: SHORT }));
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(detailEscape).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', onKey);
}
});
it('PSF-005: inside a form the row and its dialog submit nothing', () => {
const onSubmit = vi.fn();
render(
<form onSubmit={onSubmit}>
<PaddleSafetyRow />
</form>
);
fireEvent.click(screen.getByRole('button', { name: SHORT }));
fireEvent.click(footerClose(screen.getByRole('dialog', { name: TITLE })));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
describe('PaddleSafetyDialog', () => {
it('PSF-006: shows nothing while closed; open it is narrow, its one action closes it and nothing asks to confirm', () => {
const onClose = vi.fn();
const { rerender } = render(<PaddleSafetyDialog open={false} onClose={onClose} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<PaddleSafetyDialog open onClose={onClose} />);
const dialog = screen.getByRole('dialog', { name: TITLE });
expect(dialog.className).toContain('max-w-[520px]');
expect(within(dialog).getAllByRole('button')).toHaveLength(2);
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
fireEvent.click(footerClose(dialog));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { ChevronRight, LifeBuoy } from 'lucide-react';
import { useCallback, useEffect, useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
/**
* Escape closes the note and not the tour detail or panel it was opened from:
* captured and marked handled before their own listeners on the document see it.
*/
function useOwnEscape(open: boolean, onClose: () => void) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
onClose();
};
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [open, onClose]);
}
/**
* The safety note for paddle tours in full: what paddling risks and what to
* check before setting out. It is read and closed; it asks for no
* confirmation and blocks nothing.
*/
export function PaddleSafetyDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const labelId = useId();
useOwnEscape(open, onClose);
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<LifeBuoy size={20} strokeWidth={1.9} className="text-content-muted" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('tours.paddle.safety.title')}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{t('tours.paddle.safety.text')}
</p>
</DialogShell>
);
}
/**
* The reminder that paddling has risks, as a quiet row in the look of a
* panel's notices. It opens the full note and never stands in the way of
* planning or saving.
*/
export function PaddleSafetyRow() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<>
<button
type="button"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="flex w-full items-start gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2 text-start leading-snug text-content-muted transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(12, 'body')}
>
<LifeBuoy size={13} strokeWidth={2} aria-hidden="true" className="mt-[2px] flex-none" />
<span className="min-w-0 flex-1">{t('tours.paddle.safety.short')}</span>
<ChevronRight size={14} strokeWidth={2.2} aria-hidden="true" className="flex-none self-center rtl:rotate-180" />
</button>
<PaddleSafetyDialog open={open} onClose={close} />
</>
);
}
@@ -0,0 +1,263 @@
// PCM-001 to PCM-018: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
// camera goes for an entry and the order the checklist lists its rows in (PAD-06, PAD-09).
import { Ban, CircleDashed, ListChecks } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import type { PaddleCheck, PaddleCheckData } from '@trek/shared';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
} from '../../../../tests/helpers/paddleCheck';
import {
checkDataWord,
checklistRows,
focusPointsOf,
formatCheckDate,
nextPendingEntry,
paddleCheckFact,
} from './paddleCheckModel';
/** The key as it is, with its values appended: a test reads which string and which count. */
const t = (key: string, params?: Record<string, string | number>) => (params ? key + JSON.stringify(params) : key);
const fact = (bench: Partial<PaddleCheck>) => paddleCheckFact(paddleBench(bench), t);
const lines = (bench: Partial<PaddleCheck>) => fact(bench).label.split('\n');
const NOT_CLEARANCE = 'tours.paddle.check.notClearance';
describe('paddleCheckFact', () => {
it('PCM-001: missing data says so and never shows a count, whatever the bench counts', () => {
for (const data of ['unavailable', 'none'] as const) {
const shown = fact({ data, open: 2, review: 1, decided: 3, bans: 1 });
expect(shown.value).toBe(`tours.paddle.check.data.${data}`);
expect(shown.value).not.toContain('count');
expect(shown.icon).toBe(CircleDashed);
}
expect(lines({ data: 'none', entries: 0, open: 0 })).toEqual([
'tours.paddle.check.dataLabel: tours.paddle.check.data.none',
NOT_CLEARANCE,
]);
});
it('PCM-002: open entries and the ones to check again are counted together in the pill, apart in the label', () => {
const shown = fact({ entries: 4, open: 2, review: 1, decided: 1 });
expect(shown.value).toBe('tours.paddle.check.open{"count":3}');
expect(shown.icon).toBe(ListChecks);
expect(shown.label.split('\n')).toEqual([
'tours.paddle.check.open{"count":2}',
'tours.paddle.check.review{"count":1}',
'tours.paddle.check.decided{"count":1}',
NOT_CLEARANCE,
]);
expect(fact({ entries: 2, open: 0, review: 2 }).value).toBe('tours.paddle.check.open{"count":2}');
});
it('PCM-003: open entries come before a partial coverage, and with nothing open the coverage word shows', () => {
expect(fact({ data: 'partial', open: 1 }).value).toBe('tours.paddle.check.open{"count":1}');
expect(fact({ data: 'partial', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.partial');
expect(fact({ data: 'limited', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.limited');
expect(lines({ data: 'limited', open: 0, decided: 1 })[0]).toBe(
'tours.paddle.check.dataLabel: tours.paddle.check.data.limited'
);
});
it('PCM-004: a route without known obstacles says none are known, never that it is clear', () => {
const shown = fact({ entries: 0, open: 0 });
expect(shown.value).toBe('tours.paddle.check.noneKnown');
expect(shown.icon).toBe(ListChecks);
expect(shown.label).toBe(NOT_CLEARANCE);
// Data that covers only part of the route says that first.
expect(fact({ data: 'partial', entries: 0, open: 0 }).value).toBe('tours.paddle.check.data.partial');
});
it('PCM-005: with every entry decided it counts the decisions', () => {
const shown = fact({ entries: 3, open: 0, decided: 3 });
expect(shown.value).toBe('tours.paddle.check.decided{"count":3}');
expect(shown.icon).toBe(ListChecks);
});
it('PCM-006: a ban on the route shows the ban icon, and the label says a decision does not lift it', () => {
const shown = fact({ entries: 2, open: 0, decided: 2, bans: 1 });
expect(shown.icon).toBe(Ban);
expect(shown.value).toBe('tours.paddle.check.decided{"count":2}');
expect(shown.label.split('\n')).toEqual([
'tours.paddle.check.decided{"count":2}',
'tours.paddle.check.bans{"count":1}',
'tours.paddle.check.bansInfo',
NOT_CLEARANCE,
]);
// Without data the missing data wins over the ban.
expect(fact({ data: 'unavailable', bans: 1 }).icon).toBe(CircleDashed);
});
it('PCM-007: the label always ends with the note that a checklist is no clearance, once', () => {
const benches: Partial<PaddleCheck>[] = [
{},
{ data: 'none', entries: 0, open: 0 },
{ data: 'unavailable' },
{ data: 'partial', open: 0, review: 2, bans: 3 },
{ entries: 0, open: 0 },
{ open: 0, decided: 1, bans: 1 },
];
for (const bench of benches) {
const parts = lines(bench);
expect(parts[parts.length - 1]).toBe(NOT_CLEARANCE);
expect(parts.filter((part) => part === NOT_CLEARANCE)).toHaveLength(1);
}
});
});
describe('checkDataWord', () => {
it('PCM-008: data that covers the route has no word, every other coverage its own', () => {
expect(checkDataWord('covered', t)).toBeNull();
const coverages: PaddleCheckData[] = ['partial', 'none', 'limited', 'unavailable'];
for (const data of coverages) expect(checkDataWord(data, t)).toBe(`tours.paddle.check.data.${data}`);
});
});
describe('formatCheckDate', () => {
it('PCM-009: a bare month from the data reads as month and year in the language', () => {
const may = new Date(Date.UTC(2026, 4, 1));
const month = (language: string) =>
new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(may);
expect(formatCheckDate('2026-05', 'en')).toBe(month('en'));
expect(formatCheckDate('2026-05', 'en')).toMatch(/May.*2026/);
expect(formatCheckDate('2026-05', 'de')).toBe(month('de'));
});
it('PCM-010: a bare day is the calendar day it names, an instant the day it falls on here', () => {
const day = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: 'UTC' }).format(
new Date(Date.UTC(2026, 9, 1))
);
expect(formatCheckDate('2026-10-01', 'en')).toBe(day);
expect(formatCheckDate('2026-10-01', 'en')).toMatch(/Oct.*1.*2026/);
const instant = '2026-10-02T04:20:00.000Z';
expect(formatCheckDate(instant, 'en')).toBe(
new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date(instant))
);
});
it('PCM-011: a value that is no date, or a language Intl cannot read, comes back as written', () => {
expect(formatCheckDate('last summer', 'en')).toBe('last summer');
expect(formatCheckDate('', 'en')).toBe('');
expect(formatCheckDate('2026-05', 'not a language')).toBe('2026-05');
});
});
describe('focusPointsOf', () => {
it('PCM-012: an area small enough to frame whole gives its corners', () => {
expect(focusPointsOf(obstacleEntry())).toEqual([
[49.2, 12.005],
[49.2, 12.005],
]);
expect(focusPointsOf(ownPortage())).toEqual([
[49.2, 12.0045],
[49.2001, 12.0058],
]);
// A lock of 1.1 km is still framed whole.
expect(
focusPointsOf({
at: [49.205, 12],
bounds: [
[49.2, 12],
[49.21, 12],
],
})
).toEqual([
[49.2, 12],
[49.21, 12],
]);
});
it('PCM-013: an area too wide to frame goes to the point of the entry', () => {
// About 3.4 km from corner to corner.
expect(
focusPointsOf({
at: [49.215, 12.01],
bounds: [
[49.2, 12],
[49.23, 12.01],
],
})
).toEqual([[49.215, 12.01]]);
});
});
describe('checklistRows', () => {
it('PCM-014: lists the obstacles and the own portages and locks in route order, the earlier decisions apart', () => {
const weir = obstacleEntry();
const rapids = obstacleEntry({
key: 'node/2',
refs: ['node/2'],
kind: 'rapids',
relation: 'near',
along_m: 300,
distance_m: 40,
});
const dam = obstacleEntry({ key: 'node/3', refs: ['node/3'], kind: 'dam', along_m: 2600 });
const gone = earlierEntry();
const portage = ownPortage();
const lock = ownPortage({ kind: 'lock', index: 2, from_m: 2000, to_m: 2000, covers: [] });
const check = obstacleCheck({ entries: [dam, weir, gone, rapids], own: [lock, portage] });
expect(checklistRows(check)).toEqual({
rows: [
{ type: 'obstacle', entry: rapids },
{ type: 'own', own: portage },
{ type: 'obstacle', entry: weir },
{ type: 'own', own: lock },
{ type: 'obstacle', entry: dam },
],
earlier: [gone],
});
});
it('PCM-015: an empty checklist has no rows and nothing earlier', () => {
expect(checklistRows(obstacleCheck({ entries: [], own: [] }))).toEqual({ rows: [], earlier: [] });
});
});
describe('nextPendingEntry', () => {
const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 };
const first = obstacleEntry({ along_m: 100 });
const decided = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 500, status: 'decided', decision });
const todo = obstacleEntry({
key: 'node/3',
refs: ['node/3'],
along_m: 900,
status: 'todo',
decision: { ...decision, decision: 'portage' },
});
const review = obstacleEntry({
key: 'node/4',
refs: ['node/4'],
along_m: 1300,
status: 'review',
changed: ['route'],
decision,
});
const last = obstacleEntry({ key: 'node/5', refs: ['node/5'], along_m: 1700, status: 'decided', decision });
const gone = earlierEntry();
// Listed out of route order: the next one is found by its metre, not by its place in the list.
const check = obstacleCheck({ entries: [review, last, gone, todo, first, decided] });
it('PCM-016: skips what is decided and takes the next one after the entry, one to check again counted', () => {
expect(nextPendingEntry(check, first)).toEqual(todo);
expect(nextPendingEntry(check, decided)).toEqual(todo);
expect(nextPendingEntry(check, todo)).toEqual(review);
});
it('PCM-017: from an earlier decision the first pending entry of the route comes next', () => {
expect(nextPendingEntry(check, gone)).toEqual(first);
});
it('PCM-018: after the last pending entry there is none', () => {
expect(nextPendingEntry(check, review)).toBeNull();
expect(nextPendingEntry(check, last)).toBeNull();
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
});
});
@@ -52,19 +52,19 @@ export function paddleCheckFact(check: PaddleCheck, t: Translate): { icon: Lucid
const MONTH = /^(\d{4})-(\d{2})$/;
/** A date of the checklist in the user's language: a bare `YYYY-MM` (an OSM check date) reads as month and year. */
export function formatCheckDate(value: string, language: string): string {
/** A date of the checklist in the user's locale: a bare `YYYY-MM` (an OSM check date) reads as month and year. */
export function formatCheckDate(value: string, locale: string): string {
const month = MONTH.exec(value);
try {
if (month) {
const date = new Date(Date.UTC(Number(month[1]), Number(month[2]) - 1, 1));
return new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
// A bare day is a calendar day, not an instant in the user's zone.
const timeZone = /^\d{4}-\d{2}-\d{2}$/.test(value) ? 'UTC' : undefined;
return new Intl.DateTimeFormat(language, { dateStyle: 'medium', timeZone }).format(date);
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date);
} catch {
return value;
}
@@ -1,4 +1,4 @@
// FE-PADDLE-MAP-001 to FE-PADDLE-MAP-015: the paddle objects as the planner maps draw them, one model for Leaflet and GL.
// FE-PADDLE-MAP-001 to FE-PADDLE-MAP-017: the paddle objects as the planner maps draw them, one model for Leaflet and GL.
import { describe, expect, it } from 'vitest';
import {
@@ -12,6 +12,7 @@ import {
import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg } from './paddleGlyphPaths';
import {
isAreaOnly,
isPaddleItemSelected,
markerSize,
PADDLE_AREA_MIN_ZOOM,
paddleItemsAtZoom,
@@ -21,6 +22,7 @@ import {
paddleMarkerHtml,
paddleMarkerImageId,
paddleMarkerSvg,
selectedPaddleKey,
} from './paddleMapLayer';
import { paddleKindPaint, type PaddlePaint } from './paddlePaint';
@@ -359,3 +361,26 @@ describe('paddle map layer', () => {
expect(parseHtml(paddleMarkerHtml(drawn, true)).querySelector('img')).toBeNull();
});
});
describe('the selected lock (PAD-06)', () => {
const chamber = { ...feature('way/7', 'lock'), group_key: 'lock:node/3' };
const gate = { ...feature('node/3', 'lock_gate'), group_key: 'lock:node/3' };
const weir = feature('node/9', 'weir');
const items = paddleMapItems([chamber, gate, weir], [], PAINT);
it('FE-PADDLE-MAP-016: the key of the selected object is its lock, or its own ref, and nothing without a selection', () => {
expect(selectedPaddleKey([chamber, gate, weir], 'way/7')).toBe('lock:node/3');
expect(selectedPaddleKey([chamber, gate, weir], 'node/9')).toBe('node/9');
expect(selectedPaddleKey([chamber, gate, weir], 'node/404')).toBeNull();
expect(selectedPaddleKey([chamber, gate, weir], null)).toBeNull();
});
it('FE-PADDLE-MAP-017: every part of a selected lock shows as selected, another object does not', () => {
const selected = items.filter((item) => isPaddleItemSelected(item, 'way/7', 'lock:node/3'));
expect(selected.map((item) => item.feature.ref)).toEqual(['way/7', 'node/3']);
expect(items.filter((item) => isPaddleItemSelected(item, 'node/9', 'node/9')).map((i) => i.feature.ref)).toEqual([
'node/9',
]);
expect(items.some((item) => isPaddleItemSelected(item, null, null))).toBe(false);
});
});
@@ -0,0 +1,664 @@
/**
* UPC-001 to UPC-023: the obstacle checklist of the paddle tour open in the
* tours workspace (PAD-06, PAD-09), as its card and the map share it.
*
* Pinned: off for anything but an open paddle tour in an active workspace,
* new for a paddle tour not saved yet; the checklist read for the bench the
* list shows, again only when its revision or the connection changes, and an
* answer for an older revision dropped. A decision shows at once, takes in
* the server's answer and is undone with a toast when the save fails;
* deciding waits while the route has unsaved changes and is never offered to
* someone who may not change the tour. Selecting an entry moves the map onto
* it, or opens its dialog where the map does not hold it; planning a portage
* starts the track's own pick or selects the control point to carry from.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleFeature, TourDetailResponse, TourListItem, TripMember } from '@trek/shared';
import { buildUser } from '../../../../tests/helpers/factories';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
ROUTE_BASIS,
SOURCE_BASIS,
} from '../../../../tests/helpers/paddleCheck';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import type { tourRepo } from '../../../repo/tourRepo';
import { useAuthStore } from '../../../store/authStore';
import {
clearPaddleObstacles,
publishPaddleObstacles,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import { usePaddleChecklist } from './usePaddleChecklist';
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
type Options = Parameters<typeof usePaddleChecklist>[0];
type TrackPlan = NonNullable<Options['trackPaddlePlan']>;
const repo = vi.hoisted(() => ({
obstacleCheck: vi.fn<typeof tourRepo.obstacleCheck>(),
saveObstacleReviews: vi.fn<typeof tourRepo.saveObstacleReviews>(),
}));
const network = vi.hoisted(() => ({ offline: false }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: vi.fn() }),
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const ENTRY = obstacleEntry();
const CHECK = obstacleCheck({ entries: [ENTRY] });
const BASIS = { route: ROUTE_BASIS, source: SOURCE_BASIS };
const MEMBERS: TripMember[] = [
{ id: 4, username: 'maria' },
{ id: 5, username: 'jonas' },
];
/** A kayak tour of the trip with its bench: the weir of the fixtures still open. */
function paddleTour(overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: 7,
name: 'Regen from Roding',
tour_type: 'kayak',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'kayak',
water_context: 'river',
paddle_check: paddleBench(),
...overrides,
};
}
const TOUR = paddleTour();
/** Three control points due north, 1112 m apart. */
const WAYPOINTS: TourPlannerWaypoint[] = [
{ id: 'w1', lat: 49.2, lng: 12, role: 'start' },
{ id: 'w2', lat: 49.21, lng: 12, role: 'via' },
{ id: 'w3', lat: 49.22, lng: 12, role: 'end' },
];
const setSelectedWaypointId = vi.fn();
/** The planner with the saved paddle tour open for editing and nothing unsaved, unless told otherwise. */
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
const stub: Partial<TourPlannerController> = {
mode: { type: 'edit-saved', placeId: 7 },
isPaddle: true,
hasUnsavedChanges: false,
waypoints: WAYPOINTS,
setSelectedWaypointId,
...overrides,
};
return stub as TourPlannerController;
}
/** The planner with the paddle tour open read-only as a recorded track. */
function recorded(): TourPlannerController {
return planner({ mode: { type: 'view-gpx', placeId: 7, tour: TOUR } });
}
function trackPlan(overrides: Partial<TrackPlan> = {}): TrackPlan {
return { dirty: false, pick: null, startPick: vi.fn(), ...overrides };
}
function options(overrides: Partial<Options> = {}): Options {
return {
tripId: 3,
planner: planner(),
tours: [TOUR],
tripMembers: MEMBERS,
canEdit: true,
active: true,
onSaved: vi.fn(),
...overrides,
};
}
function renderChecklist(props: Options = options()) {
return renderHook((current: Options) => usePaddleChecklist(current), {
initialProps: props,
wrapper: TranslationProvider,
});
}
/** Renders the hook and waits for the checklist to be there. */
async function renderReady(props: Options = options()) {
const view = renderChecklist(props);
await waitFor(() => expect(view.result.current.status).toBe('ready'));
return view;
}
/** A read or a save the test answers by hand, so the hook can be read while it is on its way. */
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
let reject: (reason: Error) => void = () => undefined;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** Lets a read the last render started settle inside act. */
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
/** An object the map holds, at the point of the weir. */
function feature(ref: string): PaddleFeature {
return {
ref,
kind: 'weir',
group_key: null,
name: null,
geometry: { type: 'Point', coordinates: [12.005, 49.2] },
tags: {},
tag_hash: ref,
region_id: 'oberpfalz',
};
}
/** The objects around the route as the sync hook publishes them. */
function publishMap(refs: string[]) {
publishPaddleObstacles({
status: 'ready',
features: refs.map((ref) => feature(ref)),
related: [],
routeLengthM: 5000,
coverage: 'covered',
regions: [],
truncated: false,
});
}
const round = (value: number) => Math.round(value * 1e6) / 1e6;
/** Where the camera was last sent, rounded past the floating point of the margin. */
function lastFocus() {
const current = usePaddleObstacleStore.getState().focus;
if (!current) return null;
return { seq: current.seq, points: current.points.map(([lat, lng]) => [round(lat), round(lng)]) };
}
/** The point of the weir framed with the margin of the store, 0.0015 degrees. */
const WEIR_FRAME = [
[49.1985, 12.0035],
[49.2015, 12.0065],
];
beforeEach(() => {
vi.clearAllMocks();
repo.obstacleCheck.mockReset();
repo.saveObstacleReviews.mockReset();
repo.obstacleCheck.mockResolvedValue({ check: CHECK, stale: false });
network.offline = false;
clearPaddleObstacles();
seedStore(useAuthStore, { user: buildUser({ id: 4 }) });
});
describe('usePaddleChecklist: what it reads', () => {
it('UPC-001: is off for a hike, a neutral planner and a new tour that is no paddle tour, and reads nothing', () => {
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
const { result, rerender } = renderChecklist(options({ tours: [hike] }));
expect(result.current).toMatchObject({ status: 'off', tour: null, check: null, summary: null, canDecide: false });
rerender(options({ planner: planner({ mode: { type: 'neutral' } }) }));
expect(result.current).toMatchObject({ status: 'off', tour: null });
rerender(options({ planner: planner({ mode: { type: 'new-draft' }, isPaddle: false }) }));
expect(result.current.status).toBe('off');
expect(repo.obstacleCheck).not.toHaveBeenCalled();
});
it('UPC-002: is off while the workspace is not active, and reads once it is', async () => {
const { result, rerender } = renderChecklist(options({ active: false }));
expect(result.current).toMatchObject({ status: 'off', check: null, canDecide: false });
rerender(options({ active: false, planner: planner({ mode: { type: 'new-draft' } }) }));
expect(result.current.status).toBe('off');
expect(repo.obstacleCheck).not.toHaveBeenCalled();
rerender(options());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
});
it('UPC-003: a paddle tour not saved yet is new, with nothing to read', () => {
const { result } = renderChecklist(options({ planner: planner({ mode: { type: 'new-draft' } }) }));
expect(result.current).toMatchObject({ status: 'new', tour: null, check: null, summary: null, canDecide: false });
expect(repo.obstacleCheck).not.toHaveBeenCalled();
});
it('UPC-004: loads the checklist for the bench the list shows, then has it ready to decide', async () => {
const read = deferred<CheckRead>();
repo.obstacleCheck.mockReturnValueOnce(read.promise);
const { result } = renderChecklist();
expect(result.current).toMatchObject({
status: 'loading',
tour: TOUR,
check: null,
summary: TOUR.paddle_check,
canDecide: false,
});
expect(repo.obstacleCheck).toHaveBeenCalledWith(3, 7, 'rev-1', expect.any(AbortSignal));
await act(async () => {
read.resolve({ check: CHECK, stale: false });
await read.promise;
});
expect(result.current).toMatchObject({
status: 'ready',
check: CHECK,
summary: TOUR.paddle_check,
stale: false,
dirty: false,
canDecide: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
});
});
it('UPC-005: a recorded track not in the list yet is read through the tour the planner holds, and a cached answer for an older bench reads as stale', async () => {
repo.obstacleCheck.mockResolvedValueOnce({ check: CHECK, stale: true });
const { result } = await renderReady(options({ planner: recorded(), tours: [] }));
expect(result.current).toMatchObject({ tour: TOUR, check: CHECK, stale: true });
expect(repo.obstacleCheck).toHaveBeenCalledWith(3, 7, 'rev-1', expect.any(AbortSignal));
});
it('UPC-006: offline with nothing cached it says so, and reads again once online', async () => {
network.offline = true;
repo.obstacleCheck.mockRejectedValueOnce(new Error('Network Error'));
const { result, rerender } = renderChecklist();
await waitFor(() => expect(result.current.status).toBe('offline-empty'));
expect(result.current).toMatchObject({ check: null, canDecide: false });
network.offline = false;
rerender(options());
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(2);
});
it('UPC-007: a read that fails online is an error, with nothing to decide', async () => {
repo.obstacleCheck.mockRejectedValueOnce(new Error('Request failed with status code 500'));
const { result } = renderChecklist();
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current).toMatchObject({ check: null, canDecide: false });
});
it('UPC-008: reads again only when the bench revision changes', async () => {
const { result, rerender } = await renderReady();
rerender(options({ tours: [paddleTour()] }));
rerender(options({ tours: [paddleTour({ name: 'Regen, renamed' })] }));
expect(result.current.status).toBe('ready');
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
const next = obstacleCheck({ revision: 'rev-2' });
repo.obstacleCheck.mockResolvedValueOnce({ check: next, stale: false });
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) })] }));
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.check).toEqual(next));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(2);
expect(repo.obstacleCheck).toHaveBeenLastCalledWith(3, 7, 'rev-2', expect.any(AbortSignal));
});
it('UPC-009: an answer still on its way for the previous revision is aborted and dropped', async () => {
const late = deferred<CheckRead>();
const next = obstacleCheck({ revision: 'rev-2' });
repo.obstacleCheck.mockReturnValueOnce(late.promise).mockResolvedValueOnce({ check: next, stale: false });
const { result, rerender } = renderChecklist();
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) })] }));
await waitFor(() => expect(result.current.check).toEqual(next));
expect(repo.obstacleCheck.mock.calls[0]?.[3]?.aborted).toBe(true);
await act(async () => {
late.resolve({ check: CHECK, stale: false });
await late.promise;
});
expect(result.current.check).toEqual(next);
});
it('UPC-010: deciding waits while the route has unsaved changes and is never offered to a viewer', async () => {
const { result, rerender } = await renderReady();
expect(result.current.canDecide).toBe(true);
rerender(options({ planner: planner({ hasUnsavedChanges: true }) }));
expect(result.current).toMatchObject({ status: 'ready', dirty: true, canDecide: false });
rerender(options({ planner: recorded(), trackPaddlePlan: trackPlan({ dirty: true }) }));
expect(result.current).toMatchObject({ status: 'ready', dirty: true, canDecide: false });
rerender(options({ canEdit: false }));
expect(result.current).toMatchObject({ status: 'ready', dirty: false, canDecide: false });
rerender(options());
expect(result.current.canDecide).toBe(true);
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
});
it('UPC-011: a tour made offline and not on the server yet is never decided on', async () => {
const { result } = await renderReady(
options({
planner: planner({ mode: { type: 'edit-saved', placeId: -3 } }),
tours: [paddleTour({ place_id: -3 })],
})
);
expect(result.current.canDecide).toBe(false);
});
});
describe('usePaddleChecklist: deciding', () => {
it('UPC-012: a decision shows at once, then the checklist takes the server answer and the tour is handed on', async () => {
const save = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result, rerender } = await renderReady(options({ onSaved }));
let saving: Promise<void> = Promise.resolve();
act(() => {
saving = result.current.decide(ENTRY, 'pass');
});
expect(repo.saveObstacleReviews).toHaveBeenCalledWith(
3,
7,
{ ops: [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: BASIS }] },
4
);
expect(result.current).toMatchObject({
status: 'ready',
savingKey: 'node/1',
canDecide: false,
check: { revision: 'pending' },
});
expect(result.current.check?.entries[0]).toMatchObject({
status: 'decided',
decision: { decision: 'pass', note: null, decided_by: 4 },
});
// The bench counts the decision before the server has it, under the revision the list knows.
expect(result.current.summary).toEqual(paddleBench({ open: 0, decided: 1 }));
expect(onSaved).not.toHaveBeenCalled();
const answered = obstacleCheck({
revision: 'rev-2',
entries: [
obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: null, decided_at: '2026-10-17T09:00:00.000Z', decided_by: 4 },
}),
],
});
const savedTour = paddleTour({ paddle_check: paddleBench({ revision: 'rev-2', open: 0, decided: 1 }) });
await act(async () => {
save.resolve({ tour: savedTour, waypoints: [], obstacle_check: answered });
await saving;
});
expect(onSaved).toHaveBeenCalledWith(savedTour);
expect(result.current.savingKey).toBeNull();
// The workspace takes the saved tour into its list; the answer is the checklist of its new bench.
repo.obstacleCheck.mockResolvedValue({ check: answered, stale: false });
rerender(options({ onSaved, tours: [savedTour] }));
expect(result.current).toMatchObject({
status: 'ready',
check: answered,
summary: savedTour.paddle_check,
canDecide: true,
});
await flush();
expect(result.current.check).toEqual(answered);
});
it('UPC-013: a failed save is undone and says so', async () => {
const save = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = await renderReady(options({ onSaved }));
let saving: Promise<void> = Promise.resolve();
act(() => {
saving = result.current.decide(ENTRY, 'portage');
});
// Carrying round without a portage planned yet is still to do, so the bench still counts it open.
expect(result.current.check?.entries[0]).toMatchObject({ status: 'todo', decision: { decision: 'portage' } });
expect(result.current.summary).toEqual(paddleBench());
await act(async () => {
save.reject(new Error('Request failed with status code 409'));
await saving;
});
expect(result.current).toMatchObject({ status: 'ready', savingKey: null, canDecide: true });
expect(result.current.check).toEqual(CHECK);
expect(result.current.summary).toEqual(TOUR.paddle_check);
// The en wording of tours.paddle.marks.failed.
expect(toast.error).toHaveBeenCalledWith('Saving did not work. Please try again.');
expect(onSaved).not.toHaveBeenCalled();
});
it('UPC-014: a new choice keeps the note, a note is trimmed and a blank one is none, clear and keep carry no choice', async () => {
const noted = obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: 'Left bank', decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 },
});
const gone = earlierEntry();
const listed = obstacleCheck({ entries: [noted, gone] });
repo.obstacleCheck.mockResolvedValue({ check: listed, stale: false });
repo.saveObstacleReviews.mockResolvedValue({ tour: TOUR, waypoints: [], obstacle_check: listed });
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = await renderReady(options({ onSaved }));
await act(async () => {
await result.current.decide(noted, 'portage');
});
await act(async () => {
await result.current.setNote(noted, ' Scout from the left bank ');
});
await act(async () => {
await result.current.setNote(noted, ' ');
});
await act(async () => {
await result.current.clear(noted);
});
await act(async () => {
await result.current.keep(gone);
});
expect(repo.saveObstacleReviews.mock.calls.map(([, , body]) => body.ops)).toEqual([
[{ key: 'node/1', op: 'decide', decision: 'portage', note: 'Left bank', basis: BASIS }],
[{ key: 'node/1', op: 'note', decision: null, note: 'Scout from the left bank', basis: null }],
[{ key: 'node/1', op: 'note', decision: null, note: null, basis: null }],
[{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }],
[{ key: 'node/9', op: 'keep', decision: null, note: null, basis: null }],
]);
expect(onSaved).toHaveBeenCalledTimes(5);
expect(result.current).toMatchObject({ check: listed, savingKey: null });
});
it('UPC-015: nothing is saved, found or offered as next before the checklist is there', async () => {
repo.obstacleCheck.mockReturnValueOnce(new Promise<CheckRead>(() => undefined));
const { result } = renderChecklist();
await act(async () => {
await result.current.decide(ENTRY, 'pass');
});
expect(repo.saveObstacleReviews).not.toHaveBeenCalled();
expect(result.current).toMatchObject({ status: 'loading', savingKey: null });
expect(result.current.nextOf(ENTRY)).toBeNull();
expect(result.current.entryForRef('node/1')).toBeNull();
});
it('UPC-016: names the trip member who decided, and nobody for an unknown or missing id', () => {
const { result } = renderChecklist(options({ active: false }));
expect(result.current.memberName(4)).toBe('maria');
expect(result.current.memberName(5)).toBe('jonas');
expect(result.current.memberName(9)).toBeNull();
expect(result.current.memberName(null)).toBeNull();
});
});
describe('usePaddleChecklist: the map and the route', () => {
it('UPC-017: selecting an entry the map holds selects it there and moves the camera onto it', async () => {
const { result } = await renderReady();
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
expect(usePaddleObstacleStore.getState().selectedRef).toBe('node/1');
expect(lastFocus()).toEqual({ seq: 1, points: WEIR_FRAME });
expect(result.current).toMatchObject({ selectedKey: 'node/1', dialogKey: null });
});
it('UPC-018: a selection on the map marks the entry that stands for the object, a lock by any of its parts', async () => {
const lock = obstacleEntry({
key: 'lock:node/5',
refs: ['node/6', 'node/5'],
kind: 'lock',
name: 'Schleuse Roding',
along_m: 2400,
});
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ entries: [ENTRY, lock] }), stale: false });
const { result } = await renderReady();
expect(result.current.entryForRef('node/5')).toEqual(lock);
expect(result.current.entryForRef('node/42')).toBeNull();
act(() => selectPaddleObstacle('node/5'));
expect(result.current.selectedKey).toBe('lock:node/5');
act(() => selectPaddleObstacle('node/42'));
expect(result.current.selectedKey).toBeNull();
// A click on the map only selects; the camera stays where it is.
expect(lastFocus()).toBeNull();
});
it('UPC-019: selecting an entry the map does not hold frames its area and opens its dialog', async () => {
const { result } = await renderReady();
act(() => selectPaddleObstacle('node/77'));
act(() => result.current.select(ENTRY));
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
expect(lastFocus()).toEqual({ seq: 1, points: WEIR_FRAME });
expect(result.current).toMatchObject({ dialogKey: 'node/1', selectedKey: 'node/1' });
act(() => result.current.closeDialog());
expect(result.current).toMatchObject({ dialogKey: null, selectedKey: null });
// Once the map holds it, selecting it goes through the map and closes the dialog.
act(() => result.current.select(ENTRY));
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
expect(result.current).toMatchObject({ dialogKey: null, selectedKey: 'node/1' });
expect(lastFocus()?.seq).toBe(3);
});
it('UPC-020: next selects the next entry along the route still asking for a look', async () => {
const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 };
const dam = obstacleEntry({
key: 'node/2',
refs: ['node/2'],
kind: 'dam',
along_m: 2500,
status: 'decided',
decision,
});
const rapids = obstacleEntry({
key: 'node/3',
refs: ['node/3'],
kind: 'rapids',
along_m: 4000,
status: 'review',
changed: ['source'],
decision,
});
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ entries: [ENTRY, dam, rapids] }), stale: false });
const { result } = await renderReady();
expect(result.current.nextOf(ENTRY)).toEqual(rapids);
act(() => result.current.next(ENTRY));
expect(result.current).toMatchObject({ dialogKey: 'node/3', selectedKey: 'node/3' });
// After the last one there is none, and nothing moves.
expect(result.current.nextOf(rapids)).toBeNull();
act(() => result.current.next(rapids));
expect(result.current.dialogKey).toBe('node/3');
expect(lastFocus()?.seq).toBe(1);
});
it('UPC-021: planning a portage on a recorded track starts the portage pick of the track, once', async () => {
const plan = trackPlan();
const { result, rerender } = await renderReady(options({ planner: recorded(), trackPaddlePlan: plan }));
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
act(() => result.current.planPortage(ENTRY));
expect(plan.startPick).toHaveBeenCalledWith('portage');
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
expect(lastFocus()).toEqual({ seq: 2, points: WEIR_FRAME });
expect(result.current.dialogKey).toBeNull();
expect(setSelectedWaypointId).not.toHaveBeenCalled();
// While the pick is on, a second press leaves it on rather than switching it off.
const picking = trackPlan({ pick: 'portage' });
rerender(options({ planner: recorded(), trackPaddlePlan: picking }));
act(() => result.current.planPortage(ENTRY));
expect(picking.startPick).not.toHaveBeenCalled();
});
it('UPC-022: planning a portage on a planned tour selects the control point whose leg passes the obstacle', async () => {
const plan = trackPlan();
const { result, rerender } = await renderReady(options({ trackPaddlePlan: plan }));
act(() => result.current.select(ENTRY));
expect(result.current.dialogKey).toBe('node/1');
act(() => result.current.planPortage(ENTRY));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w2');
expect(result.current.dialogKey).toBeNull();
expect(lastFocus()).toEqual({ seq: 2, points: WEIR_FRAME });
expect(plan.startPick).not.toHaveBeenCalled();
act(() => result.current.planPortage(obstacleEntry({ along_m: 500 })));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w1');
// Past the last control point the portage starts on the last leg.
act(() => result.current.planPortage(obstacleEntry({ along_m: 9000 })));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w2');
// A route of one point has no leg to carry from.
rerender(options({ planner: planner({ waypoints: WAYPOINTS.slice(0, 1) }), trackPaddlePlan: plan }));
act(() => result.current.planPortage(ENTRY));
expect(setSelectedWaypointId).toHaveBeenCalledTimes(3);
});
it('UPC-023: selecting an own portage frames it, and on a planned tour selects its control point', async () => {
const { result, rerender } = await renderReady();
act(() => result.current.selectOwn(ownPortage()));
expect(lastFocus()).toEqual({
seq: 1,
points: [
[49.1985, 12.003],
[49.2016, 12.0073],
],
});
expect(setSelectedWaypointId).toHaveBeenCalledWith('w2');
// A portage laid onto a recorded track belongs to no control point.
rerender(options({ planner: recorded() }));
act(() => result.current.selectOwn(ownPortage({ index: null })));
expect(lastFocus()?.seq).toBe(2);
expect(setSelectedWaypointId).toHaveBeenCalledTimes(1);
});
});
@@ -92,40 +92,43 @@ function waypointBefore(points: TourPlannerController['waypoints'], alongM: numb
return points[points.length - 2]?.id ?? null;
}
/** Where the checklist of the open tour stands. */
function checklistStatus(input: {
active: boolean;
newPaddleDraft: boolean;
tour: TourListItem | null;
current: Loaded | null;
offline: boolean;
}): PaddleChecklistStatus {
if (!input.active) return 'off';
if (input.newPaddleDraft) return 'new';
if (!input.tour) return 'off';
if (!input.current) return 'loading';
if (input.current.check) return 'ready';
return input.offline ? 'offline-empty' : 'error';
}
/** The bench of the tour, recounted from a change shown before the server answered it. */
function benchOf(tour: TourListItem | null, optimistic: TourObstacleCheck | null): PaddleCheck | null {
const bench = tour?.paddle_check ?? null;
if (!bench || !optimistic) return bench;
return { ...paddleCheckSummaryOf(optimistic.entries, optimistic.own, bench), revision: bench.revision };
}
/**
* PAD-06 and PAD-09 in the tours workspace: the checklist of the open paddle
* tour, read through the offline core for the bench the list shows, with the
* paddlers' decisions saved optimistically and rolled back on a failure.
* Deciding waits while the route has unsaved changes, since a decision binds
* to the route the server judged.
* Moving between the entries: a click in the rail focuses the map on the
* object, or opens it in a dialog when the map does not hold it; an own
* portage selects its control point; planning a portage starts where the
* route can take one.
*/
export function usePaddleChecklist(options: PaddleChecklistOptions) {
const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options;
function useChecklistNavigation(
planner: TourPlannerController,
check: TourObstacleCheck | null,
trackPaddlePlan: PaddleChecklistOptions['trackPaddlePlan']
) {
const { mode, waypoints, setSelectedWaypointId } = planner;
const tour = useMemo(() => openPaddleTour(mode, tours), [mode, tours]);
const read = useChecklistRead(tripId, tour, active);
const writes = useChecklistWrites(options, tour, read);
const selectedRef = usePaddleObstacleStore((state) => state.selectedRef);
const [dialogKey, setDialogKey] = useState<string | null>(null);
const check = writes.optimistic ?? read.current?.check ?? null;
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
let status: PaddleChecklistStatus = 'off';
if (active && mode.type === 'new-draft' && planner.isPaddle) status = 'new';
else if (active && tour) {
if (!read.current) status = 'loading';
else if (read.current.check) status = 'ready';
else status = read.offline ? 'offline-empty' : 'error';
}
const placeId = tour?.place_id ?? -1;
const summary: PaddleCheck | null =
writes.optimistic && tour?.paddle_check
? {
...paddleCheckSummaryOf(writes.optimistic.entries, writes.optimistic.own, tour.paddle_check),
revision: tour.paddle_check.revision,
}
: (tour?.paddle_check ?? null);
const entryForRef = useCallback(
(ref: string) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
[check]
@@ -164,29 +167,58 @@ export function usePaddleChecklist(options: PaddleChecklistOptions) {
[mode.type, setSelectedWaypointId, trackPaddlePlan, waypoints]
);
const nextOf = useCallback((entry: TourObstacleEntry) => (check ? nextPendingEntry(check, entry) : null), [check]);
const selectedEntry = selectedRef ? entryForRef(selectedRef) : null;
return {
status,
tour,
check,
summary,
stale: read.current?.stale ?? false,
dirty,
canDecide: canEdit && status === 'ready' && !dirty && placeId > 0 && writes.savingKey === null,
savingKey: writes.savingKey,
selectedKey: (selectedRef ? entryForRef(selectedRef)?.key : null) ?? dialogKey,
selectedKey: selectedEntry?.key ?? dialogKey,
dialogKey,
entryForRef,
select,
selectOwn,
closeDialog: () => setDialogKey(null),
...writes.actions,
nextOf,
next: (entry: TourObstacleEntry) => {
const following = nextOf(entry);
if (following) select(following);
},
planPortage,
};
}
/**
* PAD-06 and PAD-09 in the tours workspace: the checklist of the open paddle
* tour, read through the offline core for the bench the list shows, with the
* paddlers' decisions saved optimistically and rolled back on a failure.
* Deciding waits while the route has unsaved changes, since a decision binds
* to the route the server judged.
*/
export function usePaddleChecklist(options: PaddleChecklistOptions) {
const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options;
const tour = useMemo(() => openPaddleTour(planner.mode, tours), [planner.mode, tours]);
const read = useChecklistRead(tripId, tour, active);
const writes = useChecklistWrites(options, tour, read);
const check = writes.optimistic ?? read.current?.check ?? null;
const navigation = useChecklistNavigation(planner, check, trackPaddlePlan);
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
const status = checklistStatus({
active,
newPaddleDraft: planner.mode.type === 'new-draft' && planner.isPaddle,
tour,
current: read.current,
offline: read.offline,
});
const saved = (tour?.place_id ?? -1) > 0;
return {
status,
tour,
check,
summary: benchOf(tour, writes.optimistic),
stale: !!read.current?.stale,
dirty,
canDecide: canEdit && status === 'ready' && !dirty && saved && writes.savingKey === null,
savingKey: writes.savingKey,
...navigation,
...writes.actions,
memberName: (id: number | null) => options.tripMembers.find((member) => member.id === id)?.username ?? null,
};
}
@@ -294,6 +294,7 @@ describe('usePaddleObstacleSync', () => {
regions: [],
truncated: false,
selectedRef: null,
focus: null,
});
});
});
@@ -7,6 +7,7 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store'
import { useSettingsStore } from '../../../store/settingsStore'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import type { TourPlannerController } from './useTourPlanner'
import type { PaddleChecklist } from '../paddle/usePaddleChecklist'
import type { Day } from '../../../types'
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
@@ -15,6 +16,12 @@ import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
// The checklist card is its own suite's; here it only has to sit where the rails put it.
vi.mock('../paddle/PaddleCheckCard', () => ({
PaddleCheckCard: ({ checklist }: { checklist: PaddleChecklist }) => (
<div data-testid="paddle-check-card" data-status={checklist.status} />
),
}))
const tour: TourListItem = {
place_id: 42,
@@ -872,3 +879,65 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
describe('the paddle checklist in the rails (PAD-06)', () => {
const checklist = { status: 'ready', select: vi.fn(), decide: vi.fn() } as unknown as PaddleChecklist
const kayak: TourListItem = { ...tour, tour_type: 'kayak', has_waypoints: false }
const geometry = JSON.stringify([[48, 11], [48.01, 11.02]])
const paddleEditor = (overrides: Partial<TourPlannerController> = {}) =>
planner({ mode: { type: 'new-draft' }, tourType: 'kayak', isPaddle: true, ...overrides })
const recorded = (recordedTour: TourListItem) => planner({
readOnlyGpxTour: { tour: recordedTour, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})
const safetyRow = () => screen.queryByRole('button', { name: /Paddling has risks/ })
it('PCR-001: a paddle tour shows its checklist after the control points and before the wind, the safety row above Save', () => {
render(<TourPlannerRail planner={paddleEditor()} paddleChecklist={checklist} />)
const card = screen.getByTestId('paddle-check-card')
expect(card).toHaveAttribute('data-status', 'ready')
expect(screen.getByTestId('tour-planner-empty-state').compareDocumentPosition(card)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(card.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.closest('fieldset')).toBeNull()
expect(safety.closest('.overflow-y-auto')).toBeNull()
expect(safety.compareDocumentPosition(screen.getByRole('button', { name: 'Save' }))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
it('PCR-002: the safety row stays open while a save locks the rail', () => {
render(<TourPlannerRail planner={paddleEditor({ isSaving: true })} paddleChecklist={checklist} />)
expect(screen.getByRole('button', { name: /Paddling has risks/ })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('PCR-003: a hike shows neither the checklist nor the safety row', () => {
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} paddleChecklist={checklist} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).not.toBeInTheDocument()
})
it('PCR-004: without a checklist a paddle tour still shows its safety row', () => {
render(<TourPlannerRail planner={paddleEditor()} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).toBeInTheDocument()
})
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
const card = screen.getByTestId('paddle-check-card')
expect(card.closest('.overflow-y-auto')).not.toBeNull()
expect(card.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.closest('.overflow-y-auto')).toBeNull()
expect(safety.closest('section')).toHaveAttribute('aria-label', kayak.name)
rerender(<TourPlannerRail planner={recorded({ ...tour, has_waypoints: false })} paddleChecklist={checklist} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).not.toBeInTheDocument()
})
})
@@ -19,9 +19,12 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
import { PaddleWindSection } from '../paddle/PaddleWindCard'
import { TrackPaddlePlanCards } from '../paddle/TrackPaddlePlanCard'
import type { PaddleChecklist } from '../paddle/usePaddleChecklist'
import type { TrackPaddlePlan } from '../paddle/useTrackPaddlePlan'
import { TourTypeFields } from './TourTypeFields'
import TourWaypointList from './TourWaypointList'
@@ -235,11 +238,13 @@ function GpxViewRail({
canEdit,
conversion,
trackPaddlePlan,
paddleChecklist,
}: {
planner: TourPlannerController
canEdit: boolean
conversion?: GpxConversion
trackPaddlePlan?: TrackPaddlePlan
paddleChecklist?: PaddleChecklist
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
@@ -277,6 +282,7 @@ function GpxViewRail({
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
{conversion && <TourConversionSection conversion={conversion} />}
{trackPaddlePlan && <TrackPaddlePlanCards plan={trackPaddlePlan} tour={tour} />}
{paddleChecklist && tour.tour_type === 'kayak' && <PaddleCheckCard checklist={paddleChecklist} />}
{tour.tour_type === 'kayak' && (
<PaddleWindSection placeId={tour.place_id} start={trackStart(analysis?.routeCoordinates)} />
)}
@@ -298,15 +304,37 @@ function GpxViewRail({
)}
</RailCard>
</div>
{tour.tour_type === 'kayak' && (
<div className="flex-none border-t border-edge-faint px-3 py-3">
<PaddleSafetyRow />
</div>
)}
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
)
}
/** A paddle tour's cards in the editor, after its control points: the obstacle checklist, the time and the wind. */
function PaddleEditorCards({ planner, locked, checklist }: {
planner: TourPlannerController
locked: boolean
checklist?: PaddleChecklist
}) {
if (!planner.isPaddle) return null
return (
<>
{checklist && <PaddleCheckCard checklist={checklist} />}
<PaddleTimeCard planner={planner} locked={locked} />
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
</>
)
}
export function TourPlannerRail({ planner,
onOpenWanderer,
conversion,
paddleChecklist,
recordedTrack, trackPaddlePlan, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
/** Planning an editable tour from the import in view. */
conversion?: GpxConversion
@@ -314,6 +342,8 @@ export function TourPlannerRail({ planner,
recordedTrack?: RecordedTrack
/** The portages and locks of a recorded paddle track in view (PAD-03). */
trackPaddlePlan?: TrackPaddlePlan
/** The obstacle checklist of the paddle tour open in the rail (PAD-06). */
paddleChecklist?: PaddleChecklist
/** Browse the user's wanderer trails instead; offered while nothing is being edited. */
onOpenWanderer?: () => void
} & TourPermissionProps) {
@@ -339,7 +369,8 @@ export function TourPlannerRail({ planner,
return <NeutralRail planner={planner} canEdit={canEdit} onOpenWanderer={onOpenWanderer}
/>
if (planner.mode.type === 'view-gpx')
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} trackPaddlePlan={trackPaddlePlan} />
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist} />
const isSavedEdit = planner.mode.type === 'edit-saved'
@@ -368,16 +399,13 @@ export function TourPlannerRail({ planner,
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
{planner.isPaddle && <PaddleTimeCard planner={planner} locked={locked} />}
{planner.isPaddle && (
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
)}
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
<TourDetailsSection planner={planner} locked={locked} onDifficultyChange={changeDifficulty} />
</div>
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
<div className="flex flex-none flex-col gap-2 border-t border-edge-faint px-3 py-3">
{planner.isPaddle && <PaddleSafetyRow />}
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t('common.discard')}
@@ -32,10 +32,10 @@ describe('TourTypeFields (PAD-01)', () => {
fireEvent.click(screen.getByRole('button', { name: 'Lake' }));
expect(paddleTour.paddle.setBoatType).toHaveBeenCalledWith('canoe');
expect(paddleTour.paddle.setWaterContext).toHaveBeenCalledWith('lake');
// Two notes speak here: what the tour still needs, and what is known about obstacles on the water.
// What the tour still needs speaks here; the obstacles on the water moved into the checklist card.
const notes = screen.getAllByRole('status').map((note) => note.textContent);
expect(notes).toContain('Choose the boat and the water to save this paddle tour.');
expect(notes).toContain('Loading the obstacles along the route …');
expect(notes).not.toContain('Loading the obstacles along the route …');
});
it('reads a typed speed with either decimal separator, and an empty field as the suggestion', () => {
@@ -10,7 +10,6 @@ import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { PaddleObstacleNotice } from '../paddle/PaddleObstacleNotice';
import { TourNotice } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
@@ -161,8 +160,6 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
)
)}
</div>
{/* PAD-04/05B: what the map knows about the water, and where it knows nothing, never read as open water. */}
<PaddleObstacleNotice />
{planner.paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoat')}
+4 -4
View File
@@ -1,6 +1,6 @@
// A tour answer written to the cache (PAD-06): its control points, and where it
// came from and its checklist kept from the cached row when the answer lacks them.
import type { TourListItem } from '@trek/shared';
import type { TourListItem, TourProvenance } from '@trek/shared';
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -32,10 +32,10 @@ const waypoints = [
{ lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 },
{ lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 },
];
const provenance = {
origin: 'trek' as const,
const provenance: TourProvenance = {
origin: 'trek',
createdAt: null,
source: null,
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
+1 -1
View File
@@ -634,7 +634,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
onViewportChange={wandererBrowser.onViewportChange}
onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
@@ -1,10 +1,11 @@
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
import type { Ferrata } from '@trek/shared';
import type { Ferrata, PaddleFeature } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
@@ -89,6 +90,12 @@ vi.mock('../../components/Planner/LocationSelect', () => ({
}));
// What the layer asks the server is useTripFerratas' own suite; here it answers from memory.
vi.mock('../../components/Ferratas/useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
// The checklist's part of the card is ObstacleDecisionPanel's own suite; here it shows what it was handed.
vi.mock('../../components/Tours/paddle/ObstacleDecisionPanel', () => ({
ChecklistObstaclePanel: ({ checklist, featureRef }: { checklist: { status: string }; featureRef: string }) => (
<p data-testid="checklist-obstacle-panel">{`${checklist.status} ${featureRef}`}</p>
),
}));
const FERRATA: Ferrata = {
id: 'way/42',
@@ -193,6 +200,57 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe('the paddle checklist in the obstacle card (PAD-06)', () => {
type Checklist = NonNullable<TourPlannerMapStageProps['paddleChecklist']>;
const WEIR: PaddleFeature = {
ref: 'node/101',
kind: 'weir',
group_key: null,
name: 'Hammer weir',
geometry: { type: 'Point', coordinates: [7, 46] },
tags: { waterway: 'weir' },
tag_hash: 'weir-hash',
region_id: 'valais',
};
/** The weir in the obstacle store and picked, as a click on its map pin or a row of the checklist picks it. */
function pickWeir() {
act(() => {
publishPaddleObstacles({
status: 'ready',
features: [WEIR],
related: [],
routeLengthM: 0,
coverage: 'covered',
regions: [],
truncated: false,
});
selectPaddleObstacle(WEIR.ref);
});
}
afterEach(() => {
act(() => clearPaddleObstacles());
});
it('PCM-001: the picked object card carries the checklist part for that object', () => {
const paddleChecklist = { status: 'ready' } as unknown as Checklist;
render(<TourPlannerMapStage {...stageProps(planner('edit-saved'))} paddleChecklist={paddleChecklist} />);
pickWeir();
const panel = screen.getByTestId('checklist-obstacle-panel');
expect(screen.getByTestId('paddle-obstacle-overlay')).toContainElement(panel);
expect(panel).toHaveTextContent('ready node/101');
});
it('PCM-002: without a checklist the card shows the object alone', () => {
renderStage(planner('edit-saved'));
pickWeir();
expect(screen.getByTestId('paddle-obstacle-overlay')).toBeInTheDocument();
expect(screen.queryByTestId('checklist-obstacle-panel')).not.toBeInTheDocument();
});
});
describe('TourPlannerMapStage', () => {
it('FE-PAGE-TOURSTAGE-001: the flat map adds and moves control points while a tour is edited', () => {
const tourPlanner = planner('new-draft');
@@ -9,6 +9,7 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../componen
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
import { isRtlLanguage, useTranslation } from '../../i18n';
@@ -63,6 +64,8 @@ export interface TourPlannerMapStageProps
rightPanelPx: number;
/** Told the visible area too, besides the via ferrata layer (the wanderer area search). */
onViewportChange?: (bounds: FerrataBbox) => void;
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
paddleChecklist?: PageState['paddleChecklist'];
}
/**
@@ -83,6 +86,7 @@ export function TourPlannerMapStage({
leftPanelPx,
rightPanelPx,
onViewportChange,
paddleChecklist,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
@@ -245,6 +249,11 @@ export function TourPlannerMapStage({
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
extra={
paddleChecklist
? (detail) => <ChecklistObstaclePanel checklist={paddleChecklist} featureRef={detail.feature.ref} />
: undefined
}
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
@@ -1,6 +1,7 @@
import type { TourListItem } from '@trek/shared';
import { useCallback } from 'react';
import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion';
import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleChecklist';
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
@@ -32,6 +33,7 @@ export function useTourWorkspace(
tours,
tripActions,
tripId,
tripMembers,
upsertTour,
} = planner;
// wanderer only while the admin allows it under Tours (the store reads Tours being on into it too).
@@ -118,15 +120,28 @@ export function useTourWorkspace(
},
});
// A tour saved by a paddle card, its portages and locks or a checklist decision: lists and map take it in.
const takeInSavedTour = (tour: TourListItem) => {
upsertTour(tour);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
};
// PAD-03/PAD-07: portages and locks laid onto a recorded paddle track, without redrawing it.
const trackPaddlePlan = useTrackPaddlePlan({
tripId,
planner: tourPlanner,
canEdit: canPlaceEdit,
onSaved: (tour) => {
upsertTour(tour);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
},
onSaved: takeInSavedTour,
});
// PAD-06/PAD-09: the obstacle checklist of the paddle tour open in the planner.
const paddleChecklist = usePaddleChecklist({
tripId,
planner: tourPlanner,
tours,
tripMembers,
canEdit: canPlaceEdit,
active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
trackPaddlePlan,
onSaved: takeInSavedTour,
});
const tourDetailActions = useTourDetailActions(planner);
@@ -139,6 +154,7 @@ export function useTourWorkspace(
tourConversion,
recordedTrack,
trackPaddlePlan,
paddleChecklist,
tourDetailActions,
};
}
@@ -7,6 +7,7 @@ import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPort
import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync'
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
import { useMapLocationSearch } from './useMapLocationSearch'
import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
@@ -26,7 +27,7 @@ export function useTripPlannerPage() {
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, tourDetailActions } =
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, paddleChecklist, tourDetailActions } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
@@ -65,7 +66,9 @@ export function useTripPlannerPage() {
wandererBrowser.line,
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints)
// PAD-06: an obstacle or area the paddle checklist asks for moves the camera as a picked search result does.
const paddleFocus = usePaddleObstacleStore(state => state.focus)
const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints, paddleFocus)
const trackParts = useTrackPortageParts(tourPlanner)
// PAD-05B: the obstacles around the paddle tour in view, for the map and the rails alike.
@@ -143,6 +146,7 @@ export function useTripPlannerPage() {
tourConversion,
recordedTrack,
trackPaddlePlan,
paddleChecklist,
tourDetailActions,
tourMap: { route,
routeWalking,
@@ -0,0 +1,95 @@
// FE-STORE-PADDLE-001 to FE-STORE-PADDLE-004: the camera focus of the paddle obstacle store (PAD-06). A click in
// the checklist selects an object and frames it with the other parts of its lock; an area is framed as asked; each
// focus is numbered so the newest wins; publishing new objects keeps it, clearing the store drops it.
import type { PaddleFeature, PaddleGeometry } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import {
clearPaddleObstacles,
focusPaddleArea,
focusPaddleObstacle,
publishPaddleObstacles,
usePaddleObstacleStore,
} from './paddleObstacleStore';
const point = (lng: number, lat: number): PaddleGeometry => ({ type: 'Point', coordinates: [lng, lat] });
function feature(ref: string, geometry: PaddleGeometry, groupKey: string | null = null): PaddleFeature {
return {
ref,
kind: 'lock',
group_key: groupKey,
name: null,
geometry,
tags: {},
tag_hash: ref,
region_id: 'oberpfalz',
};
}
const LOCK = [
feature('node/3', point(12.001, 49.2), 'lock:node/3'),
feature('node/4', point(12.003, 49.201), 'lock:node/3'),
];
const WEIR = feature('node/9', point(12.01, 49.21));
function publish(features: PaddleFeature[]): void {
publishPaddleObstacles({
status: 'ready',
features,
related: [],
routeLengthM: 1000,
coverage: 'covered',
regions: [],
truncated: false,
});
}
beforeEach(() => {
clearPaddleObstacles();
});
describe('paddleObstacleStore focus', () => {
it('FE-STORE-PADDLE-001: focusing a lock part selects it and frames every part with a margin', () => {
publish([...LOCK, WEIR]);
expect(focusPaddleObstacle('node/4')).toBe(true);
const { selectedRef, focus } = usePaddleObstacleStore.getState();
expect(selectedRef).toBe('node/4');
expect(focus?.seq).toBe(1);
const [[south, west], [north, east]] = focus?.points ?? [
[0, 0],
[0, 0],
];
expect(south).toBeCloseTo(49.2 - 0.0015, 6);
expect(west).toBeCloseTo(12.001 - 0.0015, 6);
expect(north).toBeCloseTo(49.201 + 0.0015, 6);
expect(east).toBeCloseTo(12.003 + 0.0015, 6);
});
it('FE-STORE-PADDLE-002: an object the store does not hold is not focused', () => {
publish([WEIR]);
expect(focusPaddleObstacle('node/404')).toBe(false);
expect(usePaddleObstacleStore.getState()).toMatchObject({ selectedRef: null, focus: null });
});
it('FE-STORE-PADDLE-003: an area is framed as asked, each focus numbered after the last, none for no points', () => {
focusPaddleArea([[49.2, 12.005]]);
focusPaddleArea([]);
focusPaddleArea([
[49.2, 12],
[49.3, 12.1],
]);
const { focus } = usePaddleObstacleStore.getState();
expect(focus?.seq).toBe(2);
expect(focus?.points[1]?.[0]).toBeCloseTo(49.3015, 6);
});
it('FE-STORE-PADDLE-004: publishing new objects keeps the focus, clearing the store drops it', () => {
publish([WEIR]);
focusPaddleObstacle('node/9');
publish([WEIR, ...LOCK]);
expect(usePaddleObstacleStore.getState().focus?.seq).toBe(1);
clearPaddleObstacles();
expect(usePaddleObstacleStore.getState().focus).toBeNull();
});
});
@@ -4,7 +4,7 @@
* checklist this build cannot read never stalls the queue, a refused body is
* parked as failed and an obstacle data outage waits for a retry.
*/
import type { TourListItem } from '@trek/shared';
import type { TourListItem, TourProvenance } from '@trek/shared';
import 'fake-indexeddb/auto';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -45,10 +45,10 @@ const waypoints = [
{ lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 },
{ lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 },
];
const provenance = {
origin: 'trek' as const,
const provenance: TourProvenance = {
origin: 'trek',
createdAt: null,
source: null,
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,